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