Skip to main content

dora_cli/command/
mod.rs

1mod build;
2mod clean;
3mod cluster;
4mod completion;
5mod coordinator;
6mod daemon;
7mod doctor;
8mod down;
9mod expand;
10mod graph;
11mod hub;
12pub mod inspect;
13mod list;
14mod logs;
15mod new;
16mod node;
17mod node_binary;
18mod param;
19mod record;
20mod replay;
21mod restart;
22mod run;
23mod runtime;
24mod self_;
25mod start;
26mod stop;
27mod system;
28mod topic;
29mod trace;
30mod up;
31mod validate;
32
33pub use build::{BuildConfig, build};
34pub use run::{Run, run};
35pub(crate) use up::set_python_executable_path;
36
37use build::Build;
38use clean::CleanArgs;
39use cluster::Cluster;
40use completion::Completion;
41use coordinator::Coordinator;
42use daemon::Daemon;
43use doctor::Doctor;
44use down::Down;
45use expand::Expand;
46use eyre::Context;
47use graph::Graph;
48use hub::Hub;
49use inspect::Inspect;
50use list::ListArgs;
51use logs::LogsArgs;
52use new::NewArgs;
53use node::Node;
54use param::Param;
55use record::Record;
56use replay::Replay;
57use restart::Restart;
58use runtime::Runtime;
59use self_::SelfSubCommand;
60use start::Start;
61use stop::Stop;
62use system::System;
63use topic::Topic;
64use trace::Trace;
65use up::Up;
66use validate::Validate;
67
68/// dora-rs cli client
69#[derive(Debug, clap::Subcommand)]
70pub enum Command {
71    // -- Lifecycle --
72    /// Run a dataflow locally in isolation (no coordinator)
73    #[clap(display_order = 1)]
74    Run(Run),
75    /// Start coordinator and daemon in local mode
76    #[clap(display_order = 2)]
77    Up(Up),
78    /// Tear down coordinator and daemon. Stops any running dataflows first.
79    #[clap(name = "down", alias = "destroy", display_order = 3)]
80    Down(Down),
81    /// Manage a multi-machine cluster (up, status, down)
82    #[clap(subcommand, display_order = 4)]
83    Cluster(Cluster),
84    /// Run build commands provided in the given dataflow
85    #[clap(display_order = 5)]
86    Build(Build),
87    /// Start a dataflow on a running coordinator
88    #[clap(display_order = 6)]
89    Start(Start),
90    /// Stop a running dataflow
91    #[clap(display_order = 7)]
92    Stop(Stop),
93    /// Restart a running dataflow (stop + re-start with stored descriptor)
94    #[clap(display_order = 8)]
95    Restart(Restart),
96
97    // -- Monitoring --
98    /// List running dataflows
99    #[clap(alias = "ps", display_order = 10)]
100    List(ListArgs),
101    /// Remove finished and failed dataflows from the coordinator
102    #[clap(display_order = 10)]
103    Clean(CleanArgs),
104    /// Show logs of a given dataflow
105    #[clap(display_order = 11)]
106    Logs(LogsArgs),
107    /// Inspect running dataflows in real-time
108    #[clap(subcommand, display_order = 12)]
109    Inspect(Inspect),
110    /// Manage and inspect dataflow topics
111    #[clap(subcommand, display_order = 13)]
112    Topic(Topic),
113    /// Manage and inspect dataflow nodes
114    #[clap(subcommand, display_order = 14)]
115    Node(Node),
116    /// Manage runtime parameters on running nodes
117    #[clap(subcommand, display_order = 15)]
118    Param(Param),
119    /// Record dataflow messages to a file for offline replay
120    #[clap(display_order = 16)]
121    Record(Record),
122    /// Replay a recorded dataflow from a `.drec` file
123    #[clap(display_order = 17)]
124    Replay(Replay),
125    /// View coordinator tracing spans
126    #[clap(subcommand, display_order = 18)]
127    Trace(Trace),
128
129    // -- Setup --
130    /// Check system health
131    #[clap(alias = "check", display_order = 20)]
132    Status(system::status::Status),
133    /// Run comprehensive system diagnostics
134    #[clap(display_order = 19)]
135    Doctor(Doctor),
136    /// Generate a new project or node
137    #[clap(display_order = 21)]
138    New(NewArgs),
139    /// Visualize a dataflow as a graph
140    #[clap(display_order = 22)]
141    Graph(Graph),
142    /// Expand module references and print the flat dataflow YAML
143    #[clap(display_order = 23)]
144    Expand(Expand),
145    /// Validate a dataflow YAML file and check type annotations
146    #[clap(display_order = 24)]
147    Validate(Validate),
148    /// System management commands
149    #[clap(subcommand, display_order = 25)]
150    System(System),
151    /// Package, discover, and use dora nodes (unstable)
152    #[clap(subcommand, display_order = 26)]
153    Hub(Hub),
154
155    // -- Utility --
156    /// Generate shell completions
157    #[clap(display_order = 30)]
158    Completion(Completion),
159    /// CLI self-management (update, uninstall)
160    #[clap(display_order = 31)]
161    Self_ {
162        #[clap(subcommand)]
163        command: SelfSubCommand,
164    },
165
166    // -- Hidden: internal / aliases --
167    #[clap(hide = true)]
168    Daemon(Daemon),
169    #[clap(hide = true)]
170    Runtime(Runtime),
171    #[clap(hide = true)]
172    Coordinator(Coordinator),
173    /// Real-time resource monitor (shortcut for `inspect top`)
174    #[clap(display_order = 12)]
175    Top(inspect::top::Top),
176}
177
178fn default_tracing() -> eyre::Result<()> {
179    #[cfg(feature = "tracing")]
180    {
181        use dora_tracing::TracingBuilder;
182
183        TracingBuilder::new("dora-cli")
184            .with_stdout("warn", false)
185            .build()
186            .wrap_err("failed to set up tracing subscriber")?;
187    }
188    Ok(())
189}
190
191pub trait Executable {
192    fn execute(self) -> eyre::Result<()>;
193}
194
195impl Executable for Command {
196    fn execute(self) -> eyre::Result<()> {
197        match self {
198            Command::Run(args) => args.execute(),
199            Command::Up(args) => args.execute(),
200            Command::Down(args) => args.execute(),
201            Command::Cluster(args) => args.execute(),
202            Command::Build(args) => args.execute(),
203            Command::Start(args) => args.execute(),
204            Command::Stop(args) => args.execute(),
205            Command::Restart(args) => args.execute(),
206            Command::List(args) => args.execute(),
207            Command::Clean(args) => args.execute(),
208            Command::Logs(args) => args.execute(),
209            Command::Inspect(args) => args.execute(),
210            Command::Topic(args) => args.execute(),
211            Command::Node(args) => args.execute(),
212            Command::Param(args) => args.execute(),
213            Command::Record(args) => args.execute(),
214            Command::Replay(args) => args.execute(),
215            Command::Trace(args) => args.execute(),
216            Command::Status(args) => args.execute(),
217            Command::Doctor(args) => args.execute(),
218            Command::New(args) => args.execute(),
219            Command::Graph(args) => args.execute(),
220            Command::Expand(args) => args.execute(),
221            Command::Validate(args) => args.execute(),
222            Command::System(args) => args.execute(),
223            Command::Hub(args) => args.execute(),
224            Command::Completion(args) => args.execute(),
225            Command::Self_ { command } => command.execute(),
226            Command::Daemon(args) => args.execute(),
227            Command::Runtime(args) => args.execute(),
228            Command::Coordinator(args) => args.execute(),
229            Command::Top(args) => args.execute(),
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use crate::Args;
237    use clap::{CommandFactory, Parser};
238
239    #[test]
240    fn verify_cli() {
241        Args::command().debug_assert();
242    }
243
244    fn parse_ok(args: &[&str]) {
245        Args::try_parse_from(args).unwrap_or_else(|e| panic!("failed to parse {args:?}: {e}"));
246    }
247
248    /// Every `--format` flag must state its JSON output shape in `--help`,
249    /// so scripters learn whether to parse line-wise (JSON Lines) or as a
250    /// single document without reading the source (#2922).
251    #[test]
252    fn help_documents_json_output_shape_for_every_format_flag() {
253        let cases: &[(&[&str], &str)] = &[
254            (
255                &["dora", "list", "--help"],
256                "JSON Lines (one object per line)",
257            ),
258            (
259                &["dora", "clean", "--help"],
260                "JSON Lines (one object per line)",
261            ),
262            (
263                &["dora", "topic", "list", "--help"],
264                "JSON Lines (one object per line)",
265            ),
266            (
267                &["dora", "topic", "echo", "--help"],
268                "JSON Lines (one object per decoded message)",
269            ),
270            (
271                &["dora", "logs", "--help"],
272                "JSON Lines (one object per log message)",
273            ),
274            (
275                &["dora", "run", "--help"],
276                "JSON Lines (one object per log message)",
277            ),
278            (
279                // top-level alias of `dora system status`
280                &["dora", "status", "--help"],
281                "a single pretty-printed JSON document",
282            ),
283            (
284                // `ps` alias resolves to `dora list`
285                &["dora", "ps", "--help"],
286                "JSON Lines (one object per line)",
287            ),
288            (
289                &["dora", "node", "list", "--help"],
290                "JSON Lines (one object per line)",
291            ),
292            (
293                &["dora", "node", "info", "--help"],
294                "a single pretty-printed JSON document",
295            ),
296            (
297                &["dora", "system", "status", "--help"],
298                "a single pretty-printed JSON document",
299            ),
300            (
301                &["dora", "param", "list", "--help"],
302                "a single pretty-printed JSON document",
303            ),
304        ];
305        for (args, expected) in cases {
306            let error = Args::try_parse_from(*args).expect_err("--help should stop parsing");
307            assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp);
308            let help = error.to_string();
309            assert!(
310                help.contains(expected),
311                "help for {args:?} must contain {expected:?}, got:\n{help}"
312            );
313        }
314    }
315
316    fn parse_err(args: &[&str]) {
317        assert!(
318            Args::try_parse_from(args).is_err(),
319            "expected parse error for {args:?}"
320        );
321    }
322
323    #[test]
324    fn parse_run() {
325        parse_ok(&["dora", "run", "foo.yml"]);
326    }
327
328    #[test]
329    fn parse_run_locked() {
330        parse_ok(&["dora", "run", "foo.yml", "--locked"]);
331    }
332
333    #[test]
334    fn reject_run_locked_and_write_lockfile() {
335        parse_err(&["dora", "run", "foo.yml", "--locked", "--write-lockfile"]);
336    }
337
338    #[test]
339    fn parse_up() {
340        parse_ok(&["dora", "up"]);
341        parse_ok(&["dora", "up", "--recreate-store"]);
342    }
343
344    #[test]
345    fn parse_down() {
346        parse_ok(&["dora", "down"]);
347    }
348
349    #[test]
350    fn parse_start() {
351        parse_ok(&["dora", "start", "foo.yml"]);
352    }
353
354    #[test]
355    fn parse_stop_uuid() {
356        parse_ok(&["dora", "stop", "a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6"]);
357    }
358
359    #[test]
360    fn parse_list() {
361        parse_ok(&["dora", "list"]);
362    }
363
364    #[test]
365    fn parse_clean() {
366        parse_ok(&["dora", "clean"]);
367        parse_ok(&["dora", "clean", "--format", "json"]);
368        parse_ok(&["dora", "clean", "-q"]);
369        // --quiet and --format conflict
370        parse_err(&["dora", "clean", "-q", "--format", "table"]);
371    }
372
373    #[test]
374    fn parse_logs() {
375        parse_ok(&["dora", "logs"]);
376    }
377
378    #[test]
379    fn parse_logs_dataflow() {
380        parse_ok(&["dora", "logs", "my-dataflow"]);
381    }
382
383    #[test]
384    fn parse_logs_node_flag() {
385        parse_ok(&["dora", "logs", "--node", "sensor"]);
386        parse_ok(&["dora", "logs", "-n", "sensor"]);
387    }
388
389    #[test]
390    fn parse_daemon_worker_threads() {
391        parse_ok(&["dora", "daemon", "--worker-threads", "4"]);
392        parse_ok(&["dora", "daemon", "--worker-threads", "1"]);
393    }
394
395    #[test]
396    fn reject_daemon_worker_threads_zero() {
397        // `tokio::runtime::Builder::worker_threads` asserts `val > 0`, so
398        // `--worker-threads 0` used to abort the daemon with a raw panic.
399        // clap must reject it with a normal usage error instead.
400        parse_err(&["dora", "daemon", "--worker-threads", "0"]);
401    }
402
403    #[test]
404    fn parse_logs_dataflow_node_flag() {
405        parse_ok(&["dora", "logs", "my-dataflow", "--node", "sensor"]);
406    }
407
408    #[test]
409    fn parse_logs_all_nodes() {
410        parse_ok(&["dora", "logs", "my-dataflow", "--all-nodes"]);
411    }
412
413    #[test]
414    fn parse_logs_legacy_positional_node_for_runtime_hint() {
415        parse_ok(&["dora", "logs", "my-dataflow", "sensor"]);
416    }
417
418    #[test]
419    fn reject_logs_legacy_positional_node_with_node_flag() {
420        parse_err(&["dora", "logs", "my-dataflow", "sensor", "--node", "other"]);
421    }
422
423    #[test]
424    fn reject_logs_legacy_positional_node_with_all_nodes_flag() {
425        parse_err(&["dora", "logs", "my-dataflow", "sensor", "--all-nodes"]);
426    }
427
428    #[test]
429    fn reject_logs_node_and_all_nodes() {
430        parse_err(&[
431            "dora",
432            "logs",
433            "my-dataflow",
434            "--node",
435            "sensor",
436            "--all-nodes",
437        ]);
438    }
439
440    #[test]
441    fn parse_build() {
442        parse_ok(&["dora", "build", "foo.yml"]);
443    }
444
445    #[test]
446    fn parse_build_locked() {
447        parse_ok(&["dora", "build", "foo.yml", "--locked"]);
448    }
449
450    #[test]
451    fn reject_build_locked_and_write_lockfile() {
452        parse_err(&["dora", "build", "foo.yml", "--locked", "--write-lockfile"]);
453    }
454
455    #[test]
456    fn parse_graph() {
457        parse_ok(&["dora", "graph", "foo.yml"]);
458    }
459
460    #[test]
461    fn parse_expand() {
462        parse_ok(&["dora", "expand", "foo.yml"]);
463    }
464
465    #[test]
466    fn parse_expand_module() {
467        parse_ok(&["dora", "expand", "--module", "module.yml"]);
468    }
469
470    #[test]
471    fn parse_validate() {
472        parse_ok(&["dora", "validate", "dataflow.yml"]);
473    }
474
475    #[test]
476    fn parse_validate_strict() {
477        parse_ok(&["dora", "validate", "--strict-types", "dataflow.yml"]);
478    }
479
480    #[test]
481    fn parse_validate_node_manifest() {
482        parse_ok(&["dora", "validate", "--node-manifest", "dora-node.yml"]);
483        // a dataflow and --node-manifest are mutually exclusive
484        parse_err(&[
485            "dora",
486            "validate",
487            "dataflow.yml",
488            "--node-manifest",
489            "dora-node.yml",
490        ]);
491        // one of the two is required
492        parse_err(&["dora", "validate"]);
493        // --strict-types is dataflow-only
494        parse_err(&[
495            "dora",
496            "validate",
497            "--node-manifest",
498            "dora-node.yml",
499            "--strict-types",
500        ]);
501    }
502
503    #[test]
504    fn parse_new() {
505        parse_ok(&["dora", "new", "test"]);
506    }
507
508    #[test]
509    fn parse_offline() {
510        parse_ok(&["dora", "build", "dataflow.yml", "--offline"]);
511        parse_ok(&["dora", "validate", "dataflow.yml", "--offline"]);
512        // --offline is meaningless for standalone manifest validation
513        parse_err(&["dora", "validate", "--node-manifest", "x.yml", "--offline"]);
514    }
515
516    #[test]
517    fn parse_hub() {
518        parse_ok(&["dora", "hub", "init"]);
519        parse_ok(&["dora", "hub", "init", "path/to/node"]);
520        parse_ok(&["dora", "hub", "install", "dora-yolo"]);
521        parse_ok(&["dora", "hub", "search", "camera", "--category", "sensor"]);
522        parse_ok(&["dora", "hub", "search", "--offline"]);
523        parse_ok(&["dora", "hub", "info", "dora-yolo@^0.5"]);
524        parse_ok(&["dora", "hub", "list", "dataflow.yml"]);
525        parse_ok(&["dora", "hub", "fetch", "dataflow.yml", "--target-dir", "c"]);
526        parse_err(&["dora", "hub"]);
527        parse_err(&["dora", "hub", "info"]);
528    }
529
530    #[test]
531    fn parse_status() {
532        parse_ok(&["dora", "status"]);
533    }
534
535    #[test]
536    fn parse_status_json() {
537        parse_ok(&["dora", "status", "--format", "json"]);
538    }
539
540    #[test]
541    fn parse_status_json_short() {
542        parse_ok(&["dora", "status", "-f", "json"]);
543    }
544
545    #[test]
546    fn parse_inspect_top() {
547        parse_ok(&["dora", "inspect", "top"]);
548    }
549
550    #[test]
551    fn parse_topic_list() {
552        parse_ok(&["dora", "topic", "list"]);
553    }
554
555    #[test]
556    fn parse_topic_hz() {
557        parse_ok(&["dora", "topic", "hz"]);
558    }
559
560    #[test]
561    fn parse_topic_echo() {
562        parse_ok(&["dora", "topic", "echo"]);
563    }
564
565    #[test]
566    fn parse_node_list() {
567        parse_ok(&["dora", "node", "list"]);
568        parse_ok(&["dora", "node", "list", "--format", "json"]);
569        // --quiet and --format conflict (documented in the flag table)
570        parse_err(&["dora", "node", "list", "-q", "--format", "json"]);
571    }
572
573    #[test]
574    fn parse_node_info() {
575        parse_ok(&["dora", "node", "info", "camera_node"]);
576    }
577
578    #[test]
579    fn parse_node_info_with_dataflow() {
580        parse_ok(&["dora", "node", "info", "sensor", "-d", "my-dataflow"]);
581    }
582
583    #[test]
584    fn reject_node_info_no_node() {
585        parse_err(&["dora", "node", "info"]);
586    }
587
588    #[test]
589    fn parse_topic_pub() {
590        parse_ok(&[
591            "dora",
592            "topic",
593            "pub",
594            "-d",
595            "my-dataflow",
596            "sensor/reading",
597            r#"{"value": 42}"#,
598        ]);
599    }
600
601    #[test]
602    fn parse_topic_pub_with_file() {
603        parse_ok(&[
604            "dora",
605            "topic",
606            "pub",
607            "-d",
608            "my-dataflow",
609            "sensor/reading",
610            "--file",
611            "data.json",
612        ]);
613    }
614
615    #[test]
616    fn reject_topic_pub_no_data_or_file() {
617        parse_err(&[
618            "dora",
619            "topic",
620            "pub",
621            "-d",
622            "my-dataflow",
623            "sensor/reading",
624        ]);
625    }
626
627    #[test]
628    fn parse_node_restart() {
629        parse_ok(&["dora", "node", "restart", "camera_node"]);
630    }
631
632    #[test]
633    fn parse_node_restart_with_grace() {
634        parse_ok(&["dora", "node", "restart", "sensor", "--grace", "30s"]);
635    }
636
637    #[test]
638    fn reject_node_restart_no_node() {
639        parse_err(&["dora", "node", "restart"]);
640    }
641
642    #[test]
643    fn parse_node_replace() {
644        parse_ok(&[
645            "dora",
646            "node",
647            "replace",
648            "filter",
649            "--from-yaml",
650            "filter-node.yml",
651        ]);
652        parse_ok(&[
653            "dora",
654            "node",
655            "replace",
656            "-d",
657            "my-flow",
658            "filter",
659            "--from-yaml",
660            "filter-node.yml",
661            "--grace",
662            "5s",
663        ]);
664    }
665
666    #[test]
667    fn reject_node_replace_without_yaml() {
668        parse_err(&["dora", "node", "replace", "filter"]);
669    }
670
671    #[test]
672    fn parse_node_stop() {
673        parse_ok(&["dora", "node", "stop", "camera_node"]);
674    }
675
676    #[test]
677    fn parse_node_stop_with_grace() {
678        parse_ok(&[
679            "dora", "node", "stop", "sensor", "--grace", "10s", "-d", "my-flow",
680        ]);
681    }
682
683    #[test]
684    fn reject_node_stop_no_node() {
685        parse_err(&["dora", "node", "stop"]);
686    }
687
688    #[test]
689    fn parse_param_list() {
690        parse_ok(&["dora", "param", "list", "camera_node"]);
691    }
692
693    #[test]
694    fn parse_param_list_with_dataflow() {
695        parse_ok(&["dora", "param", "list", "sensor", "-d", "my-dataflow"]);
696    }
697
698    #[test]
699    fn parse_param_get() {
700        parse_ok(&["dora", "param", "get", "camera_node", "fps"]);
701    }
702
703    #[test]
704    fn parse_param_set() {
705        parse_ok(&["dora", "param", "set", "camera_node", "fps", "60"]);
706    }
707
708    #[test]
709    fn parse_param_delete() {
710        parse_ok(&["dora", "param", "delete", "camera_node", "fps"]);
711    }
712
713    #[test]
714    fn reject_param_set_no_value() {
715        parse_err(&["dora", "param", "set", "camera_node", "fps"]);
716    }
717
718    #[test]
719    fn reject_param_get_no_key() {
720        parse_err(&["dora", "param", "get", "camera_node"]);
721    }
722
723    #[test]
724    fn parse_doctor() {
725        parse_ok(&["dora", "doctor"]);
726    }
727
728    #[test]
729    fn parse_doctor_with_dataflow() {
730        parse_ok(&["dora", "doctor", "--dataflow", "dataflow.yml"]);
731    }
732
733    #[test]
734    fn parse_record() {
735        parse_ok(&["dora", "record", "dataflow.yml"]);
736    }
737
738    #[test]
739    fn parse_record_with_output() {
740        parse_ok(&["dora", "record", "dataflow.yml", "-o", "capture.drec"]);
741    }
742
743    #[test]
744    fn parse_record_with_topics() {
745        parse_ok(&[
746            "dora",
747            "record",
748            "dataflow.yml",
749            "--topics",
750            "sensor/image,lidar/points",
751        ]);
752    }
753
754    #[test]
755    fn parse_record_output_yaml() {
756        parse_ok(&[
757            "dora",
758            "record",
759            "dataflow.yml",
760            "--output-yaml",
761            "modified.yml",
762        ]);
763    }
764
765    #[test]
766    fn reject_record_no_file() {
767        parse_err(&["dora", "record"]);
768    }
769
770    #[test]
771    fn parse_replay() {
772        parse_ok(&["dora", "replay", "recording.drec"]);
773    }
774
775    #[test]
776    fn parse_replay_with_options() {
777        parse_ok(&[
778            "dora",
779            "replay",
780            "recording.drec",
781            "--speed",
782            "2.0",
783            "--loop",
784            "--replace",
785            "sensor,camera",
786        ]);
787    }
788
789    #[test]
790    fn parse_replay_output_yaml() {
791        parse_ok(&[
792            "dora",
793            "replay",
794            "recording.drec",
795            "--output-yaml",
796            "modified.yml",
797        ]);
798    }
799
800    #[test]
801    fn reject_replay_no_file() {
802        parse_err(&["dora", "replay"]);
803    }
804
805    #[test]
806    fn parse_trace_list() {
807        parse_ok(&["dora", "trace", "list"]);
808    }
809
810    #[test]
811    fn parse_trace_view() {
812        parse_ok(&["dora", "trace", "view", "abc123"]);
813    }
814
815    #[test]
816    fn reject_trace_view_no_id() {
817        parse_err(&["dora", "trace", "view"]);
818    }
819
820    #[test]
821    fn parse_cluster_up() {
822        parse_ok(&["dora", "cluster", "up", "cluster.yml"]);
823    }
824
825    #[test]
826    fn parse_cluster_status() {
827        parse_ok(&["dora", "cluster", "status"]);
828    }
829
830    #[test]
831    fn parse_cluster_down() {
832        parse_ok(&["dora", "cluster", "down"]);
833    }
834
835    #[test]
836    fn reject_cluster_up_no_file() {
837        parse_err(&["dora", "cluster", "up"]);
838    }
839
840    #[test]
841    fn reject_unknown_subcommand() {
842        parse_err(&["dora", "foo"]);
843    }
844
845    #[test]
846    fn help_exits_cleanly() {
847        let err = Args::try_parse_from(["dora", "--help"]).unwrap_err();
848        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
849    }
850
851    #[test]
852    fn version_exits_cleanly() {
853        let err = Args::try_parse_from(["dora", "--version"]).unwrap_err();
854        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
855    }
856}