Skip to main content

ironflow_cli/
cli.rs

1//! Command-line surface: global flags, command tree, and dispatch.
2//!
3//! Kept in the library rather than in `main.rs` so tests can parse arbitrary
4//! argument vectors -- in particular `tests/route_coverage.rs`, which checks
5//! that every API route is reachable through a command that really exists.
6
7use std::io;
8
9use anyhow::Result;
10use clap::{CommandFactory, Parser, Subcommand};
11use clap_complete::Shell;
12use clap_mangen::Man;
13use ironflow_sdk::IronflowClient;
14
15use crate::commands;
16use crate::commands::account::AccountArgs;
17use crate::commands::agent_role::AgentRoleArgs;
18use crate::commands::api_key::ApiKeyArgs;
19use crate::commands::audit_log::AuditLogArgs;
20use crate::commands::dashboard::DashboardArgs;
21use crate::commands::delegation::DelegationArgs;
22use crate::commands::init::InitArgs;
23use crate::commands::logs::LogsArgs;
24use crate::commands::run::RunArgs;
25use crate::commands::schedule::ScheduleArgs;
26use crate::commands::secret::SecretArgs;
27use crate::commands::signal::SignalArgs;
28use crate::commands::stats::StatsArgs;
29use crate::commands::template::TemplateArgs;
30use crate::commands::user::UserArgs;
31use crate::commands::workflow::WorkflowArgs;
32
33/// CLI for the Ironflow workflow engine.
34///
35/// # Examples
36///
37/// ```
38/// use clap::Parser;
39/// use ironflow_cli::cli::Cli;
40///
41/// let cli = Cli::try_parse_from(["ironflow-cli", "run", "list"])?;
42/// assert!(!cli.json);
43/// # Ok::<(), clap::Error>(())
44/// ```
45#[derive(Debug, Parser)]
46#[command(
47    name = "ironflow-cli",
48    version,
49    about = "Drive the Ironflow workflow engine from the terminal"
50)]
51pub struct Cli {
52    /// Output raw JSON instead of formatted tables.
53    #[arg(long, global = true)]
54    pub json: bool,
55
56    /// Show verbose output (e.g. full step details in `run get`).
57    #[arg(long, global = true)]
58    pub verbose: bool,
59
60    /// Override the Ironflow API base URL.
61    #[arg(long, global = true, env = "IRONFLOW_URL")]
62    pub url: Option<String>,
63
64    /// Override the API key for authentication.
65    #[arg(long, global = true, env = "IRONFLOW_API_KEY")]
66    pub api_key: Option<String>,
67
68    /// Command to execute.
69    #[command(subcommand)]
70    pub command: Commands,
71}
72
73/// Top-level commands.
74#[derive(Debug, Subcommand)]
75pub enum Commands {
76    /// Manage workflow runs.
77    Run(RunArgs),
78    /// Manage workflows.
79    Workflow(WorkflowArgs),
80    /// Stream run logs via SSE.
81    Logs(LogsArgs),
82    /// Show statistics (aggregate or historical).
83    Stats(StatsArgs),
84    /// Manage secrets (admin only).
85    Secret(SecretArgs),
86    /// Manage Provider Accounts (admin only).
87    #[command(name = "accounts")]
88    Accounts(AccountArgs),
89    /// Manage API keys.
90    #[command(name = "api-key")]
91    ApiKey(ApiKeyArgs),
92    /// Manage users (admin only).
93    User(UserArgs),
94    /// Inspect audit logs (admin only).
95    #[command(name = "audit-log")]
96    AuditLog(AuditLogArgs),
97    /// Manage workflow schedules.
98    Schedule(ScheduleArgs),
99    /// Manage approval delegations.
100    Delegation(DelegationArgs),
101    /// Send and list signals that resume waiting runs.
102    Signal(SignalArgs),
103    /// Inspect the cost of agent roles and set their operator cap.
104    #[command(name = "agent-role")]
105    AgentRole(AgentRoleArgs),
106    /// Manage workflow templates (add, list, info, create).
107    Template(TemplateArgs),
108    /// Scaffold a new Ironflow project.
109    Init(InitArgs),
110    /// Open the Ironflow dashboard in the default browser.
111    Dashboard(DashboardArgs),
112    /// Generate shell completions for the given shell.
113    Completions {
114        /// Target shell.
115        shell: Shell,
116    },
117    /// Generate a man page and write it to stdout.
118    Man,
119}
120
121/// Write shell completions for `shell` to `writer`.
122///
123/// # Errors
124///
125/// Returns an error if writing to `writer` fails.
126///
127/// # Examples
128///
129/// ```no_run
130/// use ironflow_cli::cli::generate_completions;
131/// use clap_complete::Shell;
132///
133/// let mut buf = Vec::new();
134/// generate_completions(Shell::Bash, &mut buf)?;
135/// assert!(!buf.is_empty());
136/// # Ok::<(), anyhow::Error>(())
137/// ```
138pub fn generate_completions(shell: Shell, writer: &mut impl io::Write) -> Result<()> {
139    let mut cmd = Cli::command();
140    clap_complete::generate(shell, &mut cmd, "ironflow-cli", writer);
141    Ok(())
142}
143
144/// Write a roff-formatted man page to `writer`.
145///
146/// # Errors
147///
148/// Returns an error if rendering or writing fails.
149///
150/// # Examples
151///
152/// ```no_run
153/// use ironflow_cli::cli::generate_man_page;
154///
155/// let mut buf = Vec::new();
156/// generate_man_page(&mut buf)?;
157/// assert!(!buf.is_empty());
158/// # Ok::<(), anyhow::Error>(())
159/// ```
160pub fn generate_man_page(writer: &mut impl io::Write) -> Result<()> {
161    let cmd = Cli::command();
162    Man::new(cmd).render(writer)?;
163    Ok(())
164}
165
166/// Dispatch a parsed command against a client.
167///
168/// # Errors
169///
170/// Returns an error on API failure, invalid input, or an unconfirmed
171/// destructive command.
172pub async fn dispatch(client: &IronflowClient, cli: &Cli) -> Result<()> {
173    match &cli.command {
174        Commands::Run(args) => commands::run::execute(client, args, cli.json, cli.verbose).await,
175        Commands::Workflow(args) => commands::workflow::execute(client, args, cli.json).await,
176        Commands::Logs(args) => commands::logs::execute(client, args, cli.json).await,
177        Commands::Stats(args) => commands::stats::execute(client, args, cli.json).await,
178        Commands::Secret(args) => commands::secret::execute(client, args, cli.json).await,
179        Commands::Accounts(args) => commands::account::execute(client, args, cli.json).await,
180        Commands::ApiKey(args) => commands::api_key::execute(client, args, cli.json).await,
181        Commands::User(args) => commands::user::execute(client, args, cli.json).await,
182        Commands::AuditLog(args) => commands::audit_log::execute(client, args, cli.json).await,
183        Commands::Schedule(args) => commands::schedule::execute(client, args, cli.json).await,
184        Commands::Delegation(args) => commands::delegation::execute(client, args, cli.json).await,
185        Commands::Signal(args) => commands::signal::execute(client, args, cli.json).await,
186        Commands::AgentRole(args) => commands::agent_role::execute(client, args, cli.json).await,
187        Commands::Template(args) => commands::template::execute(args),
188        Commands::Init(args) => commands::init::execute(args),
189        Commands::Dashboard(args) => commands::dashboard::execute(client, args),
190        Commands::Completions { shell } => generate_completions(*shell, &mut io::stdout()),
191        Commands::Man => generate_man_page(&mut io::stdout()),
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use clap::Parser;
198
199    use crate::commands::account::AccountCommands;
200    use crate::commands::agent_role::AgentRoleCommands;
201    use crate::commands::api_key::ApiKeyCommands;
202    use crate::commands::audit_log::AuditLogCommands;
203    use crate::commands::delegation::DelegationCommands;
204    use crate::commands::run::RunCommands;
205    use crate::commands::schedule::{CatchupArg, OverlapArg, ScheduleCommands};
206    use crate::commands::secret::SecretCommands;
207    use crate::commands::signal::SignalCommands;
208    use crate::commands::stats::StatsCommands;
209    use crate::commands::user::UserCommands;
210
211    use super::*;
212
213    const UUID: &str = "01234567-89ab-cdef-0123-456789abcdef";
214
215    fn parse(args: &[&str]) -> Cli {
216        Cli::try_parse_from(args).unwrap()
217    }
218
219    #[test]
220    fn parse_run_list() {
221        let cli = parse(&["ironflow-cli", "run", "list"]);
222        assert!(!cli.json);
223        assert!(matches!(cli.command, Commands::Run(_)));
224    }
225
226    #[test]
227    fn parse_run_list_with_json() {
228        let cli = parse(&["ironflow-cli", "--json", "run", "list"]);
229        assert!(cli.json);
230    }
231
232    #[test]
233    fn parse_run_create_with_payload() {
234        let cli = parse(&[
235            "ironflow-cli",
236            "run",
237            "create",
238            "deploy",
239            "--payload",
240            r#"{"env": "prod"}"#,
241        ]);
242        assert!(matches!(cli.command, Commands::Run(_)));
243    }
244
245    #[test]
246    fn parse_run_create_with_payload_file() {
247        let cli = parse(&[
248            "ironflow-cli",
249            "run",
250            "create",
251            "deploy",
252            "--payload-file",
253            "/tmp/payload.json",
254        ]);
255        assert!(matches!(cli.command, Commands::Run(_)));
256    }
257
258    #[test]
259    fn parse_run_create_with_concurrency_key() {
260        let cli = parse(&[
261            "ironflow-cli",
262            "run",
263            "create",
264            "deploy",
265            "--concurrency-key",
266            "issue:12",
267        ]);
268        let Commands::Run(args) = &cli.command else {
269            panic!("expected Run command");
270        };
271        let RunCommands::Create {
272            concurrency_key, ..
273        } = &args.command
274        else {
275            panic!("expected Create subcommand");
276        };
277        assert_eq!(concurrency_key.as_deref(), Some("issue:12"));
278    }
279
280    #[test]
281    fn parse_run_create_without_concurrency_key() {
282        let cli = parse(&["ironflow-cli", "run", "create", "deploy"]);
283        let Commands::Run(args) = &cli.command else {
284            panic!("expected Run command");
285        };
286        let RunCommands::Create {
287            concurrency_key, ..
288        } = &args.command
289        else {
290            panic!("expected Create subcommand");
291        };
292        assert!(concurrency_key.is_none());
293    }
294
295    #[test]
296    fn parse_run_create_with_repeated_concurrency_limits() {
297        let cli = parse(&[
298            "ironflow-cli",
299            "run",
300            "create",
301            "deploy",
302            "--concurrency-limit",
303            "repo:acme=2",
304            "--concurrency-limit",
305            "tenant:42=1",
306        ]);
307        let Commands::Run(args) = &cli.command else {
308            panic!("expected Run command");
309        };
310        let RunCommands::Create {
311            concurrency_limits, ..
312        } = &args.command
313        else {
314            panic!("expected Create subcommand");
315        };
316        let parsed: Vec<(&str, i32)> = concurrency_limits
317            .iter()
318            .map(|l| (l.group.as_str(), l.limit))
319            .collect();
320        assert_eq!(parsed, vec![("repo:acme", 2), ("tenant:42", 1)]);
321    }
322
323    #[test]
324    fn parse_run_create_without_concurrency_limits() {
325        let cli = parse(&["ironflow-cli", "run", "create", "deploy"]);
326        let Commands::Run(args) = &cli.command else {
327            panic!("expected Run command");
328        };
329        let RunCommands::Create {
330            concurrency_limits, ..
331        } = &args.command
332        else {
333            panic!("expected Create subcommand");
334        };
335        assert!(concurrency_limits.is_empty());
336    }
337
338    #[test]
339    fn parse_run_create_with_repeated_worker_tags() {
340        let cli = parse(&[
341            "ironflow-cli",
342            "run",
343            "create",
344            "deploy",
345            "--worker-tag",
346            "gpu",
347            "--worker-tag",
348            "region:eu",
349        ]);
350        let Commands::Run(args) = &cli.command else {
351            panic!("expected Run command");
352        };
353        let RunCommands::Create { worker_tags, .. } = &args.command else {
354            panic!("expected Create subcommand");
355        };
356        assert_eq!(
357            worker_tags,
358            &vec!["gpu".to_string(), "region:eu".to_string()]
359        );
360    }
361
362    #[test]
363    fn parse_run_create_without_worker_tags() {
364        let cli = parse(&["ironflow-cli", "run", "create", "deploy"]);
365        let Commands::Run(args) = &cli.command else {
366            panic!("expected Run command");
367        };
368        let RunCommands::Create { worker_tags, .. } = &args.command else {
369            panic!("expected Create subcommand");
370        };
371        assert!(worker_tags.is_empty());
372    }
373
374    #[test]
375    fn parse_run_create_rejects_a_malformed_concurrency_limit() {
376        let result = Cli::try_parse_from([
377            "ironflow-cli",
378            "run",
379            "create",
380            "deploy",
381            "--concurrency-limit",
382            "repo:acme",
383        ]);
384        assert!(result.is_err());
385    }
386
387    #[test]
388    fn parse_run_list_with_concurrency_group() {
389        let cli = parse(&[
390            "ironflow-cli",
391            "run",
392            "list",
393            "--concurrency-group",
394            "repo:acme",
395        ]);
396        let Commands::Run(args) = &cli.command else {
397            panic!("expected Run command");
398        };
399        let RunCommands::List {
400            concurrency_group, ..
401        } = &args.command
402        else {
403            panic!("expected List subcommand");
404        };
405        assert_eq!(concurrency_group.as_deref(), Some("repo:acme"));
406    }
407
408    #[test]
409    fn parse_run_create_with_a_negative_priority() {
410        let cli = parse(&[
411            "ironflow-cli",
412            "run",
413            "create",
414            "deploy",
415            "--priority",
416            "-40",
417        ]);
418        let Commands::Run(args) = &cli.command else {
419            panic!("expected Run command");
420        };
421        let RunCommands::Create { priority, .. } = &args.command else {
422            panic!("expected Create subcommand");
423        };
424        assert_eq!(*priority, Some(-40));
425    }
426
427    #[test]
428    fn parse_run_create_without_priority() {
429        let cli = parse(&["ironflow-cli", "run", "create", "deploy"]);
430        let Commands::Run(args) = &cli.command else {
431            panic!("expected Run command");
432        };
433        let RunCommands::Create { priority, .. } = &args.command else {
434            panic!("expected Create subcommand");
435        };
436        assert!(priority.is_none());
437    }
438
439    #[test]
440    fn parse_run_create_rejects_an_out_of_range_priority() {
441        for value in ["101", "-101", "high"] {
442            let result = Cli::try_parse_from([
443                "ironflow-cli",
444                "run",
445                "create",
446                "deploy",
447                "--priority",
448                value,
449            ]);
450            assert!(result.is_err(), "--priority {value} must be rejected");
451        }
452    }
453
454    #[test]
455    fn parse_run_list_with_priority() {
456        let cli = parse(&["ironflow-cli", "run", "list", "--priority", "100"]);
457        let Commands::Run(args) = &cli.command else {
458            panic!("expected Run command");
459        };
460        let RunCommands::List { priority, .. } = &args.command else {
461            panic!("expected List subcommand");
462        };
463        assert_eq!(*priority, Some(100));
464    }
465
466    #[test]
467    fn parse_run_list_rejects_an_out_of_range_priority() {
468        let result = Cli::try_parse_from(["ironflow-cli", "run", "list", "--priority", "-500"]);
469        assert!(result.is_err());
470    }
471
472    #[test]
473    fn parse_schedule_create_with_priority() {
474        let cli = parse(&[
475            "ironflow-cli",
476            "schedule",
477            "create",
478            "deploy",
479            "0 0 * * *",
480            "--priority",
481            "-5",
482        ]);
483        let Commands::Schedule(args) = &cli.command else {
484            panic!("expected Schedule command");
485        };
486        let ScheduleCommands::Create { priority, .. } = &args.command else {
487            panic!("expected Create subcommand");
488        };
489        assert_eq!(*priority, Some(-5));
490    }
491
492    #[test]
493    fn parse_schedule_create_with_catchup_and_timezone() {
494        let cli = parse(&[
495            "ironflow-cli",
496            "schedule",
497            "create",
498            "deploy",
499            "0 9 * * *",
500            "--catchup",
501            "all",
502            "--catchup-max",
503            "24",
504            "--catchup-window",
505            "3600",
506            "--overlap",
507            "skip",
508            "--timezone",
509            "Europe/Paris",
510        ]);
511        let Commands::Schedule(args) = &cli.command else {
512            panic!("expected Schedule command");
513        };
514        let ScheduleCommands::Create {
515            catchup,
516            catchup_max,
517            catchup_window_secs,
518            overlap,
519            timezone,
520            ..
521        } = &args.command
522        else {
523            panic!("expected Create subcommand");
524        };
525        assert_eq!(*catchup, Some(CatchupArg::All));
526        assert_eq!(*catchup_max, Some(24));
527        assert_eq!(*catchup_window_secs, Some(3600));
528        assert_eq!(*overlap, Some(OverlapArg::Skip));
529        assert_eq!(timezone.as_deref(), Some("Europe/Paris"));
530    }
531
532    #[test]
533    fn parse_schedule_create_defaults_leave_the_policy_to_the_server() {
534        let cli = parse(&["ironflow-cli", "schedule", "create", "deploy", "0 9 * * *"]);
535        let Commands::Schedule(args) = &cli.command else {
536            panic!("expected Schedule command");
537        };
538        let ScheduleCommands::Create {
539            catchup,
540            catchup_max,
541            catchup_window_secs,
542            overlap,
543            timezone,
544            ..
545        } = &args.command
546        else {
547            panic!("expected Create subcommand");
548        };
549        assert_eq!(*catchup, None);
550        assert_eq!(*catchup_max, None);
551        assert_eq!(*catchup_window_secs, None);
552        assert_eq!(*overlap, None);
553        assert_eq!(*timezone, None);
554    }
555
556    #[test]
557    fn parse_schedule_create_rejects_catchup_max_zero() {
558        let result = Cli::try_parse_from([
559            "ironflow-cli",
560            "schedule",
561            "create",
562            "deploy",
563            "0 0 * * *",
564            "--catchup-max",
565            "0",
566        ]);
567        assert!(result.is_err());
568    }
569
570    #[test]
571    fn parse_schedule_create_rejects_a_catchup_window_below_a_minute() {
572        let result = Cli::try_parse_from([
573            "ironflow-cli",
574            "schedule",
575            "create",
576            "deploy",
577            "0 0 * * *",
578            "--catchup-window",
579            "59",
580        ]);
581        assert!(result.is_err());
582    }
583
584    #[test]
585    fn parse_schedule_create_rejects_an_unknown_catchup() {
586        let result = Cli::try_parse_from([
587            "ironflow-cli",
588            "schedule",
589            "create",
590            "deploy",
591            "0 0 * * *",
592            "--catchup",
593            "sometimes",
594        ]);
595        assert!(result.is_err());
596    }
597
598    #[test]
599    fn parse_schedule_create_rejects_an_out_of_range_priority() {
600        let result = Cli::try_parse_from([
601            "ironflow-cli",
602            "schedule",
603            "create",
604            "deploy",
605            "0 0 * * *",
606            "--priority",
607            "150",
608        ]);
609        assert!(result.is_err());
610    }
611
612    #[test]
613    fn parse_run_create_payload_and_file_conflict() {
614        let result = Cli::try_parse_from([
615            "ironflow-cli",
616            "run",
617            "create",
618            "deploy",
619            "--payload",
620            "{}",
621            "--payload-file",
622            "/tmp/p.json",
623        ]);
624        assert!(result.is_err());
625    }
626
627    #[test]
628    fn parse_run_get() {
629        let cli = parse(&["ironflow-cli", "run", "get", UUID]);
630        assert!(matches!(cli.command, Commands::Run(_)));
631    }
632
633    #[test]
634    fn parse_run_cancel() {
635        let cli = parse(&["ironflow-cli", "run", "cancel", UUID]);
636        assert!(matches!(cli.command, Commands::Run(_)));
637    }
638
639    #[test]
640    fn parse_run_approve() {
641        let cli = parse(&["ironflow-cli", "run", "approve", UUID]);
642        assert!(matches!(cli.command, Commands::Run(_)));
643    }
644
645    #[test]
646    fn parse_run_reject() {
647        let cli = parse(&["ironflow-cli", "run", "reject", UUID]);
648        assert!(matches!(cli.command, Commands::Run(_)));
649    }
650
651    #[test]
652    fn parse_run_reject_requires_an_id() {
653        assert!(Cli::try_parse_from(["ironflow-cli", "run", "reject"]).is_err());
654    }
655
656    #[test]
657    fn parse_run_retry() {
658        let cli = parse(&["ironflow-cli", "run", "retry", UUID]);
659        assert!(matches!(cli.command, Commands::Run(_)));
660    }
661
662    #[test]
663    fn parse_run_list_with_filters() {
664        let cli = parse(&[
665            "ironflow-cli",
666            "run",
667            "list",
668            "--status",
669            "completed",
670            "--workflow",
671            "deploy",
672            "--page",
673            "2",
674            "--per-page",
675            "50",
676        ]);
677        assert!(matches!(cli.command, Commands::Run(_)));
678    }
679
680    #[test]
681    fn parse_workflow_list() {
682        let cli = parse(&["ironflow-cli", "workflow", "list"]);
683        assert!(matches!(cli.command, Commands::Workflow(_)));
684    }
685
686    #[test]
687    fn parse_workflow_get() {
688        let cli = parse(&["ironflow-cli", "workflow", "get", "deploy"]);
689        assert!(matches!(cli.command, Commands::Workflow(_)));
690    }
691
692    #[test]
693    fn parse_logs() {
694        let cli = parse(&["ironflow-cli", "logs", UUID]);
695        assert!(matches!(cli.command, Commands::Logs(_)));
696    }
697
698    #[test]
699    fn parse_logs_follow() {
700        let cli = parse(&["ironflow-cli", "logs", UUID, "--follow"]);
701        let Commands::Logs(args) = &cli.command else {
702            panic!("expected Logs command");
703        };
704        assert!(args.follow);
705    }
706
707    #[test]
708    fn parse_stats() {
709        let cli = parse(&["ironflow-cli", "stats"]);
710        assert!(matches!(cli.command, Commands::Stats(_)));
711    }
712
713    #[test]
714    fn parse_verbose_flag() {
715        let cli = parse(&["ironflow-cli", "--verbose", "stats"]);
716        assert!(cli.verbose);
717    }
718
719    #[test]
720    fn parse_url_override() {
721        let cli = parse(&[
722            "ironflow-cli",
723            "--url",
724            "https://custom.example.com",
725            "stats",
726        ]);
727        assert_eq!(cli.url.as_deref(), Some("https://custom.example.com"));
728    }
729
730    #[test]
731    fn parse_invalid_uuid_rejected() {
732        assert!(Cli::try_parse_from(["ironflow-cli", "run", "get", "not-a-uuid"]).is_err());
733    }
734
735    #[test]
736    fn parse_no_command_fails() {
737        assert!(Cli::try_parse_from(["ironflow-cli"]).is_err());
738    }
739
740    // -- Provider Accounts --
741
742    #[test]
743    fn parse_accounts_add_with_token_stdin() {
744        let cli = parse(&["ironflow-cli", "accounts", "add", "perso", "--token-stdin"]);
745        let Commands::Accounts(args) = &cli.command else {
746            panic!("expected Accounts command");
747        };
748        let AccountCommands::Add {
749            name,
750            kind,
751            token_stdin,
752            ..
753        } = &args.command
754        else {
755            panic!("expected Add subcommand");
756        };
757        assert_eq!(name, "perso");
758        assert_eq!(kind, "claude_subscription");
759        assert!(*token_stdin);
760    }
761
762    #[test]
763    fn parse_accounts_add_requires_token_stdin() {
764        assert!(Cli::try_parse_from(["ironflow-cli", "accounts", "add", "perso"]).is_err());
765    }
766
767    #[test]
768    fn parse_accounts_remove_with_yes() {
769        let cli = parse(&["ironflow-cli", "accounts", "remove", "perso", "--yes"]);
770        let Commands::Accounts(args) = &cli.command else {
771            panic!("expected Accounts command");
772        };
773        let AccountCommands::Remove { account, yes } = &args.command else {
774            panic!("expected Remove subcommand");
775        };
776        assert_eq!(account, "perso");
777        assert!(*yes);
778    }
779
780    #[test]
781    fn parse_accounts_list() {
782        let cli = parse(&["ironflow-cli", "accounts", "list"]);
783        assert!(matches!(cli.command, Commands::Accounts(_)));
784    }
785
786    #[test]
787    fn parse_accounts_update_rejects_enable_and_disable() {
788        let args = [
789            "ironflow-cli",
790            "accounts",
791            "update",
792            "perso",
793            "--enable",
794            "--disable",
795        ];
796        assert!(Cli::try_parse_from(args).is_err());
797    }
798
799    // ── Secrets ────────────────────────────────────────────────────
800
801    #[test]
802    fn parse_secret_list() {
803        let cli = parse(&["ironflow-cli", "secret", "list"]);
804        assert!(matches!(cli.command, Commands::Secret(_)));
805    }
806
807    #[test]
808    fn parse_secret_set_with_inline_value() {
809        let cli = parse(&["ironflow-cli", "secret", "set", "db/password", "hunter2"]);
810        let Commands::Secret(args) = &cli.command else {
811            panic!("expected Secret command");
812        };
813        let SecretCommands::Set { key, value } = &args.command else {
814            panic!("expected Set subcommand");
815        };
816        assert_eq!(key, "db/password");
817        assert_eq!(value.as_deref(), Some("hunter2"));
818    }
819
820    #[test]
821    fn parse_secret_set_without_value_defers_to_stdin() {
822        let cli = parse(&["ironflow-cli", "secret", "set", "db/password"]);
823        let Commands::Secret(args) = &cli.command else {
824            panic!("expected Secret command");
825        };
826        let SecretCommands::Set { value, .. } = &args.command else {
827            panic!("expected Set subcommand");
828        };
829        assert!(value.is_none());
830    }
831
832    #[test]
833    fn parse_secret_set_requires_a_key() {
834        assert!(Cli::try_parse_from(["ironflow-cli", "secret", "set"]).is_err());
835    }
836
837    #[test]
838    fn parse_secret_update() {
839        let cli = parse(&["ironflow-cli", "secret", "update", "db/password", "new"]);
840        assert!(matches!(cli.command, Commands::Secret(_)));
841    }
842
843    #[test]
844    fn parse_secret_delete_with_yes() {
845        let cli = parse(&["ironflow-cli", "secret", "delete", "db/password", "--yes"]);
846        let Commands::Secret(args) = &cli.command else {
847            panic!("expected Secret command");
848        };
849        let SecretCommands::Delete { yes, .. } = &args.command else {
850            panic!("expected Delete subcommand");
851        };
852        assert!(yes);
853    }
854
855    #[test]
856    fn parse_secret_delete_defaults_to_confirming() {
857        let cli = parse(&["ironflow-cli", "secret", "delete", "db/password"]);
858        let Commands::Secret(args) = &cli.command else {
859            panic!("expected Secret command");
860        };
861        let SecretCommands::Delete { yes, .. } = &args.command else {
862            panic!("expected Delete subcommand");
863        };
864        assert!(!yes);
865    }
866
867    #[test]
868    fn parse_secret_rotate_defaults_to_the_active_version() {
869        let cli = parse(&["ironflow-cli", "secret", "rotate"]);
870        let Commands::Secret(args) = &cli.command else {
871            panic!("expected Secret command");
872        };
873        let SecretCommands::Rotate(rotate) = &args.command else {
874            panic!("expected Rotate subcommand");
875        };
876        assert!(rotate.to_version.is_none());
877        assert_eq!(rotate.batch_size, 100);
878    }
879
880    #[test]
881    fn parse_secret_rotate_with_version_and_batch_size() {
882        let cli = parse(&[
883            "ironflow-cli",
884            "secret",
885            "rotate",
886            "--to-version",
887            "2",
888            "--batch-size",
889            "50",
890        ]);
891        let Commands::Secret(args) = &cli.command else {
892            panic!("expected Secret command");
893        };
894        let SecretCommands::Rotate(rotate) = &args.command else {
895            panic!("expected Rotate subcommand");
896        };
897        assert_eq!(rotate.to_version, Some(2));
898        assert_eq!(rotate.batch_size, 50);
899    }
900
901    #[test]
902    fn parse_secret_rotate_rejects_a_non_positive_version() {
903        let zero = ["ironflow-cli", "secret", "rotate", "--to-version", "0"];
904        let negative = ["ironflow-cli", "secret", "rotate", "--to-version", "-1"];
905        assert!(Cli::try_parse_from(zero).is_err());
906        assert!(Cli::try_parse_from(negative).is_err());
907    }
908
909    #[test]
910    fn parse_secret_rotate_rejects_an_out_of_range_batch_size() {
911        let zero = ["ironflow-cli", "secret", "rotate", "--batch-size", "0"];
912        let too_large = ["ironflow-cli", "secret", "rotate", "--batch-size", "1001"];
913        assert!(Cli::try_parse_from(zero).is_err());
914        assert!(Cli::try_parse_from(too_large).is_err());
915    }
916
917    #[test]
918    fn parse_secret_key_status_takes_no_arguments() {
919        let cli = parse(&["ironflow-cli", "secret", "key-status"]);
920        let Commands::Secret(args) = &cli.command else {
921            panic!("expected Secret command");
922        };
923        assert!(matches!(args.command, SecretCommands::KeyStatus));
924        assert!(Cli::try_parse_from(["ironflow-cli", "secret", "key-status", "extra"]).is_err());
925    }
926
927    // ── API keys ───────────────────────────────────────────────────
928
929    #[test]
930    fn parse_api_key_list() {
931        let cli = parse(&["ironflow-cli", "api-key", "list"]);
932        assert!(matches!(cli.command, Commands::ApiKey(_)));
933    }
934
935    #[test]
936    fn parse_api_key_scopes() {
937        let cli = parse(&["ironflow-cli", "api-key", "scopes"]);
938        assert!(matches!(cli.command, Commands::ApiKey(_)));
939    }
940
941    #[test]
942    fn parse_api_key_create_with_several_scopes() {
943        let cli = parse(&[
944            "ironflow-cli",
945            "api-key",
946            "create",
947            "ci",
948            "--scope",
949            "runs_read",
950            "--scope",
951            "runs_write",
952        ]);
953        let Commands::ApiKey(args) = &cli.command else {
954            panic!("expected ApiKey command");
955        };
956        let ApiKeyCommands::Create { scopes, .. } = &args.command else {
957            panic!("expected Create subcommand");
958        };
959        assert_eq!(scopes.len(), 2);
960    }
961
962    #[test]
963    fn parse_api_key_create_requires_a_scope() {
964        assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci"]).is_err());
965    }
966
967    #[test]
968    fn parse_api_key_create_rejects_an_unknown_scope() {
969        let result =
970            Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci", "--scope", "root"]);
971        assert!(result.is_err());
972    }
973
974    #[test]
975    fn parse_api_key_create_with_expiry() {
976        let cli = parse(&[
977            "ironflow-cli",
978            "api-key",
979            "create",
980            "ci",
981            "--scope",
982            "admin",
983            "--expires-at",
984            "2026-12-31T23:59:59Z",
985        ]);
986        assert!(matches!(cli.command, Commands::ApiKey(_)));
987    }
988
989    #[test]
990    fn parse_api_key_create_rejects_a_malformed_expiry() {
991        let result = Cli::try_parse_from([
992            "ironflow-cli",
993            "api-key",
994            "create",
995            "ci",
996            "--scope",
997            "admin",
998            "--expires-at",
999            "tomorrow",
1000        ]);
1001        assert!(result.is_err());
1002    }
1003
1004    #[test]
1005    fn parse_api_key_delete_rejects_a_non_uuid() {
1006        assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "delete", "abc"]).is_err());
1007    }
1008
1009    // ── Users ──────────────────────────────────────────────────────
1010
1011    #[test]
1012    fn parse_user_list() {
1013        let cli = parse(&["ironflow-cli", "user", "list"]);
1014        assert!(matches!(cli.command, Commands::User(_)));
1015    }
1016
1017    #[test]
1018    fn parse_user_create() {
1019        let cli = parse(&[
1020            "ironflow-cli",
1021            "user",
1022            "create",
1023            "alice",
1024            "--email",
1025            "alice@example.com",
1026            "--password",
1027            "hunter2hunter2",
1028            "--admin",
1029        ]);
1030        let Commands::User(args) = &cli.command else {
1031            panic!("expected User command");
1032        };
1033        let UserCommands::Create { admin, .. } = &args.command else {
1034            panic!("expected Create subcommand");
1035        };
1036        assert!(admin);
1037    }
1038
1039    #[test]
1040    fn parse_user_create_requires_an_email() {
1041        assert!(Cli::try_parse_from(["ironflow-cli", "user", "create", "alice"]).is_err());
1042    }
1043
1044    #[test]
1045    fn parse_user_set_role_admin() {
1046        let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--admin"]);
1047        let Commands::User(args) = &cli.command else {
1048            panic!("expected User command");
1049        };
1050        let UserCommands::SetRole { admin, member, .. } = &args.command else {
1051            panic!("expected SetRole subcommand");
1052        };
1053        assert!(admin);
1054        assert!(!member);
1055    }
1056
1057    #[test]
1058    fn parse_user_set_role_member() {
1059        let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--member"]);
1060        let Commands::User(args) = &cli.command else {
1061            panic!("expected User command");
1062        };
1063        let UserCommands::SetRole { admin, .. } = &args.command else {
1064            panic!("expected SetRole subcommand");
1065        };
1066        assert!(!admin);
1067    }
1068
1069    #[test]
1070    fn parse_user_set_role_requires_a_role() {
1071        assert!(Cli::try_parse_from(["ironflow-cli", "user", "set-role", UUID]).is_err());
1072    }
1073
1074    #[test]
1075    fn parse_user_set_role_rejects_both_roles() {
1076        let result = Cli::try_parse_from([
1077            "ironflow-cli",
1078            "user",
1079            "set-role",
1080            UUID,
1081            "--admin",
1082            "--member",
1083        ]);
1084        assert!(result.is_err());
1085    }
1086
1087    // ── Audit logs ─────────────────────────────────────────────────
1088
1089    #[test]
1090    fn parse_audit_log_list_without_filters() {
1091        let cli = parse(&["ironflow-cli", "audit-log", "list"]);
1092        assert!(matches!(cli.command, Commands::AuditLog(_)));
1093    }
1094
1095    #[test]
1096    fn parse_audit_log_list_with_every_filter() {
1097        let cli = parse(&[
1098            "ironflow-cli",
1099            "audit-log",
1100            "list",
1101            "--run",
1102            UUID,
1103            "--type",
1104            "run_created",
1105            "--from",
1106            "2026-01-01T00:00:00Z",
1107            "--to",
1108            "2026-12-31T23:59:59Z",
1109            "--page",
1110            "2",
1111            "--per-page",
1112            "10",
1113        ]);
1114        let Commands::AuditLog(args) = &cli.command else {
1115            panic!("expected AuditLog command");
1116        };
1117        let AuditLogCommands::List {
1118            run,
1119            event_type,
1120            from,
1121            to,
1122            page,
1123            per_page,
1124        } = &args.command;
1125        assert!(run.is_some());
1126        assert!(event_type.is_some());
1127        assert!(from.is_some());
1128        assert!(to.is_some());
1129        assert_eq!(*page, Some(2));
1130        assert_eq!(*per_page, Some(10));
1131    }
1132
1133    #[test]
1134    fn parse_audit_log_list_rejects_an_unknown_type() {
1135        let result =
1136            Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--type", "exploded"]);
1137        assert!(result.is_err());
1138    }
1139
1140    #[test]
1141    fn parse_audit_log_list_rejects_a_malformed_date() {
1142        let result = Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--from", "hier"]);
1143        assert!(result.is_err());
1144    }
1145
1146    // ── Approval delegations ───────────────────────────────────────
1147
1148    #[test]
1149    fn parse_delegation_list() {
1150        let cli = parse(&["ironflow-cli", "delegation", "list"]);
1151        assert!(matches!(cli.command, Commands::Delegation(_)));
1152    }
1153
1154    #[test]
1155    fn parse_delegation_list_with_every_flag() {
1156        let cli = parse(&[
1157            "ironflow-cli",
1158            "delegation",
1159            "list",
1160            "--from-user",
1161            UUID,
1162            "--to-user",
1163            UUID,
1164            "--page",
1165            "2",
1166            "--per-page",
1167            "10",
1168        ]);
1169        let Commands::Delegation(args) = &cli.command else {
1170            panic!("expected Delegation command");
1171        };
1172        let DelegationCommands::List {
1173            from_user,
1174            to_user,
1175            page,
1176            per_page,
1177        } = &args.command
1178        else {
1179            panic!("expected List subcommand");
1180        };
1181        assert!(from_user.is_some());
1182        assert!(to_user.is_some());
1183        assert_eq!(*page, Some(2));
1184        assert_eq!(*per_page, Some(10));
1185    }
1186
1187    #[test]
1188    fn parse_delegation_create_with_every_flag() {
1189        let cli = parse(&[
1190            "ironflow-cli",
1191            "delegation",
1192            "create",
1193            UUID,
1194            "--until",
1195            "2026-12-31T23:59:59Z",
1196            "--from",
1197            "2026-12-01T00:00:00Z",
1198            "--workflow",
1199            "deploy-*",
1200        ]);
1201        let Commands::Delegation(args) = &cli.command else {
1202            panic!("expected Delegation command");
1203        };
1204        let DelegationCommands::Create {
1205            until,
1206            from,
1207            workflow,
1208            ..
1209        } = &args.command
1210        else {
1211            panic!("expected Create subcommand");
1212        };
1213        assert_eq!(until, "2026-12-31T23:59:59Z");
1214        assert_eq!(from.as_deref(), Some("2026-12-01T00:00:00Z"));
1215        assert_eq!(workflow.as_deref(), Some("deploy-*"));
1216    }
1217
1218    #[test]
1219    fn parse_delegation_create_requires_an_until() {
1220        assert!(Cli::try_parse_from(["ironflow-cli", "delegation", "create", UUID]).is_err());
1221    }
1222
1223    #[test]
1224    fn parse_delegation_create_rejects_a_non_uuid_target() {
1225        let result = Cli::try_parse_from([
1226            "ironflow-cli",
1227            "delegation",
1228            "create",
1229            "alice",
1230            "--until",
1231            "2026-12-31T23:59:59Z",
1232        ]);
1233        assert!(result.is_err());
1234    }
1235
1236    #[test]
1237    fn parse_delegation_delete_defaults_to_confirming() {
1238        let cli = parse(&["ironflow-cli", "delegation", "delete", UUID]);
1239        let Commands::Delegation(args) = &cli.command else {
1240            panic!("expected Delegation command");
1241        };
1242        let DelegationCommands::Delete { yes, .. } = &args.command else {
1243            panic!("expected Delete subcommand");
1244        };
1245        assert!(!yes);
1246    }
1247
1248    // ── Completions & man ───────────────────────────────────────
1249
1250    #[test]
1251    fn parse_completions_bash() {
1252        let cli = parse(&["ironflow-cli", "completions", "bash"]);
1253        let Commands::Completions { shell } = &cli.command else {
1254            panic!("expected Completions command");
1255        };
1256        assert_eq!(*shell, Shell::Bash);
1257    }
1258
1259    #[test]
1260    fn parse_completions_zsh() {
1261        let cli = parse(&["ironflow-cli", "completions", "zsh"]);
1262        let Commands::Completions { shell } = &cli.command else {
1263            panic!("expected Completions command");
1264        };
1265        assert_eq!(*shell, Shell::Zsh);
1266    }
1267
1268    #[test]
1269    fn parse_completions_fish() {
1270        let cli = parse(&["ironflow-cli", "completions", "fish"]);
1271        let Commands::Completions { shell } = &cli.command else {
1272            panic!("expected Completions command");
1273        };
1274        assert_eq!(*shell, Shell::Fish);
1275    }
1276
1277    #[test]
1278    fn parse_completions_powershell() {
1279        let cli = parse(&["ironflow-cli", "completions", "powershell"]);
1280        let Commands::Completions { shell } = &cli.command else {
1281            panic!("expected Completions command");
1282        };
1283        assert_eq!(*shell, Shell::PowerShell);
1284    }
1285
1286    #[test]
1287    fn parse_completions_requires_shell() {
1288        assert!(Cli::try_parse_from(["ironflow-cli", "completions"]).is_err());
1289    }
1290
1291    #[test]
1292    fn parse_completions_rejects_unknown_shell() {
1293        assert!(Cli::try_parse_from(["ironflow-cli", "completions", "nushell"]).is_err());
1294    }
1295
1296    #[test]
1297    fn parse_man() {
1298        let cli = parse(&["ironflow-cli", "man"]);
1299        assert!(matches!(cli.command, Commands::Man));
1300    }
1301
1302    #[test]
1303    fn completions_bash_output_is_valid() {
1304        let mut buf = Vec::new();
1305        super::generate_completions(Shell::Bash, &mut buf).unwrap();
1306        let output = String::from_utf8(buf).unwrap();
1307        assert!(output.contains("ironflow-cli"));
1308    }
1309
1310    #[test]
1311    fn man_page_output_is_valid() {
1312        let mut buf = Vec::new();
1313        super::generate_man_page(&mut buf).unwrap();
1314        let output = String::from_utf8(buf).unwrap();
1315        assert!(output.contains(".TH"));
1316        assert!(output.contains("ironflow-cli"));
1317    }
1318
1319    // ---- template ----
1320
1321    #[test]
1322    fn parse_template_list() {
1323        let cli = parse(&[
1324            "ironflow-cli",
1325            "template",
1326            "list",
1327            "https://github.com/user/templates",
1328        ]);
1329        assert!(matches!(cli.command, Commands::Template(_)));
1330    }
1331
1332    #[test]
1333    fn parse_template_add_with_from() {
1334        let cli = parse(&[
1335            "ironflow-cli",
1336            "template",
1337            "add",
1338            "ci-pipeline",
1339            "--from",
1340            "https://github.com/user/templates",
1341        ]);
1342        assert!(matches!(cli.command, Commands::Template(_)));
1343    }
1344
1345    #[test]
1346    fn parse_template_add_with_output() {
1347        let cli = parse(&[
1348            "ironflow-cli",
1349            "template",
1350            "add",
1351            "ci-pipeline",
1352            "--from",
1353            "https://github.com/user/templates",
1354            "--output",
1355            "my/custom/path",
1356        ]);
1357        assert!(matches!(cli.command, Commands::Template(_)));
1358    }
1359
1360    #[test]
1361    fn parse_template_list_registry() {
1362        let cli = parse(&["ironflow-cli", "template", "list", "--registry"]);
1363        assert!(matches!(cli.command, Commands::Template(_)));
1364    }
1365
1366    #[test]
1367    fn parse_template_update() {
1368        let cli = parse(&["ironflow-cli", "template", "update"]);
1369        assert!(matches!(cli.command, Commands::Template(_)));
1370    }
1371
1372    #[test]
1373    fn parse_template_info() {
1374        let cli = parse(&[
1375            "ironflow-cli",
1376            "template",
1377            "info",
1378            "https://github.com/user/templates",
1379            "ci-pipeline",
1380        ]);
1381        assert!(matches!(cli.command, Commands::Template(_)));
1382    }
1383
1384    #[test]
1385    fn parse_template_requires_subcommand() {
1386        let result = Cli::try_parse_from(["ironflow-cli", "template"]);
1387        assert!(result.is_err());
1388    }
1389
1390    // ── Signals ────────────────────────────────────────────────────
1391
1392    #[test]
1393    fn parse_signal_send_with_every_flag() {
1394        let cli = parse(&[
1395            "ironflow-cli",
1396            "signal",
1397            "send",
1398            "ci.pipeline_finished",
1399            "--key",
1400            "4f2a9c1",
1401            "--payload",
1402            r#"{"status":"success"}"#,
1403            "--idempotency-id",
1404            "delivery-42",
1405        ]);
1406        let Commands::Signal(args) = &cli.command else {
1407            panic!("expected Signal command");
1408        };
1409        let SignalCommands::Send {
1410            name,
1411            key,
1412            payload,
1413            idempotency_id,
1414        } = &args.command
1415        else {
1416            panic!("expected Send subcommand");
1417        };
1418        assert_eq!(name, "ci.pipeline_finished");
1419        assert_eq!(key, "4f2a9c1");
1420        assert_eq!(payload.as_deref(), Some(r#"{"status":"success"}"#));
1421        assert_eq!(idempotency_id.as_deref(), Some("delivery-42"));
1422    }
1423
1424    #[test]
1425    fn parse_signal_send_requires_a_key() {
1426        let result = Cli::try_parse_from(["ironflow-cli", "signal", "send", "demo.done"]);
1427        assert!(result.is_err());
1428    }
1429
1430    #[test]
1431    fn parse_signal_list_with_filters() {
1432        let cli = parse(&[
1433            "ironflow-cli",
1434            "signal",
1435            "list",
1436            "--name",
1437            "demo.done",
1438            "--key",
1439            "k1",
1440            "--page",
1441            "2",
1442            "--per-page",
1443            "10",
1444        ]);
1445        let Commands::Signal(args) = &cli.command else {
1446            panic!("expected Signal command");
1447        };
1448        let SignalCommands::List {
1449            name,
1450            key,
1451            page,
1452            per_page,
1453        } = &args.command
1454        else {
1455            panic!("expected List subcommand");
1456        };
1457        assert_eq!(name.as_deref(), Some("demo.done"));
1458        assert_eq!(key.as_deref(), Some("k1"));
1459        assert_eq!(*page, Some(2));
1460        assert_eq!(*per_page, Some(10));
1461    }
1462
1463    // ── Agent roles ────────────────────────────────────────────────
1464
1465    fn agent_role_command(args: &[&str]) -> AgentRoleCommands {
1466        let mut full = vec!["ironflow-cli", "agent-role"];
1467        full.extend_from_slice(args);
1468        let cli = parse(&full);
1469        let Commands::AgentRole(args) = cli.command else {
1470            panic!("expected AgentRole command");
1471        };
1472        args.command
1473    }
1474
1475    #[test]
1476    fn parse_agent_role_list_with_pagination() {
1477        let AgentRoleCommands::List { page, per_page } =
1478            agent_role_command(&["list", "--page", "2", "--per-page", "10"])
1479        else {
1480            panic!("expected List subcommand");
1481        };
1482        assert_eq!(page, Some(2));
1483        assert_eq!(per_page, Some(10));
1484    }
1485
1486    #[test]
1487    fn parse_agent_role_show() {
1488        let AgentRoleCommands::Show { name } = agent_role_command(&["show", "reviewer"]) else {
1489            panic!("expected Show subcommand");
1490        };
1491        assert_eq!(name, "reviewer");
1492    }
1493
1494    #[test]
1495    fn parse_agent_role_set_cap() {
1496        let AgentRoleCommands::SetCap { name, usd } =
1497            agent_role_command(&["set-cap", "reviewer", "--usd", "12.5"])
1498        else {
1499            panic!("expected SetCap subcommand");
1500        };
1501        assert_eq!(name, "reviewer");
1502        assert!((usd - 12.5).abs() < f64::EPSILON);
1503    }
1504
1505    #[test]
1506    fn parse_agent_role_set_cap_requires_usd() {
1507        let result = Cli::try_parse_from(["ironflow-cli", "agent-role", "set-cap", "reviewer"]);
1508        assert!(result.is_err());
1509    }
1510
1511    #[test]
1512    fn parse_agent_role_clear_cap() {
1513        let AgentRoleCommands::ClearCap { name } = agent_role_command(&["clear-cap", "reviewer"])
1514        else {
1515            panic!("expected ClearCap subcommand");
1516        };
1517        assert_eq!(name, "reviewer");
1518    }
1519
1520    #[test]
1521    fn parse_run_steps_by_role() {
1522        let cli = parse(&["ironflow-cli", "run", "steps", "--role", "reviewer"]);
1523        let Commands::Run(args) = &cli.command else {
1524            panic!("expected Run command");
1525        };
1526        let RunCommands::Steps {
1527            role,
1528            page,
1529            per_page,
1530        } = &args.command
1531        else {
1532            panic!("expected Steps subcommand");
1533        };
1534        assert_eq!(role, "reviewer");
1535        assert_eq!(*page, 1);
1536        assert_eq!(*per_page, 20);
1537    }
1538
1539    #[test]
1540    fn parse_run_steps_requires_a_role() {
1541        let result = Cli::try_parse_from(["ironflow-cli", "run", "steps"]);
1542        assert!(result.is_err());
1543    }
1544
1545    #[test]
1546    fn parse_stats_roles_with_period() {
1547        let cli = parse(&["ironflow-cli", "stats", "roles", "--period", "30d"]);
1548        let Commands::Stats(args) = &cli.command else {
1549            panic!("expected Stats command");
1550        };
1551        let Some(StatsCommands::Roles { period }) = &args.command else {
1552            panic!("expected Roles subcommand");
1553        };
1554        assert_eq!(period, "30d");
1555    }
1556
1557    #[test]
1558    fn parse_stats_roles_defaults_to_7d() {
1559        let cli = parse(&["ironflow-cli", "stats", "roles"]);
1560        let Commands::Stats(args) = &cli.command else {
1561            panic!("expected Stats command");
1562        };
1563        let Some(StatsCommands::Roles { period }) = &args.command else {
1564            panic!("expected Roles subcommand");
1565        };
1566        assert_eq!(period, "7d");
1567    }
1568}