Skip to main content

leviath_cli/
dispatch.rs

1//! Command dispatch: the `Commands` enum and the `dispatch()` function that
2//! routes a parsed subcommand to its executor.
3//!
4//! This lives in the library crate (not `main.rs`) so its routing logic can be
5//! unit-tested under `cargo llvm-cov`'s `--lib` scope. The subcommands whose
6//! real execution performs I/O a unit test must never trigger - a real
7//! terminal takeover (`dash`), blocking stdin (`setup` interactive,
8//! foreground `run`), binding a real port (`serve`), spawning a detached
9//! worker or running a real inference loop (`run` background / `__run-worker`) -
10//! are routed through the [`RiskyExecutors`] trait rather than called
11//! directly. That way:
12//!
13//! * unit tests drive `dispatch()`'s full routing match against a
14//!   `#[cfg(test)]` mock (`MockRisky`) that touches nothing real, and
15//! * the real implementations live in the (coverage-unmeasured) `lev` binary
16//!   as `main.rs`'s `RealExecutors`, which simply wires real I/O into the
17//!   library's already-tested command cores.
18//!
19//! Injection gives a "routing is tested, real I/O is never touched by a test"
20//! guarantee without any coverage escape hatch in library code.
21
22use crate::commands;
23
24/// Every `lev` subcommand. Each variant's doc comment is what `--help` prints.
25#[derive(clap::Subcommand)]
26pub enum Commands {
27    /// Create a new agent blueprint
28    Create(commands::create::CreateArgs),
29
30    /// Configure API keys and defaults
31    Setup(commands::setup::SetupArgs),
32
33    /// Run an agent
34    Run(commands::run::RunArgs),
35
36    /// List agents running in the shared-world daemon
37    #[command(long_about = commands::ps::PS_LONG_ABOUT)]
38    Ps(commands::ps::PsArgs),
39
40    /// Send a message to a running agent
41    Msg(commands::ctl::MsgArgs),
42
43    /// Cancel a running agent (alias: `kill`)
44    #[command(alias = "kill")]
45    Cancel(commands::ctl::CancelArgs),
46
47    /// Pause a running agent (it finishes its in-flight step, then holds)
48    Pause(commands::ctl::PauseArgs),
49
50    /// Resume a paused agent
51    Resume(commands::ctl::ResumeArgs),
52
53    /// Answer a pending interaction (or list open ones with no request id)
54    Respond(commands::ctl::RespondArgs),
55
56    /// Check that provider wiring works, end to end
57    #[command(long_about = commands::doctor::DOCTOR_LONG_ABOUT)]
58    Doctor(commands::doctor::DoctorArgs),
59
60    /// List available and installed blueprints
61    List(commands::list::ListArgs),
62
63    /// Install a blueprint
64    Add(commands::add::AddArgs),
65
66    /// Remove an installed blueprint
67    Remove(commands::remove::RemoveArgs),
68
69    /// Run blueprint tests
70    Test(commands::test::TestArgs),
71
72    /// Bundle a blueprint for distribution
73    Pack(commands::pack::PackArgs),
74
75    /// Interactive agent dashboard
76    #[command(name = "dash")]
77    Dashboard(commands::dashboard::DashboardArgs),
78
79    /// List and inspect available models
80    Models(commands::models::ModelsArgs),
81
82    /// Validate an agent blueprint
83    Validate(commands::validate::ValidateArgs),
84
85    /// List and validate the global Rhai script tools
86    Tools(commands::tools::ToolsArgs),
87
88    /// Show what runs without an approval prompt, and why
89    Approvals(commands::approvals::ApprovalsArgs),
90
91    /// Manage taint tracking policy rules
92    Policy(commands::policy::PolicyArgs),
93
94    /// Start the REST + WebSocket API server
95    Serve(commands::serve::ServeArgs),
96
97    /// Serve this agent over the Agent Client Protocol (JSON-RPC over stdio)
98    #[command(name = "agent-client")]
99    AgentClient(commands::agent_client::AgentClientArgs),
100
101    /// Run the shared-world daemon in the foreground
102    Daemon(commands::daemon::DaemonArgs),
103
104    /// Show a run's context-window history (from its run.lvr archive)
105    Context(commands::context::ContextArgs),
106
107    /// Show a run's per-stage token ledger, where a staged agent's cost lives
108    Stages(commands::stages::StagesArgs),
109
110    /// Print what an agent handed back when a run finished
111    Result(commands::result::ResultArgs),
112
113    /// Manage MCP tool servers and their authentication
114    Mcp(commands::mcp::McpArgs),
115
116    /// Inspect and move the secrets Leviath holds
117    Auth(commands::auth::AuthArgs),
118
119    /// Update Leviath, then everything that shipped with it
120    #[command(long_about = commands::update::UPDATE_LONG_ABOUT)]
121    Update(commands::update::UpdateArgs),
122}
123
124/// The subset of commands whose real execution performs I/O that a unit test
125/// must never trigger. `dispatch()` routes these through this trait so its
126/// routing logic stays unit-testable with a mock; the real implementations are
127/// supplied by the binary (`main.rs`'s `RealExecutors`).
128///
129/// `async fn` in a trait is fine here: `dispatch` takes `&impl RiskyExecutors`
130/// (static dispatch, no `dyn`), so no boxing or `Send` bound is required.
131pub trait RiskyExecutors {
132    // Each method returns `impl Future` rather than being an `async fn`, so what
133    // the future promises is stated rather than inferred.
134    //
135    // Deliberately **not** `+ Send`. These run on the CLI's single-threaded
136    // entry path and hold non-`Send` state across awaits - the daemon-readiness
137    // poll takes a `&mut dyn FnMut() -> bool`, and the TUI paths hold terminal
138    // handles. Adding the bound does not compile, which is the useful answer:
139    // an `async fn` here left that unsaid, and this says it.
140    /// `lev run` - auto-starts the daemon (real process spawn) if needed and
141    /// spawns the agent into the shared world over the control socket.
142    fn run(
143        &self,
144        args: commands::run::RunArgs,
145    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
146    /// `lev ps` - resolves the control-socket path and queries the daemon.
147    fn ps(
148        &self,
149        args: commands::ps::PsArgs,
150    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
151    /// `lev msg` - resolves the control-socket path and sends a message.
152    fn msg(
153        &self,
154        args: commands::ctl::MsgArgs,
155    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
156    /// `lev cancel` - resolves the control-socket path and cancels a run.
157    fn cancel(
158        &self,
159        args: commands::ctl::CancelArgs,
160    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
161    /// `lev pause` - resolves the control-socket path and pauses a run.
162    fn pause(
163        &self,
164        args: commands::ctl::PauseArgs,
165    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
166    /// `lev resume` - resolves the control-socket path and resumes a run.
167    fn resume(
168        &self,
169        args: commands::ctl::ResumeArgs,
170    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
171    /// `lev respond` - resolves the control-socket path and answers/lists interactions.
172    fn respond(
173        &self,
174        args: commands::ctl::RespondArgs,
175    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
176    /// `lev doctor` - makes real billed inference calls, and (unless
177    /// `--no-daemon`) auto-starts the daemon and spawns a throwaway run.
178    fn doctor(
179        &self,
180        args: commands::doctor::DoctorArgs,
181    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
182    /// `lev setup` - interactive (blocking stdin) or `--non-interactive`.
183    fn setup(
184        &self,
185        args: commands::setup::SetupArgs,
186    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
187    /// `lev dash` - takes over the real terminal and blocks on real keyboard input.
188    fn dashboard(
189        &self,
190        args: commands::dashboard::DashboardArgs,
191    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
192    /// `lev serve` - binds a real port and serves indefinitely.
193    fn serve(
194        &self,
195        args: commands::serve::ServeArgs,
196    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
197    /// `lev agent-client` - takes over real stdin/stdout to speak the Agent
198    /// Client Protocol against the shared-world daemon.
199    fn agent_client(
200        &self,
201        args: commands::agent_client::AgentClientArgs,
202    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
203    /// `lev daemon` - binds the control socket and serves the shared world.
204    fn daemon(
205        &self,
206        args: commands::daemon::DaemonArgs,
207    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
208    /// `lev mcp` - rewrites config, opens a browser for OAuth, touches the token store.
209    fn mcp(
210        &self,
211        args: commands::mcp::McpArgs,
212    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
213
214    /// `lev auth` - reads the config file and may write the OS credential store.
215    fn auth(
216        &self,
217        args: commands::auth::AuthArgs,
218    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
219
220    /// `lev update` - resolves the real executable, shells out to a package
221    /// manager, blocks on stdin for each confirmation, and rewrites the config.
222    fn update(
223        &self,
224        args: commands::update::UpdateArgs,
225    ) -> impl std::future::Future<Output = anyhow::Result<()>>;
226}
227
228/// Inject argv-prescanned dynamic `--<region>` seed flags into a parsed
229/// `run` command. A no-op for every other subcommand. Kept here (a tested lib
230/// seam) so the bin entrypoint's post-parse wiring stays branch-free.
231pub fn apply_region_flags(
232    command: &mut Commands,
233    regions: std::collections::HashMap<String, String>,
234) {
235    if let Commands::Run(args) = command {
236        args.regions = regions;
237    }
238}
239
240/// Route a parsed subcommand to its executor. Safe commands are called
241/// directly (and are exercised through `dispatch()` by the tests below); the
242/// I/O-risky ones go through `ex` (see [`RiskyExecutors`]).
243pub async fn dispatch(command: Commands, ex: &impl RiskyExecutors) -> anyhow::Result<()> {
244    match command {
245        Commands::Create(args) => commands::create::execute(args).await,
246        Commands::Setup(args) => ex.setup(args).await,
247        Commands::Run(args) => ex.run(args).await,
248        Commands::Ps(args) => ex.ps(args).await,
249        Commands::Msg(args) => ex.msg(args).await,
250        Commands::Cancel(args) => ex.cancel(args).await,
251        Commands::Pause(args) => ex.pause(args).await,
252        Commands::Resume(args) => ex.resume(args).await,
253        Commands::Respond(args) => ex.respond(args).await,
254        Commands::Doctor(args) => ex.doctor(args).await,
255        Commands::List(args) => commands::list::execute(args).await,
256        Commands::Add(args) => commands::add::execute(args).await,
257        Commands::Remove(args) => commands::remove::execute(args).await,
258        Commands::Test(args) => commands::test::execute(args).await,
259        Commands::Pack(args) => commands::pack::execute(args).await,
260        Commands::Dashboard(args) => ex.dashboard(args).await,
261        Commands::Models(args) => commands::models::execute(args).await,
262        Commands::Validate(args) => commands::validate::execute(args).await,
263        Commands::Tools(args) => commands::tools::execute(args).await,
264        Commands::Approvals(args) => commands::approvals::execute(args).await,
265        Commands::Policy(args) => commands::policy::execute(args).await,
266        Commands::Serve(args) => ex.serve(args).await,
267        Commands::AgentClient(args) => ex.agent_client(args).await,
268        Commands::Daemon(args) => ex.daemon(args).await,
269        Commands::Context(args) => commands::context::execute(args).await,
270        Commands::Stages(args) => commands::stages::execute(args).await,
271        Commands::Result(args) => commands::result::execute(args).await,
272        Commands::Mcp(args) => ex.mcp(args).await,
273        Commands::Auth(args) => ex.auth(args).await,
274        Commands::Update(args) => ex.update(args).await,
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    /// Test double for [`RiskyExecutors`]: every method is a no-op returning
283    /// `Ok(())`, so `dispatch()`'s risky routing arms are exercised without
284    /// touching a real terminal / stdin / port / subprocess.
285    struct MockRisky;
286
287    impl RiskyExecutors for MockRisky {
288        async fn run(&self, _args: commands::run::RunArgs) -> anyhow::Result<()> {
289            Ok(())
290        }
291        async fn ps(&self, _args: commands::ps::PsArgs) -> anyhow::Result<()> {
292            Ok(())
293        }
294        async fn msg(&self, _args: commands::ctl::MsgArgs) -> anyhow::Result<()> {
295            Ok(())
296        }
297        async fn respond(&self, _args: commands::ctl::RespondArgs) -> anyhow::Result<()> {
298            Ok(())
299        }
300        async fn doctor(&self, _args: commands::doctor::DoctorArgs) -> anyhow::Result<()> {
301            Ok(())
302        }
303        async fn cancel(&self, _args: commands::ctl::CancelArgs) -> anyhow::Result<()> {
304            Ok(())
305        }
306        async fn pause(&self, _args: commands::ctl::PauseArgs) -> anyhow::Result<()> {
307            Ok(())
308        }
309        async fn resume(&self, _args: commands::ctl::ResumeArgs) -> anyhow::Result<()> {
310            Ok(())
311        }
312        async fn setup(&self, _args: commands::setup::SetupArgs) -> anyhow::Result<()> {
313            Ok(())
314        }
315        async fn dashboard(&self, _args: commands::dashboard::DashboardArgs) -> anyhow::Result<()> {
316            Ok(())
317        }
318        async fn serve(&self, _args: commands::serve::ServeArgs) -> anyhow::Result<()> {
319            Ok(())
320        }
321        async fn agent_client(
322            &self,
323            _args: commands::agent_client::AgentClientArgs,
324        ) -> anyhow::Result<()> {
325            Ok(())
326        }
327        async fn daemon(&self, _args: commands::daemon::DaemonArgs) -> anyhow::Result<()> {
328            Ok(())
329        }
330        async fn auth(&self, _args: commands::auth::AuthArgs) -> anyhow::Result<()> {
331            Ok(())
332        }
333
334        async fn mcp(&self, _args: commands::mcp::McpArgs) -> anyhow::Result<()> {
335            Ok(())
336        }
337
338        async fn update(&self, _args: commands::update::UpdateArgs) -> anyhow::Result<()> {
339            Ok(())
340        }
341    }
342
343    fn create_args() -> commands::create::CreateArgs {
344        commands::create::CreateArgs {
345            name: "unused".to_string(),
346            template: "default".to_string(),
347        }
348    }
349
350    // ─── apply_region_flags ──────────────────────────────────────────────────
351
352    #[test]
353    fn apply_region_flags_populates_run_and_noops_other_commands() {
354        let mut run = Commands::Run(commands::run::RunArgs::default());
355        let flags = std::collections::HashMap::from([("criteria".to_string(), "safe".to_string())]);
356        apply_region_flags(&mut run, flags);
357        assert!(
358            matches!(&run, Commands::Run(a) if a.regions.get("criteria").map(String::as_str) == Some("safe")),
359            "region flag was injected into the Run args"
360        );
361        // A non-run command hits the no-op branch: it must not panic (and there
362        // is nothing to inject). Asserting the variant here would leave an
363        // always-false `matches!` arm uncovered, so the call itself is the check.
364        let mut other = Commands::Ps(commands::ps::PsArgs::default());
365        apply_region_flags(&mut other, std::collections::HashMap::new());
366    }
367
368    // ─── Risky variants: routed through the injected executor ────────────────
369
370    #[tokio::test]
371    async fn dispatch_run_variant_is_routed_through_the_executor() {
372        let result = dispatch(Commands::Run(commands::run::RunArgs::default()), &MockRisky).await;
373        assert!(result.is_ok());
374    }
375
376    #[tokio::test]
377    async fn dispatch_setup_variant_is_routed_through_the_executor() {
378        let args = commands::setup::SetupArgs {
379            non_interactive: true,
380            no_verify: false,
381            install_agents: false,
382            anthropic_key: None,
383            openai_key: None,
384            google_key: None,
385            openrouter_key: None,
386            ollama_url: None,
387            default_model: None,
388            claude_code: None,
389            claude_code_effort: None,
390        };
391        let result = dispatch(Commands::Setup(args), &MockRisky).await;
392        assert!(result.is_ok());
393    }
394
395    #[tokio::test]
396    async fn dispatch_dashboard_variant_is_routed_through_the_executor() {
397        let args = commands::dashboard::DashboardArgs {};
398        let result = dispatch(Commands::Dashboard(args), &MockRisky).await;
399        assert!(result.is_ok());
400    }
401
402    #[tokio::test]
403    async fn dispatch_msg_variant_is_routed_through_the_executor() {
404        let args = commands::ctl::MsgArgs {
405            agent_id: "a".to_string(),
406            content: "c".to_string(),
407        };
408        assert!(dispatch(Commands::Msg(args), &MockRisky).await.is_ok());
409    }
410
411    #[tokio::test]
412    async fn dispatch_respond_variant_is_routed_through_the_executor() {
413        let args = commands::ctl::RespondArgs {
414            request_id: None,
415            value: None,
416            choice: None,
417            approve: false,
418            deny: false,
419            session: false,
420            stage: false,
421            json: false,
422        };
423        assert!(dispatch(Commands::Respond(args), &MockRisky).await.is_ok());
424    }
425
426    #[tokio::test]
427    async fn dispatch_doctor_variant_is_routed_through_the_executor() {
428        // Routed, not called directly: `lev doctor` bills two inferences and
429        // auto-starts a daemon, so a unit test must never reach the real one.
430        let args = commands::doctor::DoctorArgs::default();
431        assert!(dispatch(Commands::Doctor(args), &MockRisky).await.is_ok());
432    }
433
434    #[tokio::test]
435    async fn dispatch_cancel_variant_is_routed_through_the_executor() {
436        let args = commands::ctl::CancelArgs {
437            run_id: "r".to_string(),
438            force: false,
439        };
440        assert!(dispatch(Commands::Cancel(args), &MockRisky).await.is_ok());
441    }
442
443    #[tokio::test]
444    async fn dispatch_pause_variant_is_routed_through_the_executor() {
445        let args = commands::ctl::PauseArgs {
446            run_id: "r".to_string(),
447        };
448        assert!(dispatch(Commands::Pause(args), &MockRisky).await.is_ok());
449    }
450
451    #[tokio::test]
452    async fn dispatch_resume_variant_is_routed_through_the_executor() {
453        let args = commands::ctl::ResumeArgs {
454            run_id: "r".to_string(),
455        };
456        assert!(dispatch(Commands::Resume(args), &MockRisky).await.is_ok());
457    }
458
459    #[tokio::test]
460    async fn dispatch_ps_variant_is_routed_through_the_executor() {
461        let result = dispatch(Commands::Ps(commands::ps::PsArgs::default()), &MockRisky).await;
462        assert!(result.is_ok());
463    }
464
465    #[tokio::test]
466    async fn dispatch_daemon_variant_is_routed_through_the_executor() {
467        let args = commands::daemon::DaemonArgs {
468            action: None,
469            socket: None,
470        };
471        let result = dispatch(Commands::Daemon(args), &MockRisky).await;
472        assert!(result.is_ok());
473    }
474
475    #[tokio::test]
476    async fn dispatch_auth_variant_is_routed_through_the_executor() {
477        let args = commands::auth::AuthArgs::status_for_test();
478        let result = dispatch(Commands::Auth(args), &MockRisky).await;
479        assert!(result.is_ok());
480    }
481
482    #[tokio::test]
483    async fn dispatch_update_variant_is_routed_through_the_executor() {
484        // Routed, not called directly: the real `lev update` shells out to a
485        // package manager and blocks on stdin, so a unit test must never reach
486        // it. Its own tests drive the command core against injected seams.
487        let args = commands::update::UpdateArgs::default();
488        let result = dispatch(Commands::Update(args), &MockRisky).await;
489        assert!(result.is_ok());
490    }
491
492    #[tokio::test]
493    async fn dispatch_mcp_variant_is_routed_through_the_executor() {
494        let args = commands::mcp::McpArgs::list_for_test();
495        let result = dispatch(Commands::Mcp(args), &MockRisky).await;
496        assert!(result.is_ok());
497    }
498
499    #[tokio::test]
500    async fn dispatch_serve_variant_is_routed_through_the_executor() {
501        let args = commands::serve::ServeArgs {
502            port: 0,
503            host: "127.0.0.1".to_string(),
504            cors: None,
505            token: Some("test-token".to_string()),
506            allow_admin: false,
507            workdir_root: None,
508            no_remote_yolo: false,
509            tls_cert: None,
510            tls_key: None,
511        };
512        let result = dispatch(Commands::Serve(args), &MockRisky).await;
513        assert!(result.is_ok());
514    }
515
516    #[tokio::test]
517    async fn dispatch_agent_client_variant_is_routed_through_the_executor() {
518        let args = commands::agent_client::AgentClientArgs::default();
519        let result = dispatch(Commands::AgentClient(args), &MockRisky).await;
520        assert!(result.is_ok());
521    }
522
523    // ─── Safe variants: called directly, driven through dispatch() ───────────
524
525    #[tokio::test]
526    async fn dispatch_create_variant_is_routed() {
527        // An already-existing directory makes `create::execute` return a real,
528        // harmless `Err` without touching anything outside a tempdir.
529        let dir = tempfile::tempdir().unwrap();
530        let args = commands::create::CreateArgs {
531            name: dir.path().to_str().unwrap().to_string(),
532            ..create_args()
533        };
534        let result = dispatch(Commands::Create(args), &MockRisky).await;
535        assert!(result.is_err());
536    }
537
538    #[tokio::test]
539    async fn dispatch_list_variant_is_routed() {
540        // Isolated: this reaches `Config::load()`, which reads process-wide
541        // environment. Unisolated it races every `temp_env` test in the binary.
542        crate::config::with_isolated_config_path_async("dispatch-list", |_fake_dir| async move {
543            let args = commands::list::ListArgs {
544                filter: commands::list::ListFilter::All,
545                json: false,
546            };
547            let result = dispatch(Commands::List(args), &MockRisky).await;
548            assert!(result.is_ok());
549        })
550        .await;
551    }
552
553    #[tokio::test]
554    async fn dispatch_add_variant_is_routed() {
555        let args = commands::add::AddArgs {
556            package: "definitely-not-a-real-bundle-xyz.leviath-bundle".to_string(),
557        };
558        // `add` loads the real config to report the `[read_paths]` grant status
559        // of what it installs, so it needs the same isolation every other
560        // config-touching test takes.
561        let result = crate::config::with_isolated_config_path_async("dispatch-add", |_| {
562            dispatch(Commands::Add(args), &MockRisky)
563        })
564        .await;
565        assert!(result.is_err());
566    }
567
568    #[tokio::test]
569    async fn dispatch_remove_variant_is_routed() {
570        let args = commands::remove::RemoveArgs {
571            name: "definitely-not-an-installed-agent-xyz".to_string(),
572        };
573        let result = dispatch(Commands::Remove(args), &MockRisky).await;
574        assert!(result.is_err());
575    }
576
577    #[tokio::test]
578    async fn dispatch_test_variant_is_routed() {
579        let dir = tempfile::tempdir().unwrap();
580        let args = commands::test::TestArgs {
581            path: Some(dir.path().to_str().unwrap().to_string()),
582            filter: None,
583            dry_run: true,
584        };
585        let result = dispatch(Commands::Test(args), &MockRisky).await;
586        assert!(result.is_err());
587    }
588
589    #[tokio::test]
590    async fn dispatch_pack_variant_is_routed() {
591        let dir = tempfile::tempdir().unwrap();
592        let args = commands::pack::PackArgs {
593            path: Some(dir.path().to_str().unwrap().to_string()),
594            output: None,
595        };
596        let result = dispatch(Commands::Pack(args), &MockRisky).await;
597        assert!(result.is_err());
598    }
599
600    #[tokio::test]
601    async fn dispatch_models_variant_is_routed() {
602        crate::config::with_isolated_config_path_async("dispatch-models", |_fake_dir| async move {
603            let args = commands::models::ModelsArgs {
604                command: commands::models::ModelsCommand::List(commands::models::ListArgs {
605                    provider: None,
606                    remote: false,
607                    all: false,
608                    json: false,
609                }),
610            };
611            let result = dispatch(Commands::Models(args), &MockRisky).await;
612            assert!(result.is_ok());
613        })
614        .await;
615    }
616
617    #[tokio::test]
618    async fn dispatch_validate_variant_is_routed() {
619        // `validate` loads the real config to answer "can this install reach
620        // the providers this blueprint names", so it needs the same isolation
621        // every other config-touching test takes.
622        crate::config::with_isolated_config_path_async("dispatch-validate", |_| async {
623            let dir = tempfile::tempdir().unwrap();
624            let args = commands::validate::ValidateArgs {
625                path: dir
626                    .path()
627                    .join("does-not-exist")
628                    .to_str()
629                    .unwrap()
630                    .to_string(),
631                deny_warnings: false,
632                json: false,
633            };
634            let result = dispatch(Commands::Validate(args), &MockRisky).await;
635            assert!(result.is_err());
636        })
637        .await;
638    }
639
640    #[tokio::test]
641    async fn dispatch_tools_variant_is_routed() {
642        // Point LEVIATH_HOME at a temp dir so the scan is hermetic; an empty
643        // tools dir just lists nothing and returns Ok (routing is exercised).
644        let home = tempfile::tempdir().unwrap();
645        let result = temp_env::async_with_vars(
646            [("LEVIATH_HOME", Some(home.path().to_str().unwrap()))],
647            async {
648                let args = commands::tools::ToolsArgs { json: false };
649                dispatch(Commands::Tools(args), &MockRisky).await
650            },
651        )
652        .await;
653        assert!(result.is_ok());
654    }
655
656    #[tokio::test]
657    async fn dispatch_routes_stages() {
658        // A run id that does not exist: the point is that the arm is wired to
659        // the command, not what the command finds.
660        let result = dispatch(
661            Commands::Stages(commands::stages::StagesArgs {
662                run_id: "no-such-run".to_string(),
663                json: false,
664                regions: false,
665            }),
666            &MockRisky,
667        )
668        .await;
669        assert!(result.is_err(), "no ledger for a run that never ran");
670    }
671
672    #[tokio::test]
673    async fn dispatch_context_variant_is_routed() {
674        // A run with no archive → the command errors (routing is exercised).
675        let args = commands::context::ContextArgs {
676            run_id: "no-such-run-xyzzy".to_string(),
677            json: false,
678            full: false,
679        };
680        let result = dispatch(Commands::Context(args), &MockRisky).await;
681        assert!(result.is_err());
682    }
683
684    #[tokio::test]
685    async fn dispatch_result_variant_is_routed() {
686        // A run that is not there → the command errors, which is what shows the
687        // routing reached it.
688        let args = commands::result::ResultArgs {
689            run_id: "no-such-run-xyzzy".to_string(),
690            json: false,
691            raw: false,
692        };
693        let result = dispatch(Commands::Result(args), &MockRisky).await;
694        assert!(result.is_err());
695    }
696
697    #[tokio::test]
698    async fn dispatch_approvals_variant_is_routed() {
699        // A temp home means an empty config, so the report is the shipped
700        // defaults and nothing touches the user's own file.
701        let home = tempfile::tempdir().unwrap();
702        let config = home.path().join("config.toml");
703        let result = temp_env::async_with_vars(
704            [
705                ("LEVIATH_HOME", Some(home.path().to_str().unwrap())),
706                ("LEVIATH_CONFIG_PATH", Some(config.to_str().unwrap())),
707            ],
708            async {
709                // Both spellings: with an agent named, and without, which is
710                // the form that reports only what every agent gets.
711                let args = commands::approvals::ApprovalsArgs {
712                    command: commands::approvals::ApprovalsCommand::Safe(
713                        commands::approvals::SafeArgs {
714                            agent: Some("coder".to_string()),
715                            json: true,
716                        },
717                    ),
718                };
719                dispatch(Commands::Approvals(args), &MockRisky).await
720            },
721        )
722        .await;
723        assert!(result.is_ok());
724    }
725
726    /// A config that will not parse has to surface, not be reported as "these
727    /// are your defaults" - the whole point of the command is telling the user
728    /// what is actually in effect.
729    #[tokio::test]
730    async fn dispatch_approvals_surfaces_a_broken_config() {
731        let home = tempfile::tempdir().unwrap();
732        let config = home.path().join("config.toml");
733        std::fs::write(&config, "this is not = = toml").unwrap();
734        let result = temp_env::async_with_vars(
735            [
736                ("LEVIATH_HOME", Some(home.path().to_str().unwrap())),
737                ("LEVIATH_CONFIG_PATH", Some(config.to_str().unwrap())),
738            ],
739            async {
740                let args = commands::approvals::ApprovalsArgs {
741                    command: commands::approvals::ApprovalsCommand::Safe(
742                        commands::approvals::SafeArgs {
743                            agent: None,
744                            json: false,
745                        },
746                    ),
747                };
748                dispatch(Commands::Approvals(args), &MockRisky).await
749            },
750        )
751        .await;
752        assert!(result.is_err());
753    }
754
755    #[tokio::test]
756    async fn dispatch_policy_list_variant_is_routed() {
757        let args = commands::policy::PolicyArgs {
758            command: commands::policy::PolicyCommand::List(commands::policy::PolicyListArgs {}),
759        };
760        let result = dispatch(Commands::Policy(args), &MockRisky).await;
761        assert!(result.is_ok());
762    }
763
764    #[tokio::test]
765    async fn dispatch_policy_test_variant_is_routed() {
766        let args = commands::policy::PolicyArgs {
767            command: commands::policy::PolicyCommand::Test(commands::policy::PolicyTestArgs {
768                tool: "shell".to_string(),
769                target: None,
770                taint: "public".to_string(),
771            }),
772        };
773        let result = dispatch(Commands::Policy(args), &MockRisky).await;
774        assert!(result.is_ok());
775    }
776}