Skip to main content

reth_ethereum_cli/
interface.rs

1//! CLI definition and entrypoint to executable
2
3use crate::{app::CliApp, chainspec::EthereumChainSpecParser};
4use clap::{Parser, Subcommand};
5use reth_chainspec::{ChainSpec, Hardforks};
6use reth_cli::chainspec::ChainSpecParser;
7use reth_cli_commands::{
8    common::{CliComponentsBuilder, CliNodeTypes, HeaderMut},
9    config_cmd, db, download,
10    download::manifest_cmd,
11    dump_genesis, export_era, import, import_era, init_cmd, init_state,
12    launcher::FnLauncher,
13    node::{self, NoArgs},
14    p2p, prune, re_execute, stage,
15};
16use reth_cli_runner::CliRunner;
17use reth_db::DatabaseEnv;
18use reth_node_api::NodePrimitives;
19use reth_node_builder::{NodeBuilder, WithLaunchContext};
20use reth_node_core::{
21    args::{LogArgs, OtlpInitStatus, OtlpLogsStatus, TraceArgs},
22    version::version_metadata,
23};
24use reth_rpc_server_types::{DefaultRpcModuleValidator, RethRpcModule, RpcModuleValidator};
25use reth_tracing::{FileWorkerGuard, Layers};
26use std::{ffi::OsString, fmt, future::Future, marker::PhantomData, sync::Arc};
27use tracing::{info, warn};
28
29/// The main reth cli interface.
30///
31/// This is the entrypoint to the executable.
32#[derive(Debug, Parser)]
33#[command(author, name = version_metadata().name_client.as_ref(), version = version_metadata().short_version.as_ref(), long_version = version_metadata().long_version.as_ref(), about = "Reth", long_about = None)]
34pub struct Cli<
35    C: ChainSpecParser = EthereumChainSpecParser,
36    Ext: clap::Args + fmt::Debug = NoArgs,
37    Rpc: RpcModuleValidator = DefaultRpcModuleValidator,
38    SubCmd: Subcommand + fmt::Debug = NoSubCmd,
39> {
40    /// The command to run
41    #[command(subcommand)]
42    pub command: Commands<C, Ext, SubCmd>,
43
44    /// The logging configuration for the CLI.
45    #[command(flatten)]
46    pub logs: LogArgs,
47
48    /// The tracing configuration for the CLI.
49    #[command(flatten)]
50    pub traces: TraceArgs,
51
52    /// Type marker for the RPC module validator
53    #[arg(skip)]
54    pub _phantom: PhantomData<Rpc>,
55}
56
57impl Cli {
58    /// Parsers only the default CLI arguments
59    pub fn parse_args() -> Self {
60        Self::parse()
61    }
62
63    /// Parsers only the default CLI arguments from the given iterator
64    pub fn try_parse_args_from<I, T>(itr: I) -> Result<Self, clap::error::Error>
65    where
66        I: IntoIterator<Item = T>,
67        T: Into<OsString> + Clone,
68    {
69        Self::try_parse_from(itr)
70    }
71}
72
73impl<
74        C: ChainSpecParser,
75        Ext: clap::Args + fmt::Debug,
76        Rpc: RpcModuleValidator,
77        SubCmd: crate::app::ExtendedCommand + Subcommand + fmt::Debug,
78    > Cli<C, Ext, Rpc, SubCmd>
79{
80    /// Configures the CLI and returns a [`CliApp`] instance.
81    ///
82    /// This method is used to prepare the CLI for execution by wrapping it in a
83    /// [`CliApp`] that can be further configured before running.
84    pub fn configure(self) -> CliApp<C, Ext, Rpc, SubCmd> {
85        CliApp::new(self)
86    }
87
88    /// Execute the configured cli command.
89    ///
90    /// This accepts a closure that is used to launch the node via the
91    /// [`NodeCommand`](node::NodeCommand).
92    ///
93    /// This command will be run on the default tokio runtime.
94    ///
95    ///
96    /// # Example
97    ///
98    /// ```no_run
99    /// use reth_ethereum_cli::interface::Cli;
100    /// use reth_node_ethereum::EthereumNode;
101    ///
102    /// Cli::parse_args()
103    ///     .run(async move |builder, _| {
104    ///         let handle = builder.launch_node(EthereumNode::default()).await?;
105    ///
106    ///         handle.wait_for_node_exit().await
107    ///     })
108    ///     .unwrap();
109    /// ```
110    ///
111    /// # Example
112    ///
113    /// Parse additional CLI arguments for the node command and use it to configure the node.
114    ///
115    /// ```no_run
116    /// use clap::Parser;
117    /// use reth_ethereum_cli::{chainspec::EthereumChainSpecParser, interface::Cli};
118    ///
119    /// #[derive(Debug, Parser)]
120    /// pub struct MyArgs {
121    ///     pub enable: bool,
122    /// }
123    ///
124    /// Cli::<EthereumChainSpecParser, MyArgs>::parse()
125    ///     .run(async move |builder, my_args: MyArgs|
126    ///         // launch the node
127    ///         Ok(()))
128    ///     .unwrap();
129    /// ````
130    pub fn run<L, Fut>(self, launcher: L) -> eyre::Result<()>
131    where
132        L: FnOnce(WithLaunchContext<NodeBuilder<DatabaseEnv, C::ChainSpec>>, Ext) -> Fut,
133        Fut: Future<Output = eyre::Result<()>>,
134        C: ChainSpecParser<ChainSpec = ChainSpec>,
135    {
136        self.configure()
137            .run(FnLauncher::new::<C, Ext>(async move |builder, ext| launcher(builder, ext).await))
138    }
139
140    /// Execute the configured cli command with the provided [`CliComponentsBuilder`].
141    ///
142    /// This accepts a closure that is used to launch the node via the
143    /// [`NodeCommand`](node::NodeCommand).
144    ///
145    /// This command will be run on the default tokio runtime.
146    pub fn run_with_components<N>(
147        self,
148        components: impl CliComponentsBuilder<N>,
149        launcher: impl AsyncFnOnce(
150            WithLaunchContext<NodeBuilder<DatabaseEnv, C::ChainSpec>>,
151            Ext,
152        ) -> eyre::Result<()>,
153    ) -> eyre::Result<()>
154    where
155        N: CliNodeTypes<Primitives: NodePrimitives<BlockHeader: HeaderMut>, ChainSpec: Hardforks>,
156        C: ChainSpecParser<ChainSpec = N::ChainSpec>,
157    {
158        self.configure().run_with_components(components, launcher)
159    }
160
161    /// Execute the configured cli command with the provided [`CliRunner`].
162    ///
163    ///
164    /// # Example
165    ///
166    /// ```no_run
167    /// use reth_cli_runner::CliRunner;
168    /// use reth_ethereum_cli::interface::Cli;
169    /// use reth_node_ethereum::EthereumNode;
170    ///
171    /// let runner = CliRunner::try_default_runtime().unwrap();
172    ///
173    /// Cli::parse_args()
174    ///     .with_runner(runner, |builder, _| async move {
175    ///         let handle = builder.launch_node(EthereumNode::default()).await?;
176    ///         handle.wait_for_node_exit().await
177    ///     })
178    ///     .unwrap();
179    /// ```
180    pub fn with_runner<L, Fut>(self, runner: CliRunner, launcher: L) -> eyre::Result<()>
181    where
182        L: FnOnce(WithLaunchContext<NodeBuilder<DatabaseEnv, C::ChainSpec>>, Ext) -> Fut,
183        Fut: Future<Output = eyre::Result<()>>,
184        C: ChainSpecParser<ChainSpec = ChainSpec>,
185    {
186        let mut app = self.configure();
187        app.set_runner(runner);
188        app.run(FnLauncher::new::<C, Ext>(async move |builder, ext| launcher(builder, ext).await))
189    }
190
191    /// Execute the configured cli command with the provided [`CliRunner`] and
192    /// [`CliComponentsBuilder`].
193    pub fn with_runner_and_components<N>(
194        self,
195        runner: CliRunner,
196        components: impl CliComponentsBuilder<N>,
197        launcher: impl AsyncFnOnce(
198            WithLaunchContext<NodeBuilder<DatabaseEnv, C::ChainSpec>>,
199            Ext,
200        ) -> eyre::Result<()>,
201    ) -> eyre::Result<()>
202    where
203        N: CliNodeTypes<Primitives: NodePrimitives<BlockHeader: HeaderMut>, ChainSpec: Hardforks>,
204        C: ChainSpecParser<ChainSpec = N::ChainSpec>,
205    {
206        let mut app = self.configure();
207        app.set_runner(runner);
208        app.run_with_components(components, launcher)
209    }
210
211    /// Initializes tracing with the configured options.
212    ///
213    /// If file logging is enabled, this function returns a guard that must be kept alive to ensure
214    /// that all logs are flushed to disk.
215    ///
216    /// If an OTLP endpoint is specified, it will export traces and logs to the configured
217    /// collector.
218    pub fn init_tracing(
219        &mut self,
220        runner: &CliRunner,
221        mut layers: Layers,
222    ) -> eyre::Result<Option<FileWorkerGuard>> {
223        let otlp_status = runner.block_on(self.traces.init_otlp_tracing(&mut layers))?;
224        let otlp_logs_status = runner.block_on(self.traces.init_otlp_logs(&mut layers))?;
225
226        // Enable reload support if debug RPC namespace is available
227        let enable_reload = self.command.debug_namespace_enabled();
228        let file_guard = self.logs.init_tracing_with_layers(layers, enable_reload)?;
229        info!(target: "reth::cli", "Initialized tracing, debug log directory: {}", self.logs.log_file_directory);
230
231        match otlp_status {
232            OtlpInitStatus::Started(endpoint) => {
233                info!(target: "reth::cli", "Started OTLP {:?} tracing export to {endpoint}", self.traces.protocol);
234            }
235            OtlpInitStatus::NoFeature => {
236                warn!(target: "reth::cli", "Provided OTLP tracing arguments do not have effect, compile with the `otlp` feature")
237            }
238            OtlpInitStatus::Disabled => {}
239        }
240
241        match otlp_logs_status {
242            OtlpLogsStatus::Started(endpoint) => {
243                info!(target: "reth::cli", "Started OTLP {:?} logs export to {endpoint}", self.traces.protocol);
244            }
245            OtlpLogsStatus::NoFeature => {
246                warn!(target: "reth::cli", "Provided OTLP logs arguments do not have effect, compile with the `otlp-logs` feature")
247            }
248            OtlpLogsStatus::Disabled => {}
249        }
250
251        Ok(file_guard)
252    }
253}
254
255/// Commands to be executed
256#[derive(Debug, Subcommand)]
257pub enum Commands<
258    C: ChainSpecParser,
259    Ext: clap::Args + fmt::Debug,
260    SubCmd: Subcommand + fmt::Debug = NoSubCmd,
261> {
262    /// Start the node
263    #[command(name = "node")]
264    Node(Box<node::NodeCommand<C, Ext>>),
265    /// Initialize the database from a genesis file.
266    #[command(name = "init")]
267    Init(init_cmd::InitCommand<C>),
268    /// Initialize the database from a state dump file.
269    #[command(name = "init-state")]
270    InitState(init_state::InitStateCommand<C>),
271    /// This syncs RLP encoded blocks from a file or files.
272    #[command(name = "import")]
273    Import(import::ImportCommand<C>),
274    /// This syncs ERA encoded blocks from a directory.
275    #[command(name = "import-era")]
276    ImportEra(import_era::ImportEraCommand<C>),
277    /// Exports block to era1 files in a specified directory.
278    #[command(name = "export-era")]
279    ExportEra(export_era::ExportEraCommand<C>),
280    /// Dumps genesis block JSON configuration to stdout.
281    DumpGenesis(dump_genesis::DumpGenesisCommand<C>),
282    /// Database debugging utilities
283    #[command(name = "db")]
284    Db(Box<db::Command<C>>),
285    /// Download public node snapshots
286    #[command(name = "download")]
287    Download(download::DownloadCommand<C>),
288    /// Generate a snapshot manifest from local archive files.
289    #[command(name = "snapshot-manifest")]
290    SnapshotManifest(manifest_cmd::SnapshotManifestCommand),
291    /// Manipulate individual stages.
292    #[command(name = "stage")]
293    Stage(stage::Command<C>),
294    /// P2P Debugging utilities
295    #[command(name = "p2p")]
296    P2P(Box<p2p::Command<C>>),
297    /// Generate Test Vectors
298    #[cfg(feature = "dev")]
299    #[command(name = "test-vectors")]
300    TestVectors(reth_cli_commands::test_vectors::Command),
301    /// Write config to stdout
302    #[command(name = "config")]
303    Config(config_cmd::Command),
304    /// Prune according to the configuration without any limits
305    #[command(name = "prune")]
306    Prune(prune::PruneCommand<C>),
307    /// Re-execute blocks in parallel to verify historical sync correctness.
308    #[command(name = "re-execute")]
309    ReExecute(re_execute::Command<C>),
310    /// Extension subcommands provided by consumers.
311    #[command(flatten)]
312    Ext(SubCmd),
313}
314
315/// A no-op subcommand type for when no extension subcommands are needed.
316///
317/// This is the default type parameter for `Commands` when consumers don't need
318/// to add custom subcommands.
319#[derive(Debug, Subcommand)]
320pub enum NoSubCmd {}
321
322impl crate::app::ExtendedCommand for NoSubCmd {
323    fn execute(self, _runner: CliRunner) -> eyre::Result<()> {
324        match self {}
325    }
326}
327
328impl<C: ChainSpecParser, Ext: clap::Args + fmt::Debug, SubCmd: Subcommand + fmt::Debug>
329    Commands<C, Ext, SubCmd>
330{
331    /// Returns the underlying chain being used for commands
332    pub fn chain_spec(&self) -> Option<&Arc<C::ChainSpec>> {
333        match self {
334            Self::Node(cmd) => cmd.chain_spec(),
335            Self::Init(cmd) => cmd.chain_spec(),
336            Self::InitState(cmd) => cmd.chain_spec(),
337            Self::Import(cmd) => cmd.chain_spec(),
338            Self::ExportEra(cmd) => cmd.chain_spec(),
339            Self::ImportEra(cmd) => cmd.chain_spec(),
340            Self::DumpGenesis(cmd) => cmd.chain_spec(),
341            Self::Db(cmd) => cmd.chain_spec(),
342            Self::Download(cmd) => cmd.chain_spec(),
343            Self::SnapshotManifest(_) => None,
344            Self::Stage(cmd) => cmd.chain_spec(),
345            Self::P2P(cmd) => cmd.chain_spec(),
346            #[cfg(feature = "dev")]
347            Self::TestVectors(_) => None,
348            Self::Config(_) => None,
349            Self::Prune(cmd) => cmd.chain_spec(),
350            Self::ReExecute(cmd) => cmd.chain_spec(),
351            Self::Ext(_) => None,
352        }
353    }
354
355    /// Returns `true` if this is a node command with debug RPC namespace enabled.
356    ///
357    /// This is used to determine whether to enable runtime log level changes.
358    pub fn debug_namespace_enabled(&self) -> bool {
359        match self {
360            Self::Node(cmd) => cmd.rpc.is_namespace_enabled(RethRpcModule::Debug),
361            _ => false,
362        }
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::chainspec::SUPPORTED_CHAINS;
370    use clap::CommandFactory;
371    use reth_chainspec::{EthChainSpec, SEPOLIA};
372    use reth_node_core::args::ColorMode;
373
374    #[test]
375    fn parse_color_mode() {
376        let reth = Cli::try_parse_args_from(["reth", "node", "--color", "always"]).unwrap();
377        assert_eq!(reth.logs.color, ColorMode::Always);
378    }
379
380    /// Tests that the help message is parsed correctly. This ensures that clap args are configured
381    /// correctly and no conflicts are introduced via attributes that would result in a panic at
382    /// runtime
383    #[test]
384    fn test_parse_help_all_subcommands() {
385        let reth = Cli::<EthereumChainSpecParser, NoArgs>::command();
386        for sub_command in reth.get_subcommands() {
387            let err = Cli::try_parse_args_from(["reth", sub_command.get_name(), "--help"])
388                .err()
389                .unwrap_or_else(|| {
390                    panic!("Failed to parse help message {}", sub_command.get_name())
391                });
392
393            // --help is treated as error, but
394            // > Not a true "error" as it means --help or similar was used. The help message will be sent to stdout.
395            assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
396        }
397    }
398
399    /// Tests that the log directory is parsed correctly when using the node command. It's
400    /// always tied to the specific chain's name.
401    #[test]
402    fn parse_logs_path_node() {
403        let mut reth = Cli::try_parse_args_from(["reth", "node"]).unwrap();
404        if let Some(chain_spec) = reth.command.chain_spec() {
405            reth.logs.log_file_directory =
406                reth.logs.log_file_directory.join(chain_spec.chain.to_string());
407        }
408        let log_dir = reth.logs.log_file_directory;
409        let end = format!("reth/logs/{}", SUPPORTED_CHAINS[0]);
410        assert!(log_dir.as_ref().ends_with(end), "{log_dir:?}");
411
412        let mut iter = SUPPORTED_CHAINS.iter();
413        iter.next();
414        for chain in iter {
415            let mut reth = Cli::try_parse_args_from(["reth", "node", "--chain", chain]).unwrap();
416            let chain =
417                reth.command.chain_spec().map(|c| c.chain.to_string()).unwrap_or(String::new());
418            reth.logs.log_file_directory = reth.logs.log_file_directory.join(chain.clone());
419            let log_dir = reth.logs.log_file_directory;
420            let end = format!("reth/logs/{chain}");
421            assert!(log_dir.as_ref().ends_with(end), "{log_dir:?}");
422        }
423    }
424
425    /// Tests that the log directory is parsed correctly when using the init command. It
426    /// uses the underlying environment in command to get the chain.
427    #[test]
428    fn parse_logs_path_init() {
429        let mut reth = Cli::try_parse_args_from(["reth", "init"]).unwrap();
430        if let Some(chain_spec) = reth.command.chain_spec() {
431            reth.logs.log_file_directory =
432                reth.logs.log_file_directory.join(chain_spec.chain.to_string());
433        }
434        let log_dir = reth.logs.log_file_directory;
435        let end = format!("reth/logs/{}", SUPPORTED_CHAINS[0]);
436        println!("{log_dir:?}");
437        assert!(log_dir.as_ref().ends_with(end), "{log_dir:?}");
438    }
439
440    /// Tests that the config command does not return any chain spec leading to empty chain id.
441    #[test]
442    fn parse_empty_logs_path() {
443        let mut reth = Cli::try_parse_args_from(["reth", "config"]).unwrap();
444        if let Some(chain_spec) = reth.command.chain_spec() {
445            reth.logs.log_file_directory =
446                reth.logs.log_file_directory.join(chain_spec.chain.to_string());
447        }
448        let log_dir = reth.logs.log_file_directory;
449        let end = "reth/logs".to_string();
450        println!("{log_dir:?}");
451        assert!(log_dir.as_ref().ends_with(end), "{log_dir:?}");
452    }
453
454    #[test]
455    fn log_file_max_files_defaults() {
456        use reth_node_core::args::LogArgs;
457
458        // Node command without explicit --log.file.max-files should get Some(5) after
459        // apply_node_defaults
460        let mut cli = Cli::try_parse_args_from(["reth", "node"]).unwrap();
461        assert!(cli.logs.log_file_max_files.is_none());
462        cli.logs.apply_node_defaults();
463        assert_eq!(cli.logs.log_file_max_files, Some(LogArgs::DEFAULT_MAX_LOG_FILES_NODE));
464
465        // Non-node command without explicit --log.file.max-files should be None and
466        // effective_log_file_max_files returns 0
467        let cli = Cli::try_parse_args_from(["reth", "config"]).unwrap();
468        assert!(cli.logs.log_file_max_files.is_none());
469        assert_eq!(cli.logs.effective_log_file_max_files(), 0);
470
471        // Explicitly set value should be preserved for node command
472        let mut cli =
473            Cli::try_parse_args_from(["reth", "node", "--log.file.max-files", "10"]).unwrap();
474        assert_eq!(cli.logs.log_file_max_files, Some(10));
475        cli.logs.apply_node_defaults();
476        assert_eq!(cli.logs.log_file_max_files, Some(10));
477
478        // Explicitly set value should be preserved for non-node command
479        let cli =
480            Cli::try_parse_args_from(["reth", "config", "--log.file.max-files", "3"]).unwrap();
481        assert_eq!(cli.logs.log_file_max_files, Some(3));
482        assert_eq!(cli.logs.effective_log_file_max_files(), 3);
483
484        // Setting to 0 explicitly should work
485        let cli = Cli::try_parse_args_from(["reth", "node", "--log.file.max-files", "0"]).unwrap();
486        assert_eq!(cli.logs.log_file_max_files, Some(0));
487        assert_eq!(cli.logs.effective_log_file_max_files(), 0);
488    }
489
490    #[test]
491    fn parse_env_filter_directives() {
492        let temp_dir = tempfile::tempdir().unwrap();
493
494        unsafe { std::env::set_var("RUST_LOG", "info,evm=debug") };
495        let reth = Cli::try_parse_args_from([
496            "reth",
497            "init",
498            "--datadir",
499            temp_dir.path().to_str().unwrap(),
500            "--log.file.filter",
501            "debug,net=trace",
502        ])
503        .unwrap();
504        assert!(reth.run(async move |_, _| Ok(())).is_ok());
505    }
506
507    #[test]
508    fn test_rpc_module_validation() {
509        use reth_rpc_server_types::RethRpcModule;
510
511        // Test that standard modules are accepted
512        let cli =
513            Cli::try_parse_args_from(["reth", "node", "--http.api", "eth,admin,debug"]).unwrap();
514
515        if let Commands::Node(command) = &cli.command {
516            if let Some(http_api) = &command.rpc.http_api {
517                // Should contain the expected modules
518                let modules = http_api.to_selection();
519                assert!(modules.contains(&RethRpcModule::Eth));
520                assert!(modules.contains(&RethRpcModule::Admin));
521                assert!(modules.contains(&RethRpcModule::Debug));
522            } else {
523                panic!("Expected http.api to be set");
524            }
525        } else {
526            panic!("Expected Node command");
527        }
528
529        // Test that unknown modules are parsed as Other variant
530        let cli =
531            Cli::try_parse_args_from(["reth", "node", "--http.api", "eth,customrpc"]).unwrap();
532
533        if let Commands::Node(command) = &cli.command {
534            if let Some(http_api) = &command.rpc.http_api {
535                let modules = http_api.to_selection();
536                assert!(modules.contains(&RethRpcModule::Eth));
537                assert!(modules.contains(&RethRpcModule::Other("customrpc".to_string())));
538            } else {
539                panic!("Expected http.api to be set");
540            }
541        } else {
542            panic!("Expected Node command");
543        }
544    }
545
546    #[test]
547    fn test_rpc_module_unknown_rejected() {
548        use reth_cli_runner::CliRunner;
549
550        // Test that unknown module names are rejected during validation
551        let cli =
552            Cli::try_parse_args_from(["reth", "node", "--http.api", "unknownmodule"]).unwrap();
553
554        // When we try to run the CLI with validation, it should fail
555        let runner = CliRunner::try_default_runtime().unwrap();
556        let result = cli.with_runner(runner, |_, _| async { Ok(()) });
557
558        assert!(result.is_err());
559        let err = result.unwrap_err();
560        let err_msg = err.to_string();
561
562        // The error should mention it's an unknown module
563        assert!(
564            err_msg.contains("Unknown RPC module"),
565            "Error should mention unknown module: {}",
566            err_msg
567        );
568        assert!(
569            err_msg.contains("'unknownmodule'"),
570            "Error should mention the module name: {}",
571            err_msg
572        );
573    }
574
575    #[test]
576    fn parse_unwind_chain() {
577        let cli = Cli::try_parse_args_from([
578            "reth", "stage", "unwind", "--chain", "sepolia", "to-block", "100",
579        ])
580        .unwrap();
581        match cli.command {
582            Commands::Stage(cmd) => match cmd.command {
583                stage::Subcommands::Unwind(cmd) => {
584                    assert_eq!(cmd.chain_spec().unwrap().chain_id(), SEPOLIA.chain_id());
585                }
586                _ => panic!("Expected Unwind command"),
587            },
588            _ => panic!("Expected Stage command"),
589        };
590    }
591
592    #[test]
593    fn parse_empty_supported_chains() {
594        #[derive(Debug, Clone, Default)]
595        struct FileChainSpecParser;
596
597        impl ChainSpecParser for FileChainSpecParser {
598            type ChainSpec = ChainSpec;
599
600            const SUPPORTED_CHAINS: &'static [&'static str] = &[];
601
602            fn parse(s: &str) -> eyre::Result<Arc<Self::ChainSpec>> {
603                EthereumChainSpecParser::parse(s)
604            }
605        }
606
607        let cli = Cli::<FileChainSpecParser>::try_parse_from([
608            "reth", "stage", "unwind", "--chain", "sepolia", "to-block", "100",
609        ])
610        .unwrap();
611        match cli.command {
612            Commands::Stage(cmd) => match cmd.command {
613                stage::Subcommands::Unwind(cmd) => {
614                    assert_eq!(cmd.chain_spec().unwrap().chain_id(), SEPOLIA.chain_id());
615                }
616                _ => panic!("Expected Unwind command"),
617            },
618            _ => panic!("Expected Stage command"),
619        };
620    }
621
622    #[test]
623    fn test_extensible_subcommands() {
624        use crate::app::ExtendedCommand;
625        use reth_cli_runner::CliRunner;
626        use reth_rpc_server_types::DefaultRpcModuleValidator;
627        use std::sync::atomic::{AtomicBool, Ordering};
628
629        #[derive(Debug, Subcommand)]
630        enum CustomCommands {
631            /// A custom hello command
632            #[command(name = "hello")]
633            Hello {
634                /// Name to greet
635                #[arg(long)]
636                name: String,
637            },
638            /// Another custom command
639            #[command(name = "goodbye")]
640            Goodbye,
641        }
642
643        static EXECUTED: AtomicBool = AtomicBool::new(false);
644
645        impl ExtendedCommand for CustomCommands {
646            fn execute(self, _runner: CliRunner) -> eyre::Result<()> {
647                match self {
648                    Self::Hello { name } => {
649                        assert_eq!(name, "world");
650                        EXECUTED.store(true, Ordering::SeqCst);
651                        Ok(())
652                    }
653                    Self::Goodbye => Ok(()),
654                }
655            }
656        }
657
658        // Test parsing the custom "hello" command
659        let cli = Cli::<
660            EthereumChainSpecParser,
661            NoArgs,
662            DefaultRpcModuleValidator,
663            CustomCommands,
664        >::try_parse_from(["reth", "hello", "--name", "world"])
665        .unwrap();
666
667        match &cli.command {
668            Commands::Ext(CustomCommands::Hello { name }) => {
669                assert_eq!(name, "world");
670            }
671            _ => panic!("Expected Ext(Hello) command"),
672        }
673
674        // Test parsing the custom "goodbye" command
675        let cli = Cli::<
676            EthereumChainSpecParser,
677            NoArgs,
678            DefaultRpcModuleValidator,
679            CustomCommands,
680        >::try_parse_from(["reth", "goodbye"])
681        .unwrap();
682
683        match &cli.command {
684            Commands::Ext(CustomCommands::Goodbye) => {}
685            _ => panic!("Expected Ext(Goodbye) command"),
686        }
687
688        // Test that built-in commands still work alongside custom ones
689        let cli = Cli::<
690            EthereumChainSpecParser,
691            NoArgs,
692            DefaultRpcModuleValidator,
693            CustomCommands,
694        >::try_parse_from(["reth", "node"])
695        .unwrap();
696
697        match &cli.command {
698            Commands::Node(_) => {}
699            _ => panic!("Expected Node command"),
700        }
701
702        // Test executing the custom command
703        let cli = Cli::<
704            EthereumChainSpecParser,
705            NoArgs,
706            DefaultRpcModuleValidator,
707            CustomCommands,
708        >::try_parse_from(["reth", "hello", "--name", "world"])
709        .unwrap();
710
711        if let Commands::Ext(cmd) = cli.command {
712            let runner = CliRunner::try_default_runtime().unwrap();
713            cmd.execute(runner).unwrap();
714            assert!(EXECUTED.load(Ordering::SeqCst), "Custom command should have been executed");
715        }
716    }
717}