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