clementine_core/
cli.rs

1//! # Command Line Interface
2//!
3//! This module defines command line interface for server binaries. `Clap` is used
4//! for easy generation of help messages and handling arguments.
5
6use crate::config::protocol::ProtocolParamset;
7use crate::config::protocol::ProtocolParamsetExt;
8use crate::config::BridgeConfig;
9use crate::utils::delayed_panic;
10use clap::Parser;
11use clementine_errors::BridgeError;
12use eyre::Context;
13use std::env;
14use std::path::PathBuf;
15use std::process;
16
17/// Available actor types that can be run as services
18#[derive(Debug, Clone, Copy, Eq, PartialEq)]
19pub enum Actor {
20    Verifier,
21    Operator,
22    Aggregator,
23    TestActor,
24}
25
26/// Clementine (C) 2025 Chainway Limited
27#[derive(Parser, Debug, Clone)]
28#[command(version, about, long_about = None)]
29pub struct Args {
30    /// Verbosity level, ranging from 0 (none) to 5 (highest)
31    #[arg(short, long, default_value_t = 3, global = true)]
32    pub verbose: u8,
33
34    /// TOML formatted configuration file.
35    #[arg(short, long, global = true)]
36    pub config: Option<PathBuf>,
37
38    /// TOML formatted protocol parameters file.
39    #[arg(short, long, global = true)]
40    pub protocol_params: Option<PathBuf>,
41
42    #[command(subcommand)]
43    pub command: Command,
44}
45
46#[derive(Debug, Clone, clap::Subcommand)]
47pub enum Command {
48    /// Run the verifier service
49    Verifier,
50    /// Run the operator service
51    Operator,
52    /// Run the aggregator service
53    Aggregator,
54    /// Run the test actor (for health checks)
55    TestActor,
56    /// Generate BitVM cache files
57    GenerateBitvmCache,
58}
59
60/// Parse given iterator with our clap Args and handle help/version cases.
61///
62/// Returns: (status code, message)
63/// If the status code != 0, the message is an error message.
64///
65/// Cases:
66/// - On successful parse, returns `Ok(Args)`
67/// - On help/version, returns `Err((0, msg))`
68/// - On general errors, returns `Err((1, formatted_error_msg))`
69fn parse_cli_args<I, T>(itr: I) -> Result<Args, (i32, String)>
70where
71    I: IntoIterator<Item = T>,
72    T: Into<std::ffi::OsString> + Clone,
73{
74    match Args::try_parse_from(itr) {
75        Ok(c) => Ok(c),
76        Err(e)
77            if matches!(
78                e.kind(),
79                clap::error::ErrorKind::DisplayHelp
80                    | clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
81                    | clap::error::ErrorKind::DisplayVersion
82            ) =>
83        {
84            Err((0, e.to_string()))
85        }
86        Err(e) => Err((
87            1,
88            format!(
89                "Failed to parse CLI arguments: {}",
90                BridgeError::ConfigError(e.to_string())
91            ),
92        )),
93    }
94}
95
96/// Returns CLI arguments after handling help/version cases with BridgeError.
97///
98/// If there are any errors or help/version display requests, prints and exits.
99pub fn get_cli_args() -> Args {
100    match parse_cli_args(env::args()) {
101        Ok(args) => args,
102        Err((code, msg)) => {
103            if code == 0 {
104                println!("{msg}");
105            } else {
106                eprintln!("{msg}");
107            }
108            process::exit(code);
109        }
110    }
111}
112
113#[derive(Debug, Clone, Eq, PartialEq)]
114pub enum ConfigSource {
115    File(PathBuf),
116    Env,
117}
118
119/// Selects a configuration source for the main config or the protocol paramset.
120///
121/// Configuration can be loaded either from a file specified by a path in the CLI args,
122/// or from environment variables.
123///
124/// Selection logic is as follows:
125///
126/// 1. If the named environment variable (eg. `READ_CONFIG_FROM_ENV`) is not set
127///    or if the named environment variable is set to `0` or `off`, we use the file
128///    path provided in the CLI args (fail if not provided)
129///
130/// 2. If the named environment variable is set to `1` or `on`, we explicitly read from the
131///    environment variable
132///
133/// 3. If the named environment variable is set to an unknown value, we print a
134///    warning and default to environment variables
135///
136/// # Examples
137///
138/// ```bash
139/// # Load config from a file and protocol params from a file
140/// READ_CONFIG_FROM_ENV=0 READ_PARAMSET_FROM_ENV=0 clementine-core verifier --config /path/to/config.toml --protocol-params /path/to/protocol-params.toml
141///
142/// # or
143/// # define all config variables in the environment
144/// export CONFIG_ONE=1
145/// export PARAM_ONE=1
146/// # and source from environment variables
147/// READ_CONFIG_FROM_ENV=1 READ_PARAMSET_FROM_ENV=1 clementine-core verifier
148///
149/// # or
150/// # source paramset from environment variables but use config from a file
151/// export PARAM_ONE=1
152/// export PARAM_TWO=1
153/// READ_CONFIG_FROM_ENV=0 READ_PARAMSET_FROM_ENV=1 clementine-core verifier --config /path/to/config.toml
154///
155/// # WRONG usage (will use environment variables for both config and paramset)
156/// export CONFIG_ONE=1
157/// export PARAM_ONE=1
158/// READ_CONFIG_FROM_ENV=1 READ_PARAMSET_FROM_ENV=1 clementine-core --config /path/to/config.toml --protocol-params /path/to/protocol-params.toml
159/// ```
160fn get_config_source(
161    read_from_env_name: &'static str,
162    provided_arg: Option<PathBuf>,
163) -> Result<ConfigSource, BridgeError> {
164    Ok(match std::env::var(read_from_env_name) {
165        Err(_) => ConfigSource::File(provided_arg.ok_or(BridgeError::ConfigError(
166            "No file path or environment variable provided for config file.".to_string(),
167        ))?),
168        Ok(str) if str == "0" || str == "off" => ConfigSource::File(provided_arg.ok_or(
169            BridgeError::ConfigError("No file path provided for config file.".to_string()),
170        )?),
171        Ok(str) => {
172            if str != "1" && str != "on" {
173                tracing::warn!("Unknown value for {read_from_env_name}: {str}. Expected 1/0/off/on. Defaulting to environment variables.");
174            }
175
176            if provided_arg.is_some() {
177                tracing::warn!("File path provided in CLI arguments while {read_from_env_name} is set to 1. Ignoring provided file path and reading from environment variables.");
178            }
179
180            ConfigSource::Env
181        }
182    })
183}
184
185/// Gets configuration using CLI arguments, for binaries. If there are any errors, prints
186/// error and panics.
187///
188/// Steps:
189///
190/// 1. Get CLI arguments
191/// 2. Initialize logger
192/// 3. Get configuration, either from environment variables or
193///    configuration file
194/// 4. Get protocol parameters, either from environment variables or
195///    protocol parameters file
196///
197/// # Returns
198///
199/// A tuple, containing:
200///
201/// - [`BridgeConfig`] from CLI argument
202/// - [`Args`] from CLI options
203pub fn get_config(args: Args) -> BridgeConfig {
204    match get_config_from_args(args) {
205        Ok(config) => config,
206        Err(e) => delayed_panic!("Failed to load configuration: {e:?}"),
207    }
208}
209
210/// Wrapped function for tests
211fn get_config_from_args(args: Args) -> Result<BridgeConfig, BridgeError> {
212    let config_source = get_config_source("READ_CONFIG_FROM_ENV", args.config.clone());
213
214    let mut config =
215        match config_source.wrap_err("Failed to determine source for configuration.")? {
216            ConfigSource::File(config_file) => {
217                // Read from configuration file ONLY
218                BridgeConfig::try_parse_file(config_file)
219                    .wrap_err("Failed to read configuration from file.")?
220            }
221            ConfigSource::Env => BridgeConfig::from_env()
222                .wrap_err("Failed to read configuration from environment variables.")?,
223        };
224
225    let protocol_params_source =
226        get_config_source("READ_PARAMSET_FROM_ENV", args.protocol_params.clone())
227            .wrap_err("Failed to determine source for protocol parameters.")?;
228
229    // Leaks memory to get a static reference to the paramset
230    // This is needed to reduce copies of the protocol paramset when passing it around.
231    // This is fine, since this will only run once in the lifetime of the program.
232    let paramset: &'static ProtocolParamset = Box::leak(Box::new(match protocol_params_source {
233        ConfigSource::File(path) => ProtocolParamset::from_toml_file(path.as_path())
234            .wrap_err("Failed to read protocol parameters from file.")?,
235        ConfigSource::Env => ProtocolParamset::from_env()
236            .wrap_err("Failed to read protocol parameters from environment.")?,
237    }));
238
239    // The default will be REGTEST_PARAMSET and is overridden from the selected source above.
240    config.protocol_paramset = paramset;
241
242    Ok(config)
243}
244
245#[cfg(test)]
246mod tests {
247    use super::{get_config_from_args, get_config_source, parse_cli_args, Command, ConfigSource};
248    use clementine_errors::BridgeError;
249    use std::env;
250    use std::fs::File;
251    use std::io::Write;
252    use std::path::PathBuf;
253
254    /// With help message flag, we should see the help message. Shocking.
255    #[test]
256    fn help_message() {
257        match parse_cli_args(vec!["clementine-core", "--help"]) {
258            Ok(_) => panic!("expected configuration error"),
259            Err((0, _)) => {}
260            e => panic!("unexpected error {e:#?}"),
261        }
262    }
263
264    /// With version flag, we should see the program version read from
265    /// `Cargo.toml`.
266    #[test]
267    fn version() {
268        match parse_cli_args(vec!["clementine-core", "--version"]) {
269            Ok(_) => panic!("expected configuration error"),
270            Err((0, _)) => {}
271            e => panic!("unexpected error {e:#?}"),
272        }
273    }
274
275    // Helper function to set and unset environment variables for tests
276    fn with_env_var<F, T>(name: &str, value: Option<&str>, test: F) -> T
277    where
278        F: FnOnce() -> T,
279    {
280        let prev_value = env::var(name).ok();
281        match value {
282            Some(val) => env::set_var(name, val),
283            None => env::remove_var(name),
284        }
285        let result = test();
286        match prev_value {
287            Some(val) => env::set_var(name, val),
288            None => env::remove_var(name),
289        }
290        result
291    }
292
293    #[test]
294    #[serial_test::serial]
295    fn test_get_config_source_env_not_set() {
296        with_env_var("TEST_READ_FROM_ENV", None, || {
297            let path = PathBuf::from("/path/to/config");
298            let result = get_config_source("TEST_READ_FROM_ENV", Some(path.clone()));
299            assert_eq!(result.unwrap(), ConfigSource::File(path));
300
301            // When path is not provided, should return error
302            let result = get_config_source("TEST_READ_FROM_ENV", None);
303            assert!(result.is_err());
304            assert!(matches!(result.unwrap_err(), BridgeError::ConfigError(_)));
305        })
306    }
307
308    #[test]
309    #[serial_test::serial]
310    fn test_get_config_source_env_set_to_off() {
311        // Test with "0"
312        with_env_var("TEST_READ_FROM_ENV", Some("0"), || {
313            let path = PathBuf::from("/path/to/config");
314            let result = get_config_source("TEST_READ_FROM_ENV", Some(path.clone()));
315            assert_eq!(result.unwrap(), ConfigSource::File(path));
316
317            // When path is not provided, should return error
318            let result = get_config_source("TEST_READ_FROM_ENV", None);
319            assert!(result.is_err());
320        });
321
322        // Test with "off"
323        with_env_var("TEST_READ_FROM_ENV", Some("off"), || {
324            let path = PathBuf::from("/path/to/config");
325            let result = get_config_source("TEST_READ_FROM_ENV", Some(path.clone()));
326            assert_eq!(result.unwrap(), ConfigSource::File(path));
327        })
328    }
329
330    #[test]
331    #[serial_test::serial]
332    fn test_get_config_source_env_set_to_on() {
333        // Test with "1"
334        with_env_var("TEST_READ_FROM_ENV", Some("1"), || {
335            let result = get_config_source("TEST_READ_FROM_ENV", None);
336            assert_eq!(result.unwrap(), ConfigSource::Env);
337
338            // Even if path is provided, should still return Env
339            let path = PathBuf::from("/path/to/config");
340            let result = get_config_source("TEST_READ_FROM_ENV", Some(path));
341            assert_eq!(result.unwrap(), ConfigSource::Env);
342        });
343
344        // Test with "on"
345        with_env_var("TEST_READ_FROM_ENV", Some("on"), || {
346            let result = get_config_source("TEST_READ_FROM_ENV", None);
347            assert_eq!(result.unwrap(), ConfigSource::Env);
348        })
349    }
350
351    #[test]
352    #[serial_test::serial]
353    fn test_get_config_source_env_unknown_value() {
354        with_env_var("TEST_READ_FROM_ENV", Some("invalid"), || {
355            let result = get_config_source("TEST_READ_FROM_ENV", None);
356            assert_eq!(result.unwrap(), ConfigSource::Env);
357        })
358    }
359
360    // Helper to create a temporary config file
361    fn with_temp_config_file<F, T>(content: &str, test: F) -> T
362    where
363        F: FnOnce(PathBuf) -> T,
364    {
365        let temp_dir = tempfile::tempdir().unwrap();
366        let file_path = temp_dir.path().join("bridge_config.toml");
367
368        let mut file = File::create(&file_path).unwrap();
369        file.write_all(content.as_bytes()).unwrap();
370
371        let result = test(file_path);
372        temp_dir.close().unwrap();
373        result
374    }
375
376    // Helper to set up all environment variables needed for config
377    fn setup_config_env_vars() {
378        env::set_var("HOST", "127.0.0.1");
379        env::set_var("PORT", "17000");
380        env::set_var(
381            "SECRET_KEY",
382            "1111111111111111111111111111111111111111111111111111111111111111",
383        );
384        env::set_var("OPERATOR_WITHDRAWAL_FEE_SATS", "100000");
385        env::set_var("BITCOIN_RPC_URL", "http://127.0.0.1:18443/wallet/admin");
386        env::set_var("BITCOIN_RPC_USER", "admin");
387        env::set_var("BITCOIN_RPC_PASSWORD", "admin");
388        env::set_var("DB_HOST", "127.0.0.1");
389        env::set_var("DB_PORT", "5432");
390        env::set_var("DB_USER", "clementine");
391        env::set_var("DB_PASSWORD", "clementine");
392        env::set_var("DB_NAME", "clementine");
393        env::set_var("CITREA_RPC_URL", "");
394        env::set_var("CITREA_LIGHT_CLIENT_PROVER_URL", "");
395        env::set_var("CITREA_CHAIN_ID", "5655");
396        env::set_var(
397            "BRIDGE_CONTRACT_ADDRESS",
398            "3100000000000000000000000000000000000002",
399        );
400        env::set_var("SERVER_CERT_PATH", "certs/server/server.pem");
401        env::set_var("SERVER_KEY_PATH", "certs/server/server.key");
402        env::set_var("CA_CERT_PATH", "certs/ca/ca.pem");
403        env::set_var("CLIENT_CERT_PATH", "certs/client/client.pem");
404        env::set_var("CLIENT_KEY_PATH", "certs/client/client.key");
405        env::set_var("AGGREGATOR_CERT_PATH", "certs/aggregator/aggregator.pem");
406        env::set_var("CLIENT_VERIFICATION", "true");
407        env::set_var(
408            "SECURITY_COUNCIL",
409            "1:50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0",
410        );
411
412        env::set_var("TELEMETRY_HOST", "0.0.0.0");
413        env::set_var("TELEMETRY_PORT", "8081");
414        env::set_var("TX_SENDER_FEE_RATE_HARD_CAP", "100");
415        env::set_var("TX_SENDER_MEMPOOL_FEE_RATE_MULTIPLIER", "1");
416        env::set_var("TX_SENDER_MEMPOOL_FEE_RATE_OFFSET_SAT_KVB", "0");
417        env::set_var("TX_SENDER_CPFP_FEE_PAYER_BUMP_WAIT_TIME_SECONDS", "3600");
418        env::set_var("TIME_TO_SEND_WATCHTOWER_CHALLENGE", "216");
419    }
420
421    // Helper to set up all environment variables needed for protocol paramset
422    fn setup_protocol_paramset_env_vars() {
423        env::set_var("NETWORK", "regtest");
424        env::set_var("NUM_ROUND_TXS", "2");
425        env::set_var("NUM_KICKOFFS_PER_ROUND", "10");
426        env::set_var("NUM_SIGNED_KICKOFFS", "2");
427        env::set_var("BRIDGE_AMOUNT", "1000000000");
428        env::set_var("KICKOFF_AMOUNT", "0");
429        env::set_var("OPERATOR_CHALLENGE_AMOUNT", "200000000");
430        env::set_var("COLLATERAL_FUNDING_AMOUNT", "99000000");
431        env::set_var("KICKOFF_BLOCKHASH_COMMIT_LENGTH", "40");
432        env::set_var("WATCHTOWER_CHALLENGE_BYTES", "144");
433        env::set_var("WINTERNITZ_LOG_D", "4");
434        env::set_var("USER_TAKES_AFTER", "200");
435        env::set_var("OPERATOR_CHALLENGE_TIMEOUT_TIMELOCK", "144");
436        env::set_var("OPERATOR_CHALLENGE_NACK_TIMELOCK", "432");
437        env::set_var("DISPROVE_TIMEOUT_TIMELOCK", "720");
438        env::set_var("ASSERT_TIMEOUT_TIMELOCK", "576");
439        env::set_var("OPERATOR_REIMBURSE_TIMELOCK", "12");
440        env::set_var("WATCHTOWER_CHALLENGE_TIMEOUT_TIMELOCK", "288");
441        env::set_var("LATEST_BLOCKHASH_TIMEOUT_TIMELOCK", "360");
442        env::set_var("FINALITY_DEPTH", "1");
443        env::set_var("START_HEIGHT", "8148");
444        env::set_var("GENESIS_HEIGHT", "0");
445        env::set_var(
446            "GENESIS_CHAIN_STATE_HASH",
447            "5f7302ad16c8bd9ef2f3be00c8199a86f9e0ba861484abb4af5f7e457f8c2216",
448        );
449        env::set_var("HEADER_CHAIN_PROOF_BATCH_SIZE", "100");
450        env::set_var("BRIDGE_NONSTANDARD", "true");
451    }
452
453    // Helper to clean up all environment variables
454    fn cleanup_config_env_vars() {
455        env::remove_var("HOST");
456        env::remove_var("PORT");
457        env::remove_var("SECRET_KEY");
458        env::remove_var("OPERATOR_WITHDRAWAL_FEE_SATS");
459        env::remove_var("BITCOIN_RPC_URL");
460        env::remove_var("BITCOIN_RPC_USER");
461        env::remove_var("BITCOIN_RPC_PASSWORD");
462        env::remove_var("DB_HOST");
463        env::remove_var("DB_PORT");
464        env::remove_var("DB_USER");
465        env::remove_var("DB_PASSWORD");
466        env::remove_var("DB_NAME");
467        env::remove_var("CITREA_RPC_URL");
468        env::remove_var("CITREA_LIGHT_CLIENT_PROVER_URL");
469        env::remove_var("BRIDGE_CONTRACT_ADDRESS");
470        env::remove_var("SERVER_CERT_PATH");
471        env::remove_var("SERVER_KEY_PATH");
472        env::remove_var("CA_CERT_PATH");
473        env::remove_var("CLIENT_CERT_PATH");
474        env::remove_var("CLIENT_KEY_PATH");
475        env::remove_var("AGGREGATOR_CERT_PATH");
476        env::remove_var("CLIENT_VERIFICATION");
477        env::remove_var("SECURITY_COUNCIL");
478        env::remove_var("TELEMETRY_HOST");
479        env::remove_var("TELEMETRY_PORT");
480        env::remove_var("TIME_TO_SEND_WATCHTOWER_CHALLENGE");
481    }
482
483    // Helper to clean up all protocol paramset environment variables
484    fn cleanup_protocol_paramset_env_vars() {
485        env::remove_var("NETWORK");
486        env::remove_var("NUM_ROUND_TXS");
487        env::remove_var("NUM_KICKOFFS_PER_ROUND");
488        env::remove_var("NUM_SIGNED_KICKOFFS");
489        env::remove_var("BRIDGE_AMOUNT");
490        env::remove_var("KICKOFF_AMOUNT");
491        env::remove_var("OPERATOR_CHALLENGE_AMOUNT");
492        env::remove_var("COLLATERAL_FUNDING_AMOUNT");
493        env::remove_var("KICKOFF_BLOCKHASH_COMMIT_LENGTH");
494        env::remove_var("WATCHTOWER_CHALLENGE_BYTES");
495        env::remove_var("WINTERNITZ_LOG_D");
496        env::remove_var("USER_TAKES_AFTER");
497        env::remove_var("OPERATOR_CHALLENGE_TIMEOUT_TIMELOCK");
498        env::remove_var("OPERATOR_CHALLENGE_NACK_TIMELOCK");
499        env::remove_var("DISPROVE_TIMEOUT_TIMELOCK");
500        env::remove_var("ASSERT_TIMEOUT_TIMELOCK");
501        env::remove_var("OPERATOR_REIMBURSE_TIMELOCK");
502        env::remove_var("WATCHTOWER_CHALLENGE_TIMEOUT_TIMELOCK");
503        env::remove_var("FINALITY_DEPTH");
504        env::remove_var("START_HEIGHT");
505        env::remove_var("HEADER_CHAIN_PROOF_BATCH_SIZE");
506    }
507
508    // Basic minimum toml config content
509    const MINIMAL_CONFIG_CONTENT: &str = include_str!("test/data/bridge_config.toml");
510
511    #[test]
512    #[serial_test::serial]
513    fn test_get_cli_config_file_mode() {
514        with_env_var("READ_CONFIG_FROM_ENV", Some("0"), || {
515            with_temp_config_file(MINIMAL_CONFIG_CONTENT, |config_path| {
516                // Create a temp protocol paramset file
517                with_temp_config_file(
518                    include_str!("./test/data/protocol_paramset.toml"),
519                    |protocol_path| {
520                        let args = vec![
521                            "clementine-core",
522                            "verifier",
523                            "--config",
524                            config_path.to_str().unwrap(),
525                            "--protocol-params",
526                            protocol_path.to_str().unwrap(),
527                        ];
528
529                        let cli_args = parse_cli_args(args).expect("Failed to parse CLI arguments");
530                        let result = get_config_from_args(cli_args.clone());
531
532                        let config = result.expect("Failed to get CLI config");
533                        assert_eq!(config.host, "127.0.0.1");
534                        assert_eq!(config.port, 17000);
535                        assert!(matches!(cli_args.command, Command::Verifier));
536
537                        // Assert some protocol paramset values
538                        assert_eq!(config.protocol_paramset.network.to_string(), "regtest");
539                        assert_eq!(config.protocol_paramset.num_round_txs, 2);
540                        assert_eq!(config.protocol_paramset.winternitz_log_d, 4);
541                    },
542                )
543            })
544        })
545    }
546
547    #[test]
548    #[serial_test::serial]
549    fn test_get_cli_config_env_mode() {
550        setup_config_env_vars();
551        setup_protocol_paramset_env_vars();
552
553        with_env_var("READ_CONFIG_FROM_ENV", Some("1"), || {
554            with_env_var("READ_PARAMSET_FROM_ENV", Some("1"), || {
555                let args = vec!["clementine-core", "operator"];
556
557                let cli_args = parse_cli_args(args).expect("Failed to parse CLI arguments");
558                let result = get_config_from_args(cli_args.clone());
559
560                let config = result.expect("Failed to get CLI config");
561                assert_eq!(config.host, "127.0.0.1");
562                assert_eq!(config.port, 17000);
563                assert!(matches!(cli_args.command, Command::Operator));
564
565                // Assert some protocol paramset values
566                assert_eq!(config.protocol_paramset.network.to_string(), "regtest");
567                assert_eq!(config.protocol_paramset.num_round_txs, 2);
568                assert_eq!(config.protocol_paramset.winternitz_log_d, 4);
569                assert_eq!(config.protocol_paramset.start_height, 8148); // This should be from the environment variable
570            });
571        });
572
573        cleanup_config_env_vars();
574        cleanup_protocol_paramset_env_vars();
575    }
576
577    #[test]
578    #[serial_test::serial]
579    fn test_mixed_config_sources() {
580        // Set up config from file but protocol paramset from env
581        setup_protocol_paramset_env_vars();
582
583        with_env_var("READ_CONFIG_FROM_ENV", Some("0"), || {
584            with_env_var("READ_PARAMSET_FROM_ENV", Some("1"), || {
585                with_temp_config_file(MINIMAL_CONFIG_CONTENT, |config_path| {
586                    let args = vec![
587                        "clementine-core",
588                        "verifier",
589                        "--config",
590                        config_path.to_str().unwrap(),
591                    ];
592
593                    let cli_args = parse_cli_args(args).expect("Failed to parse CLI arguments");
594                    let result = get_config_from_args(cli_args.clone());
595
596                    let config = result.expect("Failed to get CLI config");
597                    assert_eq!(config.host, "127.0.0.1");
598                    assert_eq!(config.port, 17000);
599                    assert!(matches!(cli_args.command, Command::Verifier));
600
601                    // Assert some protocol paramset values from env
602                    assert_eq!(config.protocol_paramset.network.to_string(), "regtest");
603                    assert_eq!(config.protocol_paramset.start_height, 8148); // This should be from the environment variable
604                })
605            })
606        });
607
608        cleanup_protocol_paramset_env_vars();
609    }
610
611    #[test]
612    #[serial_test::serial]
613    fn test_get_cli_config_file_without_path() {
614        with_env_var("READ_CONFIG_FROM_ENV", Some("0"), || {
615            let args = vec!["clementine-core", "verifier"];
616
617            let cli_args = parse_cli_args(args).expect("Failed to parse CLI arguments");
618            let result = get_config_from_args(cli_args.clone());
619            result.expect_err("Expected error when config file path is not provided");
620        })
621    }
622
623    #[test]
624    fn test_parse_cli_args_success() {
625        // A minimal, valid invocation: program name + required actor
626        let args = vec!["clementine-core", "verifier"];
627
628        let result = parse_cli_args(args);
629        let parsed = result.expect("Expected successful CLI parsing");
630
631        assert!(matches!(parsed.command, Command::Verifier));
632        // Default verbosity should be 3
633        assert_eq!(parsed.verbose, 3);
634        // No config/protocol-params paths provided
635        assert!(parsed.config.is_none());
636        assert!(parsed.protocol_params.is_none());
637    }
638
639    #[test]
640    fn test_parse_cli_args_help_and_version_exit_code_zero() {
641        // --help should map to a exit code 0 with help message
642        let help_args = vec!["clementine-core", "--help"];
643        let help_result = parse_cli_args(help_args);
644        let (help_code, help_msg) =
645            help_result.expect_err("Expected help invocation to request exit");
646        assert_eq!(help_code, 0);
647        assert!(
648            help_msg.contains("Usage") || help_msg.contains("USAGE"),
649            "help output should contain usage text, got: {help_msg}"
650        );
651
652        // --version should also map to exit code 0 with version message
653        let version_args = vec!["clementine-core", "--version"];
654        let version_result = parse_cli_args(version_args);
655        let (version_code, version_msg) =
656            version_result.expect_err("Expected version invocation to request exit");
657        assert_eq!(version_code, 0);
658        // The exact version string is dynamic; just assert it's non-empty
659        assert!(
660            !version_msg.trim().is_empty(),
661            "version output should not be empty"
662        );
663    }
664
665    #[test]
666    fn test_parse_cli_args_invalid_args_exit_code_one() {
667        // Unknown flag should be treated as a general error, leading to exit code 1
668        let args = vec!["clementine-core", "--unknown-flag"];
669
670        let result = parse_cli_args(args);
671        let (code, msg) = result.expect_err("Expected invalid arguments to cause an error");
672
673        assert_eq!(code, 1);
674        assert!(
675            msg.contains("Failed to parse CLI arguments"),
676            "error message should include prefix, got: {msg}"
677        );
678        assert!(
679            msg.contains("unexpected") || msg.contains("Unknown") || msg.contains("unknown"),
680            "underlying clap error should be included in message, got: {msg}"
681        );
682    }
683}