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::{CatchupArg, OverlapArg, 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_with_catchup_and_timezone() {
487        let cli = parse(&[
488            "ironflow-cli",
489            "schedule",
490            "create",
491            "deploy",
492            "0 9 * * *",
493            "--catchup",
494            "all",
495            "--catchup-max",
496            "24",
497            "--catchup-window",
498            "3600",
499            "--overlap",
500            "skip",
501            "--timezone",
502            "Europe/Paris",
503        ]);
504        let Commands::Schedule(args) = &cli.command else {
505            panic!("expected Schedule command");
506        };
507        let ScheduleCommands::Create {
508            catchup,
509            catchup_max,
510            catchup_window_secs,
511            overlap,
512            timezone,
513            ..
514        } = &args.command
515        else {
516            panic!("expected Create subcommand");
517        };
518        assert_eq!(*catchup, Some(CatchupArg::All));
519        assert_eq!(*catchup_max, Some(24));
520        assert_eq!(*catchup_window_secs, Some(3600));
521        assert_eq!(*overlap, Some(OverlapArg::Skip));
522        assert_eq!(timezone.as_deref(), Some("Europe/Paris"));
523    }
524
525    #[test]
526    fn parse_schedule_create_defaults_leave_the_policy_to_the_server() {
527        let cli = parse(&["ironflow-cli", "schedule", "create", "deploy", "0 9 * * *"]);
528        let Commands::Schedule(args) = &cli.command else {
529            panic!("expected Schedule command");
530        };
531        let ScheduleCommands::Create {
532            catchup,
533            catchup_max,
534            catchup_window_secs,
535            overlap,
536            timezone,
537            ..
538        } = &args.command
539        else {
540            panic!("expected Create subcommand");
541        };
542        assert_eq!(*catchup, None);
543        assert_eq!(*catchup_max, None);
544        assert_eq!(*catchup_window_secs, None);
545        assert_eq!(*overlap, None);
546        assert_eq!(*timezone, None);
547    }
548
549    #[test]
550    fn parse_schedule_create_rejects_catchup_max_zero() {
551        let result = Cli::try_parse_from([
552            "ironflow-cli",
553            "schedule",
554            "create",
555            "deploy",
556            "0 0 * * *",
557            "--catchup-max",
558            "0",
559        ]);
560        assert!(result.is_err());
561    }
562
563    #[test]
564    fn parse_schedule_create_rejects_a_catchup_window_below_a_minute() {
565        let result = Cli::try_parse_from([
566            "ironflow-cli",
567            "schedule",
568            "create",
569            "deploy",
570            "0 0 * * *",
571            "--catchup-window",
572            "59",
573        ]);
574        assert!(result.is_err());
575    }
576
577    #[test]
578    fn parse_schedule_create_rejects_an_unknown_catchup() {
579        let result = Cli::try_parse_from([
580            "ironflow-cli",
581            "schedule",
582            "create",
583            "deploy",
584            "0 0 * * *",
585            "--catchup",
586            "sometimes",
587        ]);
588        assert!(result.is_err());
589    }
590
591    #[test]
592    fn parse_schedule_create_rejects_an_out_of_range_priority() {
593        let result = Cli::try_parse_from([
594            "ironflow-cli",
595            "schedule",
596            "create",
597            "deploy",
598            "0 0 * * *",
599            "--priority",
600            "150",
601        ]);
602        assert!(result.is_err());
603    }
604
605    #[test]
606    fn parse_run_create_payload_and_file_conflict() {
607        let result = Cli::try_parse_from([
608            "ironflow-cli",
609            "run",
610            "create",
611            "deploy",
612            "--payload",
613            "{}",
614            "--payload-file",
615            "/tmp/p.json",
616        ]);
617        assert!(result.is_err());
618    }
619
620    #[test]
621    fn parse_run_get() {
622        let cli = parse(&["ironflow-cli", "run", "get", UUID]);
623        assert!(matches!(cli.command, Commands::Run(_)));
624    }
625
626    #[test]
627    fn parse_run_cancel() {
628        let cli = parse(&["ironflow-cli", "run", "cancel", UUID]);
629        assert!(matches!(cli.command, Commands::Run(_)));
630    }
631
632    #[test]
633    fn parse_run_approve() {
634        let cli = parse(&["ironflow-cli", "run", "approve", UUID]);
635        assert!(matches!(cli.command, Commands::Run(_)));
636    }
637
638    #[test]
639    fn parse_run_reject() {
640        let cli = parse(&["ironflow-cli", "run", "reject", UUID]);
641        assert!(matches!(cli.command, Commands::Run(_)));
642    }
643
644    #[test]
645    fn parse_run_reject_requires_an_id() {
646        assert!(Cli::try_parse_from(["ironflow-cli", "run", "reject"]).is_err());
647    }
648
649    #[test]
650    fn parse_run_retry() {
651        let cli = parse(&["ironflow-cli", "run", "retry", UUID]);
652        assert!(matches!(cli.command, Commands::Run(_)));
653    }
654
655    #[test]
656    fn parse_run_list_with_filters() {
657        let cli = parse(&[
658            "ironflow-cli",
659            "run",
660            "list",
661            "--status",
662            "completed",
663            "--workflow",
664            "deploy",
665            "--page",
666            "2",
667            "--per-page",
668            "50",
669        ]);
670        assert!(matches!(cli.command, Commands::Run(_)));
671    }
672
673    #[test]
674    fn parse_workflow_list() {
675        let cli = parse(&["ironflow-cli", "workflow", "list"]);
676        assert!(matches!(cli.command, Commands::Workflow(_)));
677    }
678
679    #[test]
680    fn parse_workflow_get() {
681        let cli = parse(&["ironflow-cli", "workflow", "get", "deploy"]);
682        assert!(matches!(cli.command, Commands::Workflow(_)));
683    }
684
685    #[test]
686    fn parse_logs() {
687        let cli = parse(&["ironflow-cli", "logs", UUID]);
688        assert!(matches!(cli.command, Commands::Logs(_)));
689    }
690
691    #[test]
692    fn parse_logs_follow() {
693        let cli = parse(&["ironflow-cli", "logs", UUID, "--follow"]);
694        let Commands::Logs(args) = &cli.command else {
695            panic!("expected Logs command");
696        };
697        assert!(args.follow);
698    }
699
700    #[test]
701    fn parse_stats() {
702        let cli = parse(&["ironflow-cli", "stats"]);
703        assert!(matches!(cli.command, Commands::Stats(_)));
704    }
705
706    #[test]
707    fn parse_verbose_flag() {
708        let cli = parse(&["ironflow-cli", "--verbose", "stats"]);
709        assert!(cli.verbose);
710    }
711
712    #[test]
713    fn parse_url_override() {
714        let cli = parse(&[
715            "ironflow-cli",
716            "--url",
717            "https://custom.example.com",
718            "stats",
719        ]);
720        assert_eq!(cli.url.as_deref(), Some("https://custom.example.com"));
721    }
722
723    #[test]
724    fn parse_invalid_uuid_rejected() {
725        assert!(Cli::try_parse_from(["ironflow-cli", "run", "get", "not-a-uuid"]).is_err());
726    }
727
728    #[test]
729    fn parse_no_command_fails() {
730        assert!(Cli::try_parse_from(["ironflow-cli"]).is_err());
731    }
732
733    // -- Provider Accounts --
734
735    #[test]
736    fn parse_accounts_add_with_token_stdin() {
737        let cli = parse(&["ironflow-cli", "accounts", "add", "perso", "--token-stdin"]);
738        let Commands::Accounts(args) = &cli.command else {
739            panic!("expected Accounts command");
740        };
741        let AccountCommands::Add {
742            name,
743            kind,
744            token_stdin,
745            ..
746        } = &args.command
747        else {
748            panic!("expected Add subcommand");
749        };
750        assert_eq!(name, "perso");
751        assert_eq!(kind, "claude_subscription");
752        assert!(*token_stdin);
753    }
754
755    #[test]
756    fn parse_accounts_add_requires_token_stdin() {
757        assert!(Cli::try_parse_from(["ironflow-cli", "accounts", "add", "perso"]).is_err());
758    }
759
760    #[test]
761    fn parse_accounts_remove_with_yes() {
762        let cli = parse(&["ironflow-cli", "accounts", "remove", "perso", "--yes"]);
763        let Commands::Accounts(args) = &cli.command else {
764            panic!("expected Accounts command");
765        };
766        let AccountCommands::Remove { account, yes } = &args.command else {
767            panic!("expected Remove subcommand");
768        };
769        assert_eq!(account, "perso");
770        assert!(*yes);
771    }
772
773    #[test]
774    fn parse_accounts_list() {
775        let cli = parse(&["ironflow-cli", "accounts", "list"]);
776        assert!(matches!(cli.command, Commands::Accounts(_)));
777    }
778
779    #[test]
780    fn parse_accounts_update_rejects_enable_and_disable() {
781        let args = [
782            "ironflow-cli",
783            "accounts",
784            "update",
785            "perso",
786            "--enable",
787            "--disable",
788        ];
789        assert!(Cli::try_parse_from(args).is_err());
790    }
791
792    // ── Secrets ────────────────────────────────────────────────────
793
794    #[test]
795    fn parse_secret_list() {
796        let cli = parse(&["ironflow-cli", "secret", "list"]);
797        assert!(matches!(cli.command, Commands::Secret(_)));
798    }
799
800    #[test]
801    fn parse_secret_set_with_inline_value() {
802        let cli = parse(&["ironflow-cli", "secret", "set", "db/password", "hunter2"]);
803        let Commands::Secret(args) = &cli.command else {
804            panic!("expected Secret command");
805        };
806        let SecretCommands::Set { key, value } = &args.command else {
807            panic!("expected Set subcommand");
808        };
809        assert_eq!(key, "db/password");
810        assert_eq!(value.as_deref(), Some("hunter2"));
811    }
812
813    #[test]
814    fn parse_secret_set_without_value_defers_to_stdin() {
815        let cli = parse(&["ironflow-cli", "secret", "set", "db/password"]);
816        let Commands::Secret(args) = &cli.command else {
817            panic!("expected Secret command");
818        };
819        let SecretCommands::Set { value, .. } = &args.command else {
820            panic!("expected Set subcommand");
821        };
822        assert!(value.is_none());
823    }
824
825    #[test]
826    fn parse_secret_set_requires_a_key() {
827        assert!(Cli::try_parse_from(["ironflow-cli", "secret", "set"]).is_err());
828    }
829
830    #[test]
831    fn parse_secret_update() {
832        let cli = parse(&["ironflow-cli", "secret", "update", "db/password", "new"]);
833        assert!(matches!(cli.command, Commands::Secret(_)));
834    }
835
836    #[test]
837    fn parse_secret_delete_with_yes() {
838        let cli = parse(&["ironflow-cli", "secret", "delete", "db/password", "--yes"]);
839        let Commands::Secret(args) = &cli.command else {
840            panic!("expected Secret command");
841        };
842        let SecretCommands::Delete { yes, .. } = &args.command else {
843            panic!("expected Delete subcommand");
844        };
845        assert!(yes);
846    }
847
848    #[test]
849    fn parse_secret_delete_defaults_to_confirming() {
850        let cli = parse(&["ironflow-cli", "secret", "delete", "db/password"]);
851        let Commands::Secret(args) = &cli.command else {
852            panic!("expected Secret command");
853        };
854        let SecretCommands::Delete { yes, .. } = &args.command else {
855            panic!("expected Delete subcommand");
856        };
857        assert!(!yes);
858    }
859
860    #[test]
861    fn parse_secret_rotate_defaults_to_the_active_version() {
862        let cli = parse(&["ironflow-cli", "secret", "rotate"]);
863        let Commands::Secret(args) = &cli.command else {
864            panic!("expected Secret command");
865        };
866        let SecretCommands::Rotate(rotate) = &args.command else {
867            panic!("expected Rotate subcommand");
868        };
869        assert!(rotate.to_version.is_none());
870        assert_eq!(rotate.batch_size, 100);
871    }
872
873    #[test]
874    fn parse_secret_rotate_with_version_and_batch_size() {
875        let cli = parse(&[
876            "ironflow-cli",
877            "secret",
878            "rotate",
879            "--to-version",
880            "2",
881            "--batch-size",
882            "50",
883        ]);
884        let Commands::Secret(args) = &cli.command else {
885            panic!("expected Secret command");
886        };
887        let SecretCommands::Rotate(rotate) = &args.command else {
888            panic!("expected Rotate subcommand");
889        };
890        assert_eq!(rotate.to_version, Some(2));
891        assert_eq!(rotate.batch_size, 50);
892    }
893
894    #[test]
895    fn parse_secret_rotate_rejects_a_non_positive_version() {
896        let zero = ["ironflow-cli", "secret", "rotate", "--to-version", "0"];
897        let negative = ["ironflow-cli", "secret", "rotate", "--to-version", "-1"];
898        assert!(Cli::try_parse_from(zero).is_err());
899        assert!(Cli::try_parse_from(negative).is_err());
900    }
901
902    #[test]
903    fn parse_secret_rotate_rejects_an_out_of_range_batch_size() {
904        let zero = ["ironflow-cli", "secret", "rotate", "--batch-size", "0"];
905        let too_large = ["ironflow-cli", "secret", "rotate", "--batch-size", "1001"];
906        assert!(Cli::try_parse_from(zero).is_err());
907        assert!(Cli::try_parse_from(too_large).is_err());
908    }
909
910    #[test]
911    fn parse_secret_key_status_takes_no_arguments() {
912        let cli = parse(&["ironflow-cli", "secret", "key-status"]);
913        let Commands::Secret(args) = &cli.command else {
914            panic!("expected Secret command");
915        };
916        assert!(matches!(args.command, SecretCommands::KeyStatus));
917        assert!(Cli::try_parse_from(["ironflow-cli", "secret", "key-status", "extra"]).is_err());
918    }
919
920    // ── API keys ───────────────────────────────────────────────────
921
922    #[test]
923    fn parse_api_key_list() {
924        let cli = parse(&["ironflow-cli", "api-key", "list"]);
925        assert!(matches!(cli.command, Commands::ApiKey(_)));
926    }
927
928    #[test]
929    fn parse_api_key_scopes() {
930        let cli = parse(&["ironflow-cli", "api-key", "scopes"]);
931        assert!(matches!(cli.command, Commands::ApiKey(_)));
932    }
933
934    #[test]
935    fn parse_api_key_create_with_several_scopes() {
936        let cli = parse(&[
937            "ironflow-cli",
938            "api-key",
939            "create",
940            "ci",
941            "--scope",
942            "runs_read",
943            "--scope",
944            "runs_write",
945        ]);
946        let Commands::ApiKey(args) = &cli.command else {
947            panic!("expected ApiKey command");
948        };
949        let ApiKeyCommands::Create { scopes, .. } = &args.command else {
950            panic!("expected Create subcommand");
951        };
952        assert_eq!(scopes.len(), 2);
953    }
954
955    #[test]
956    fn parse_api_key_create_requires_a_scope() {
957        assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci"]).is_err());
958    }
959
960    #[test]
961    fn parse_api_key_create_rejects_an_unknown_scope() {
962        let result =
963            Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci", "--scope", "root"]);
964        assert!(result.is_err());
965    }
966
967    #[test]
968    fn parse_api_key_create_with_expiry() {
969        let cli = parse(&[
970            "ironflow-cli",
971            "api-key",
972            "create",
973            "ci",
974            "--scope",
975            "admin",
976            "--expires-at",
977            "2026-12-31T23:59:59Z",
978        ]);
979        assert!(matches!(cli.command, Commands::ApiKey(_)));
980    }
981
982    #[test]
983    fn parse_api_key_create_rejects_a_malformed_expiry() {
984        let result = Cli::try_parse_from([
985            "ironflow-cli",
986            "api-key",
987            "create",
988            "ci",
989            "--scope",
990            "admin",
991            "--expires-at",
992            "tomorrow",
993        ]);
994        assert!(result.is_err());
995    }
996
997    #[test]
998    fn parse_api_key_delete_rejects_a_non_uuid() {
999        assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "delete", "abc"]).is_err());
1000    }
1001
1002    // ── Users ──────────────────────────────────────────────────────
1003
1004    #[test]
1005    fn parse_user_list() {
1006        let cli = parse(&["ironflow-cli", "user", "list"]);
1007        assert!(matches!(cli.command, Commands::User(_)));
1008    }
1009
1010    #[test]
1011    fn parse_user_create() {
1012        let cli = parse(&[
1013            "ironflow-cli",
1014            "user",
1015            "create",
1016            "alice",
1017            "--email",
1018            "alice@example.com",
1019            "--password",
1020            "hunter2hunter2",
1021            "--admin",
1022        ]);
1023        let Commands::User(args) = &cli.command else {
1024            panic!("expected User command");
1025        };
1026        let UserCommands::Create { admin, .. } = &args.command else {
1027            panic!("expected Create subcommand");
1028        };
1029        assert!(admin);
1030    }
1031
1032    #[test]
1033    fn parse_user_create_requires_an_email() {
1034        assert!(Cli::try_parse_from(["ironflow-cli", "user", "create", "alice"]).is_err());
1035    }
1036
1037    #[test]
1038    fn parse_user_set_role_admin() {
1039        let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--admin"]);
1040        let Commands::User(args) = &cli.command else {
1041            panic!("expected User command");
1042        };
1043        let UserCommands::SetRole { admin, member, .. } = &args.command else {
1044            panic!("expected SetRole subcommand");
1045        };
1046        assert!(admin);
1047        assert!(!member);
1048    }
1049
1050    #[test]
1051    fn parse_user_set_role_member() {
1052        let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--member"]);
1053        let Commands::User(args) = &cli.command else {
1054            panic!("expected User command");
1055        };
1056        let UserCommands::SetRole { admin, .. } = &args.command else {
1057            panic!("expected SetRole subcommand");
1058        };
1059        assert!(!admin);
1060    }
1061
1062    #[test]
1063    fn parse_user_set_role_requires_a_role() {
1064        assert!(Cli::try_parse_from(["ironflow-cli", "user", "set-role", UUID]).is_err());
1065    }
1066
1067    #[test]
1068    fn parse_user_set_role_rejects_both_roles() {
1069        let result = Cli::try_parse_from([
1070            "ironflow-cli",
1071            "user",
1072            "set-role",
1073            UUID,
1074            "--admin",
1075            "--member",
1076        ]);
1077        assert!(result.is_err());
1078    }
1079
1080    // ── Audit logs ─────────────────────────────────────────────────
1081
1082    #[test]
1083    fn parse_audit_log_list_without_filters() {
1084        let cli = parse(&["ironflow-cli", "audit-log", "list"]);
1085        assert!(matches!(cli.command, Commands::AuditLog(_)));
1086    }
1087
1088    #[test]
1089    fn parse_audit_log_list_with_every_filter() {
1090        let cli = parse(&[
1091            "ironflow-cli",
1092            "audit-log",
1093            "list",
1094            "--run",
1095            UUID,
1096            "--type",
1097            "run_created",
1098            "--from",
1099            "2026-01-01T00:00:00Z",
1100            "--to",
1101            "2026-12-31T23:59:59Z",
1102            "--page",
1103            "2",
1104            "--per-page",
1105            "10",
1106        ]);
1107        let Commands::AuditLog(args) = &cli.command else {
1108            panic!("expected AuditLog command");
1109        };
1110        let AuditLogCommands::List {
1111            run,
1112            event_type,
1113            from,
1114            to,
1115            page,
1116            per_page,
1117        } = &args.command;
1118        assert!(run.is_some());
1119        assert!(event_type.is_some());
1120        assert!(from.is_some());
1121        assert!(to.is_some());
1122        assert_eq!(*page, Some(2));
1123        assert_eq!(*per_page, Some(10));
1124    }
1125
1126    #[test]
1127    fn parse_audit_log_list_rejects_an_unknown_type() {
1128        let result =
1129            Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--type", "exploded"]);
1130        assert!(result.is_err());
1131    }
1132
1133    #[test]
1134    fn parse_audit_log_list_rejects_a_malformed_date() {
1135        let result = Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--from", "hier"]);
1136        assert!(result.is_err());
1137    }
1138
1139    // ── Approval delegations ───────────────────────────────────────
1140
1141    #[test]
1142    fn parse_delegation_list() {
1143        let cli = parse(&["ironflow-cli", "delegation", "list"]);
1144        assert!(matches!(cli.command, Commands::Delegation(_)));
1145    }
1146
1147    #[test]
1148    fn parse_delegation_list_with_every_flag() {
1149        let cli = parse(&[
1150            "ironflow-cli",
1151            "delegation",
1152            "list",
1153            "--from-user",
1154            UUID,
1155            "--to-user",
1156            UUID,
1157            "--page",
1158            "2",
1159            "--per-page",
1160            "10",
1161        ]);
1162        let Commands::Delegation(args) = &cli.command else {
1163            panic!("expected Delegation command");
1164        };
1165        let DelegationCommands::List {
1166            from_user,
1167            to_user,
1168            page,
1169            per_page,
1170        } = &args.command
1171        else {
1172            panic!("expected List subcommand");
1173        };
1174        assert!(from_user.is_some());
1175        assert!(to_user.is_some());
1176        assert_eq!(*page, Some(2));
1177        assert_eq!(*per_page, Some(10));
1178    }
1179
1180    #[test]
1181    fn parse_delegation_create_with_every_flag() {
1182        let cli = parse(&[
1183            "ironflow-cli",
1184            "delegation",
1185            "create",
1186            UUID,
1187            "--until",
1188            "2026-12-31T23:59:59Z",
1189            "--from",
1190            "2026-12-01T00:00:00Z",
1191            "--workflow",
1192            "deploy-*",
1193        ]);
1194        let Commands::Delegation(args) = &cli.command else {
1195            panic!("expected Delegation command");
1196        };
1197        let DelegationCommands::Create {
1198            until,
1199            from,
1200            workflow,
1201            ..
1202        } = &args.command
1203        else {
1204            panic!("expected Create subcommand");
1205        };
1206        assert_eq!(until, "2026-12-31T23:59:59Z");
1207        assert_eq!(from.as_deref(), Some("2026-12-01T00:00:00Z"));
1208        assert_eq!(workflow.as_deref(), Some("deploy-*"));
1209    }
1210
1211    #[test]
1212    fn parse_delegation_create_requires_an_until() {
1213        assert!(Cli::try_parse_from(["ironflow-cli", "delegation", "create", UUID]).is_err());
1214    }
1215
1216    #[test]
1217    fn parse_delegation_create_rejects_a_non_uuid_target() {
1218        let result = Cli::try_parse_from([
1219            "ironflow-cli",
1220            "delegation",
1221            "create",
1222            "alice",
1223            "--until",
1224            "2026-12-31T23:59:59Z",
1225        ]);
1226        assert!(result.is_err());
1227    }
1228
1229    #[test]
1230    fn parse_delegation_delete_defaults_to_confirming() {
1231        let cli = parse(&["ironflow-cli", "delegation", "delete", UUID]);
1232        let Commands::Delegation(args) = &cli.command else {
1233            panic!("expected Delegation command");
1234        };
1235        let DelegationCommands::Delete { yes, .. } = &args.command else {
1236            panic!("expected Delete subcommand");
1237        };
1238        assert!(!yes);
1239    }
1240
1241    // ── Completions & man ───────────────────────────────────────
1242
1243    #[test]
1244    fn parse_completions_bash() {
1245        let cli = parse(&["ironflow-cli", "completions", "bash"]);
1246        let Commands::Completions { shell } = &cli.command else {
1247            panic!("expected Completions command");
1248        };
1249        assert_eq!(*shell, Shell::Bash);
1250    }
1251
1252    #[test]
1253    fn parse_completions_zsh() {
1254        let cli = parse(&["ironflow-cli", "completions", "zsh"]);
1255        let Commands::Completions { shell } = &cli.command else {
1256            panic!("expected Completions command");
1257        };
1258        assert_eq!(*shell, Shell::Zsh);
1259    }
1260
1261    #[test]
1262    fn parse_completions_fish() {
1263        let cli = parse(&["ironflow-cli", "completions", "fish"]);
1264        let Commands::Completions { shell } = &cli.command else {
1265            panic!("expected Completions command");
1266        };
1267        assert_eq!(*shell, Shell::Fish);
1268    }
1269
1270    #[test]
1271    fn parse_completions_powershell() {
1272        let cli = parse(&["ironflow-cli", "completions", "powershell"]);
1273        let Commands::Completions { shell } = &cli.command else {
1274            panic!("expected Completions command");
1275        };
1276        assert_eq!(*shell, Shell::PowerShell);
1277    }
1278
1279    #[test]
1280    fn parse_completions_requires_shell() {
1281        assert!(Cli::try_parse_from(["ironflow-cli", "completions"]).is_err());
1282    }
1283
1284    #[test]
1285    fn parse_completions_rejects_unknown_shell() {
1286        assert!(Cli::try_parse_from(["ironflow-cli", "completions", "nushell"]).is_err());
1287    }
1288
1289    #[test]
1290    fn parse_man() {
1291        let cli = parse(&["ironflow-cli", "man"]);
1292        assert!(matches!(cli.command, Commands::Man));
1293    }
1294
1295    #[test]
1296    fn completions_bash_output_is_valid() {
1297        let mut buf = Vec::new();
1298        super::generate_completions(Shell::Bash, &mut buf).unwrap();
1299        let output = String::from_utf8(buf).unwrap();
1300        assert!(output.contains("ironflow-cli"));
1301    }
1302
1303    #[test]
1304    fn man_page_output_is_valid() {
1305        let mut buf = Vec::new();
1306        super::generate_man_page(&mut buf).unwrap();
1307        let output = String::from_utf8(buf).unwrap();
1308        assert!(output.contains(".TH"));
1309        assert!(output.contains("ironflow-cli"));
1310    }
1311
1312    // ---- template ----
1313
1314    #[test]
1315    fn parse_template_list() {
1316        let cli = parse(&[
1317            "ironflow-cli",
1318            "template",
1319            "list",
1320            "https://github.com/user/templates",
1321        ]);
1322        assert!(matches!(cli.command, Commands::Template(_)));
1323    }
1324
1325    #[test]
1326    fn parse_template_add_with_from() {
1327        let cli = parse(&[
1328            "ironflow-cli",
1329            "template",
1330            "add",
1331            "ci-pipeline",
1332            "--from",
1333            "https://github.com/user/templates",
1334        ]);
1335        assert!(matches!(cli.command, Commands::Template(_)));
1336    }
1337
1338    #[test]
1339    fn parse_template_add_with_output() {
1340        let cli = parse(&[
1341            "ironflow-cli",
1342            "template",
1343            "add",
1344            "ci-pipeline",
1345            "--from",
1346            "https://github.com/user/templates",
1347            "--output",
1348            "my/custom/path",
1349        ]);
1350        assert!(matches!(cli.command, Commands::Template(_)));
1351    }
1352
1353    #[test]
1354    fn parse_template_list_registry() {
1355        let cli = parse(&["ironflow-cli", "template", "list", "--registry"]);
1356        assert!(matches!(cli.command, Commands::Template(_)));
1357    }
1358
1359    #[test]
1360    fn parse_template_update() {
1361        let cli = parse(&["ironflow-cli", "template", "update"]);
1362        assert!(matches!(cli.command, Commands::Template(_)));
1363    }
1364
1365    #[test]
1366    fn parse_template_info() {
1367        let cli = parse(&[
1368            "ironflow-cli",
1369            "template",
1370            "info",
1371            "https://github.com/user/templates",
1372            "ci-pipeline",
1373        ]);
1374        assert!(matches!(cli.command, Commands::Template(_)));
1375    }
1376
1377    #[test]
1378    fn parse_template_requires_subcommand() {
1379        let result = Cli::try_parse_from(["ironflow-cli", "template"]);
1380        assert!(result.is_err());
1381    }
1382
1383    // ── Signals ────────────────────────────────────────────────────
1384
1385    #[test]
1386    fn parse_signal_send_with_every_flag() {
1387        let cli = parse(&[
1388            "ironflow-cli",
1389            "signal",
1390            "send",
1391            "ci.pipeline_finished",
1392            "--key",
1393            "4f2a9c1",
1394            "--payload",
1395            r#"{"status":"success"}"#,
1396            "--idempotency-id",
1397            "delivery-42",
1398        ]);
1399        let Commands::Signal(args) = &cli.command else {
1400            panic!("expected Signal command");
1401        };
1402        let SignalCommands::Send {
1403            name,
1404            key,
1405            payload,
1406            idempotency_id,
1407        } = &args.command
1408        else {
1409            panic!("expected Send subcommand");
1410        };
1411        assert_eq!(name, "ci.pipeline_finished");
1412        assert_eq!(key, "4f2a9c1");
1413        assert_eq!(payload.as_deref(), Some(r#"{"status":"success"}"#));
1414        assert_eq!(idempotency_id.as_deref(), Some("delivery-42"));
1415    }
1416
1417    #[test]
1418    fn parse_signal_send_requires_a_key() {
1419        let result = Cli::try_parse_from(["ironflow-cli", "signal", "send", "demo.done"]);
1420        assert!(result.is_err());
1421    }
1422
1423    #[test]
1424    fn parse_signal_list_with_filters() {
1425        let cli = parse(&[
1426            "ironflow-cli",
1427            "signal",
1428            "list",
1429            "--name",
1430            "demo.done",
1431            "--key",
1432            "k1",
1433            "--page",
1434            "2",
1435            "--per-page",
1436            "10",
1437        ]);
1438        let Commands::Signal(args) = &cli.command else {
1439            panic!("expected Signal command");
1440        };
1441        let SignalCommands::List {
1442            name,
1443            key,
1444            page,
1445            per_page,
1446        } = &args.command
1447        else {
1448            panic!("expected List subcommand");
1449        };
1450        assert_eq!(name.as_deref(), Some("demo.done"));
1451        assert_eq!(key.as_deref(), Some("k1"));
1452        assert_eq!(*page, Some(2));
1453        assert_eq!(*per_page, Some(10));
1454    }
1455}