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::stats::StatsCommands;
203    use crate::commands::user::UserCommands;
204
205    use super::*;
206
207    const UUID: &str = "01234567-89ab-cdef-0123-456789abcdef";
208
209    fn parse(args: &[&str]) -> Cli {
210        Cli::try_parse_from(args).unwrap()
211    }
212
213    #[test]
214    fn parse_run_list() {
215        let cli = parse(&["ironflow-cli", "run", "list"]);
216        assert!(!cli.json);
217        assert!(matches!(cli.command, Commands::Run(_)));
218    }
219
220    #[test]
221    fn parse_run_list_with_json() {
222        let cli = parse(&["ironflow-cli", "--json", "run", "list"]);
223        assert!(cli.json);
224    }
225
226    #[test]
227    fn parse_run_create_with_payload() {
228        let cli = parse(&[
229            "ironflow-cli",
230            "run",
231            "create",
232            "deploy",
233            "--payload",
234            r#"{"env": "prod"}"#,
235        ]);
236        assert!(matches!(cli.command, Commands::Run(_)));
237    }
238
239    #[test]
240    fn parse_run_create_with_payload_file() {
241        let cli = parse(&[
242            "ironflow-cli",
243            "run",
244            "create",
245            "deploy",
246            "--payload-file",
247            "/tmp/payload.json",
248        ]);
249        assert!(matches!(cli.command, Commands::Run(_)));
250    }
251
252    #[test]
253    fn parse_run_create_with_concurrency_key() {
254        let cli = parse(&[
255            "ironflow-cli",
256            "run",
257            "create",
258            "deploy",
259            "--concurrency-key",
260            "issue:12",
261        ]);
262        let Commands::Run(args) = &cli.command else {
263            panic!("expected Run command");
264        };
265        let RunCommands::Create {
266            concurrency_key, ..
267        } = &args.command
268        else {
269            panic!("expected Create subcommand");
270        };
271        assert_eq!(concurrency_key.as_deref(), Some("issue:12"));
272    }
273
274    #[test]
275    fn parse_run_create_without_concurrency_key() {
276        let cli = parse(&["ironflow-cli", "run", "create", "deploy"]);
277        let Commands::Run(args) = &cli.command else {
278            panic!("expected Run command");
279        };
280        let RunCommands::Create {
281            concurrency_key, ..
282        } = &args.command
283        else {
284            panic!("expected Create subcommand");
285        };
286        assert!(concurrency_key.is_none());
287    }
288
289    #[test]
290    fn parse_run_create_with_repeated_concurrency_limits() {
291        let cli = parse(&[
292            "ironflow-cli",
293            "run",
294            "create",
295            "deploy",
296            "--concurrency-limit",
297            "repo:acme=2",
298            "--concurrency-limit",
299            "tenant:42=1",
300        ]);
301        let Commands::Run(args) = &cli.command else {
302            panic!("expected Run command");
303        };
304        let RunCommands::Create {
305            concurrency_limits, ..
306        } = &args.command
307        else {
308            panic!("expected Create subcommand");
309        };
310        let parsed: Vec<(&str, i32)> = concurrency_limits
311            .iter()
312            .map(|l| (l.group.as_str(), l.limit))
313            .collect();
314        assert_eq!(parsed, vec![("repo:acme", 2), ("tenant:42", 1)]);
315    }
316
317    #[test]
318    fn parse_run_create_without_concurrency_limits() {
319        let cli = parse(&["ironflow-cli", "run", "create", "deploy"]);
320        let Commands::Run(args) = &cli.command else {
321            panic!("expected Run command");
322        };
323        let RunCommands::Create {
324            concurrency_limits, ..
325        } = &args.command
326        else {
327            panic!("expected Create subcommand");
328        };
329        assert!(concurrency_limits.is_empty());
330    }
331
332    #[test]
333    fn parse_run_create_with_repeated_worker_tags() {
334        let cli = parse(&[
335            "ironflow-cli",
336            "run",
337            "create",
338            "deploy",
339            "--worker-tag",
340            "gpu",
341            "--worker-tag",
342            "region:eu",
343        ]);
344        let Commands::Run(args) = &cli.command else {
345            panic!("expected Run command");
346        };
347        let RunCommands::Create { worker_tags, .. } = &args.command else {
348            panic!("expected Create subcommand");
349        };
350        assert_eq!(
351            worker_tags,
352            &vec!["gpu".to_string(), "region:eu".to_string()]
353        );
354    }
355
356    #[test]
357    fn parse_run_create_without_worker_tags() {
358        let cli = parse(&["ironflow-cli", "run", "create", "deploy"]);
359        let Commands::Run(args) = &cli.command else {
360            panic!("expected Run command");
361        };
362        let RunCommands::Create { worker_tags, .. } = &args.command else {
363            panic!("expected Create subcommand");
364        };
365        assert!(worker_tags.is_empty());
366    }
367
368    #[test]
369    fn parse_run_create_rejects_a_malformed_concurrency_limit() {
370        let result = Cli::try_parse_from([
371            "ironflow-cli",
372            "run",
373            "create",
374            "deploy",
375            "--concurrency-limit",
376            "repo:acme",
377        ]);
378        assert!(result.is_err());
379    }
380
381    #[test]
382    fn parse_run_list_with_concurrency_group() {
383        let cli = parse(&[
384            "ironflow-cli",
385            "run",
386            "list",
387            "--concurrency-group",
388            "repo:acme",
389        ]);
390        let Commands::Run(args) = &cli.command else {
391            panic!("expected Run command");
392        };
393        let RunCommands::List {
394            concurrency_group, ..
395        } = &args.command
396        else {
397            panic!("expected List subcommand");
398        };
399        assert_eq!(concurrency_group.as_deref(), Some("repo:acme"));
400    }
401
402    #[test]
403    fn parse_run_create_with_a_negative_priority() {
404        let cli = parse(&[
405            "ironflow-cli",
406            "run",
407            "create",
408            "deploy",
409            "--priority",
410            "-40",
411        ]);
412        let Commands::Run(args) = &cli.command else {
413            panic!("expected Run command");
414        };
415        let RunCommands::Create { priority, .. } = &args.command else {
416            panic!("expected Create subcommand");
417        };
418        assert_eq!(*priority, Some(-40));
419    }
420
421    #[test]
422    fn parse_run_create_without_priority() {
423        let cli = parse(&["ironflow-cli", "run", "create", "deploy"]);
424        let Commands::Run(args) = &cli.command else {
425            panic!("expected Run command");
426        };
427        let RunCommands::Create { priority, .. } = &args.command else {
428            panic!("expected Create subcommand");
429        };
430        assert!(priority.is_none());
431    }
432
433    #[test]
434    fn parse_run_create_rejects_an_out_of_range_priority() {
435        for value in ["101", "-101", "high"] {
436            let result = Cli::try_parse_from([
437                "ironflow-cli",
438                "run",
439                "create",
440                "deploy",
441                "--priority",
442                value,
443            ]);
444            assert!(result.is_err(), "--priority {value} must be rejected");
445        }
446    }
447
448    #[test]
449    fn parse_run_list_with_priority() {
450        let cli = parse(&["ironflow-cli", "run", "list", "--priority", "100"]);
451        let Commands::Run(args) = &cli.command else {
452            panic!("expected Run command");
453        };
454        let RunCommands::List { priority, .. } = &args.command else {
455            panic!("expected List subcommand");
456        };
457        assert_eq!(*priority, Some(100));
458    }
459
460    #[test]
461    fn parse_run_list_rejects_an_out_of_range_priority() {
462        let result = Cli::try_parse_from(["ironflow-cli", "run", "list", "--priority", "-500"]);
463        assert!(result.is_err());
464    }
465
466    #[test]
467    fn parse_schedule_create_with_priority() {
468        let cli = parse(&[
469            "ironflow-cli",
470            "schedule",
471            "create",
472            "deploy",
473            "0 0 * * *",
474            "--priority",
475            "-5",
476        ]);
477        let Commands::Schedule(args) = &cli.command else {
478            panic!("expected Schedule command");
479        };
480        let ScheduleCommands::Create { priority, .. } = &args.command else {
481            panic!("expected Create subcommand");
482        };
483        assert_eq!(*priority, Some(-5));
484    }
485
486    #[test]
487    fn parse_schedule_create_with_catchup_and_timezone() {
488        let cli = parse(&[
489            "ironflow-cli",
490            "schedule",
491            "create",
492            "deploy",
493            "0 9 * * *",
494            "--catchup",
495            "all",
496            "--catchup-max",
497            "24",
498            "--catchup-window",
499            "3600",
500            "--overlap",
501            "skip",
502            "--timezone",
503            "Europe/Paris",
504        ]);
505        let Commands::Schedule(args) = &cli.command else {
506            panic!("expected Schedule command");
507        };
508        let ScheduleCommands::Create {
509            catchup,
510            catchup_max,
511            catchup_window_secs,
512            overlap,
513            timezone,
514            ..
515        } = &args.command
516        else {
517            panic!("expected Create subcommand");
518        };
519        assert_eq!(*catchup, Some(CatchupArg::All));
520        assert_eq!(*catchup_max, Some(24));
521        assert_eq!(*catchup_window_secs, Some(3600));
522        assert_eq!(*overlap, Some(OverlapArg::Skip));
523        assert_eq!(timezone.as_deref(), Some("Europe/Paris"));
524    }
525
526    #[test]
527    fn parse_schedule_create_defaults_leave_the_policy_to_the_server() {
528        let cli = parse(&["ironflow-cli", "schedule", "create", "deploy", "0 9 * * *"]);
529        let Commands::Schedule(args) = &cli.command else {
530            panic!("expected Schedule command");
531        };
532        let ScheduleCommands::Create {
533            catchup,
534            catchup_max,
535            catchup_window_secs,
536            overlap,
537            timezone,
538            ..
539        } = &args.command
540        else {
541            panic!("expected Create subcommand");
542        };
543        assert_eq!(*catchup, None);
544        assert_eq!(*catchup_max, None);
545        assert_eq!(*catchup_window_secs, None);
546        assert_eq!(*overlap, None);
547        assert_eq!(*timezone, None);
548    }
549
550    #[test]
551    fn parse_schedule_create_rejects_catchup_max_zero() {
552        let result = Cli::try_parse_from([
553            "ironflow-cli",
554            "schedule",
555            "create",
556            "deploy",
557            "0 0 * * *",
558            "--catchup-max",
559            "0",
560        ]);
561        assert!(result.is_err());
562    }
563
564    #[test]
565    fn parse_schedule_create_rejects_a_catchup_window_below_a_minute() {
566        let result = Cli::try_parse_from([
567            "ironflow-cli",
568            "schedule",
569            "create",
570            "deploy",
571            "0 0 * * *",
572            "--catchup-window",
573            "59",
574        ]);
575        assert!(result.is_err());
576    }
577
578    #[test]
579    fn parse_schedule_create_rejects_an_unknown_catchup() {
580        let result = Cli::try_parse_from([
581            "ironflow-cli",
582            "schedule",
583            "create",
584            "deploy",
585            "0 0 * * *",
586            "--catchup",
587            "sometimes",
588        ]);
589        assert!(result.is_err());
590    }
591
592    #[test]
593    fn parse_schedule_create_rejects_an_out_of_range_priority() {
594        let result = Cli::try_parse_from([
595            "ironflow-cli",
596            "schedule",
597            "create",
598            "deploy",
599            "0 0 * * *",
600            "--priority",
601            "150",
602        ]);
603        assert!(result.is_err());
604    }
605
606    #[test]
607    fn parse_run_create_payload_and_file_conflict() {
608        let result = Cli::try_parse_from([
609            "ironflow-cli",
610            "run",
611            "create",
612            "deploy",
613            "--payload",
614            "{}",
615            "--payload-file",
616            "/tmp/p.json",
617        ]);
618        assert!(result.is_err());
619    }
620
621    #[test]
622    fn parse_run_get() {
623        let cli = parse(&["ironflow-cli", "run", "get", UUID]);
624        assert!(matches!(cli.command, Commands::Run(_)));
625    }
626
627    #[test]
628    fn parse_run_cancel() {
629        let cli = parse(&["ironflow-cli", "run", "cancel", UUID]);
630        assert!(matches!(cli.command, Commands::Run(_)));
631    }
632
633    #[test]
634    fn parse_run_approve() {
635        let cli = parse(&["ironflow-cli", "run", "approve", UUID]);
636        assert!(matches!(cli.command, Commands::Run(_)));
637    }
638
639    #[test]
640    fn parse_run_reject() {
641        let cli = parse(&["ironflow-cli", "run", "reject", UUID]);
642        assert!(matches!(cli.command, Commands::Run(_)));
643    }
644
645    #[test]
646    fn parse_run_reject_requires_an_id() {
647        assert!(Cli::try_parse_from(["ironflow-cli", "run", "reject"]).is_err());
648    }
649
650    #[test]
651    fn parse_run_retry() {
652        let cli = parse(&["ironflow-cli", "run", "retry", UUID]);
653        assert!(matches!(cli.command, Commands::Run(_)));
654    }
655
656    #[test]
657    fn parse_run_list_with_filters() {
658        let cli = parse(&[
659            "ironflow-cli",
660            "run",
661            "list",
662            "--status",
663            "completed",
664            "--workflow",
665            "deploy",
666            "--page",
667            "2",
668            "--per-page",
669            "50",
670        ]);
671        assert!(matches!(cli.command, Commands::Run(_)));
672    }
673
674    #[test]
675    fn parse_workflow_list() {
676        let cli = parse(&["ironflow-cli", "workflow", "list"]);
677        assert!(matches!(cli.command, Commands::Workflow(_)));
678    }
679
680    #[test]
681    fn parse_workflow_get() {
682        let cli = parse(&["ironflow-cli", "workflow", "get", "deploy"]);
683        assert!(matches!(cli.command, Commands::Workflow(_)));
684    }
685
686    #[test]
687    fn parse_logs() {
688        let cli = parse(&["ironflow-cli", "logs", UUID]);
689        assert!(matches!(cli.command, Commands::Logs(_)));
690    }
691
692    #[test]
693    fn parse_logs_follow() {
694        let cli = parse(&["ironflow-cli", "logs", UUID, "--follow"]);
695        let Commands::Logs(args) = &cli.command else {
696            panic!("expected Logs command");
697        };
698        assert!(args.follow);
699    }
700
701    #[test]
702    fn parse_stats() {
703        let cli = parse(&["ironflow-cli", "stats"]);
704        assert!(matches!(cli.command, Commands::Stats(_)));
705    }
706
707    #[test]
708    fn parse_verbose_flag() {
709        let cli = parse(&["ironflow-cli", "--verbose", "stats"]);
710        assert!(cli.verbose);
711    }
712
713    #[test]
714    fn parse_url_override() {
715        let cli = parse(&[
716            "ironflow-cli",
717            "--url",
718            "https://custom.example.com",
719            "stats",
720        ]);
721        assert_eq!(cli.url.as_deref(), Some("https://custom.example.com"));
722    }
723
724    #[test]
725    fn parse_invalid_uuid_rejected() {
726        assert!(Cli::try_parse_from(["ironflow-cli", "run", "get", "not-a-uuid"]).is_err());
727    }
728
729    #[test]
730    fn parse_no_command_fails() {
731        assert!(Cli::try_parse_from(["ironflow-cli"]).is_err());
732    }
733
734    // -- Provider Accounts --
735
736    #[test]
737    fn parse_accounts_add_with_token_stdin() {
738        let cli = parse(&["ironflow-cli", "accounts", "add", "perso", "--token-stdin"]);
739        let Commands::Accounts(args) = &cli.command else {
740            panic!("expected Accounts command");
741        };
742        let AccountCommands::Add {
743            name,
744            kind,
745            token_stdin,
746            ..
747        } = &args.command
748        else {
749            panic!("expected Add subcommand");
750        };
751        assert_eq!(name, "perso");
752        assert_eq!(kind, "claude_subscription");
753        assert!(*token_stdin);
754    }
755
756    #[test]
757    fn parse_accounts_add_requires_token_stdin() {
758        assert!(Cli::try_parse_from(["ironflow-cli", "accounts", "add", "perso"]).is_err());
759    }
760
761    #[test]
762    fn parse_accounts_remove_with_yes() {
763        let cli = parse(&["ironflow-cli", "accounts", "remove", "perso", "--yes"]);
764        let Commands::Accounts(args) = &cli.command else {
765            panic!("expected Accounts command");
766        };
767        let AccountCommands::Remove { account, yes } = &args.command else {
768            panic!("expected Remove subcommand");
769        };
770        assert_eq!(account, "perso");
771        assert!(*yes);
772    }
773
774    #[test]
775    fn parse_accounts_list() {
776        let cli = parse(&["ironflow-cli", "accounts", "list"]);
777        assert!(matches!(cli.command, Commands::Accounts(_)));
778    }
779
780    #[test]
781    fn parse_accounts_update_rejects_enable_and_disable() {
782        let args = [
783            "ironflow-cli",
784            "accounts",
785            "update",
786            "perso",
787            "--enable",
788            "--disable",
789        ];
790        assert!(Cli::try_parse_from(args).is_err());
791    }
792
793    // ── Secrets ────────────────────────────────────────────────────
794
795    #[test]
796    fn parse_secret_list() {
797        let cli = parse(&["ironflow-cli", "secret", "list"]);
798        assert!(matches!(cli.command, Commands::Secret(_)));
799    }
800
801    #[test]
802    fn parse_secret_set_with_inline_value() {
803        let cli = parse(&["ironflow-cli", "secret", "set", "db/password", "hunter2"]);
804        let Commands::Secret(args) = &cli.command else {
805            panic!("expected Secret command");
806        };
807        let SecretCommands::Set { key, value } = &args.command else {
808            panic!("expected Set subcommand");
809        };
810        assert_eq!(key, "db/password");
811        assert_eq!(value.as_deref(), Some("hunter2"));
812    }
813
814    #[test]
815    fn parse_secret_set_without_value_defers_to_stdin() {
816        let cli = parse(&["ironflow-cli", "secret", "set", "db/password"]);
817        let Commands::Secret(args) = &cli.command else {
818            panic!("expected Secret command");
819        };
820        let SecretCommands::Set { value, .. } = &args.command else {
821            panic!("expected Set subcommand");
822        };
823        assert!(value.is_none());
824    }
825
826    #[test]
827    fn parse_secret_set_requires_a_key() {
828        assert!(Cli::try_parse_from(["ironflow-cli", "secret", "set"]).is_err());
829    }
830
831    #[test]
832    fn parse_secret_update() {
833        let cli = parse(&["ironflow-cli", "secret", "update", "db/password", "new"]);
834        assert!(matches!(cli.command, Commands::Secret(_)));
835    }
836
837    #[test]
838    fn parse_secret_delete_with_yes() {
839        let cli = parse(&["ironflow-cli", "secret", "delete", "db/password", "--yes"]);
840        let Commands::Secret(args) = &cli.command else {
841            panic!("expected Secret command");
842        };
843        let SecretCommands::Delete { yes, .. } = &args.command else {
844            panic!("expected Delete subcommand");
845        };
846        assert!(yes);
847    }
848
849    #[test]
850    fn parse_secret_delete_defaults_to_confirming() {
851        let cli = parse(&["ironflow-cli", "secret", "delete", "db/password"]);
852        let Commands::Secret(args) = &cli.command else {
853            panic!("expected Secret command");
854        };
855        let SecretCommands::Delete { yes, .. } = &args.command else {
856            panic!("expected Delete subcommand");
857        };
858        assert!(!yes);
859    }
860
861    #[test]
862    fn parse_secret_rotate_defaults_to_the_active_version() {
863        let cli = parse(&["ironflow-cli", "secret", "rotate"]);
864        let Commands::Secret(args) = &cli.command else {
865            panic!("expected Secret command");
866        };
867        let SecretCommands::Rotate(rotate) = &args.command else {
868            panic!("expected Rotate subcommand");
869        };
870        assert!(rotate.to_version.is_none());
871        assert_eq!(rotate.batch_size, 100);
872    }
873
874    #[test]
875    fn parse_secret_rotate_with_version_and_batch_size() {
876        let cli = parse(&[
877            "ironflow-cli",
878            "secret",
879            "rotate",
880            "--to-version",
881            "2",
882            "--batch-size",
883            "50",
884        ]);
885        let Commands::Secret(args) = &cli.command else {
886            panic!("expected Secret command");
887        };
888        let SecretCommands::Rotate(rotate) = &args.command else {
889            panic!("expected Rotate subcommand");
890        };
891        assert_eq!(rotate.to_version, Some(2));
892        assert_eq!(rotate.batch_size, 50);
893    }
894
895    #[test]
896    fn parse_secret_rotate_rejects_a_non_positive_version() {
897        let zero = ["ironflow-cli", "secret", "rotate", "--to-version", "0"];
898        let negative = ["ironflow-cli", "secret", "rotate", "--to-version", "-1"];
899        assert!(Cli::try_parse_from(zero).is_err());
900        assert!(Cli::try_parse_from(negative).is_err());
901    }
902
903    #[test]
904    fn parse_secret_rotate_rejects_an_out_of_range_batch_size() {
905        let zero = ["ironflow-cli", "secret", "rotate", "--batch-size", "0"];
906        let too_large = ["ironflow-cli", "secret", "rotate", "--batch-size", "1001"];
907        assert!(Cli::try_parse_from(zero).is_err());
908        assert!(Cli::try_parse_from(too_large).is_err());
909    }
910
911    #[test]
912    fn parse_secret_key_status_takes_no_arguments() {
913        let cli = parse(&["ironflow-cli", "secret", "key-status"]);
914        let Commands::Secret(args) = &cli.command else {
915            panic!("expected Secret command");
916        };
917        assert!(matches!(args.command, SecretCommands::KeyStatus));
918        assert!(Cli::try_parse_from(["ironflow-cli", "secret", "key-status", "extra"]).is_err());
919    }
920
921    // ── API keys ───────────────────────────────────────────────────
922
923    #[test]
924    fn parse_api_key_list() {
925        let cli = parse(&["ironflow-cli", "api-key", "list"]);
926        assert!(matches!(cli.command, Commands::ApiKey(_)));
927    }
928
929    #[test]
930    fn parse_api_key_scopes() {
931        let cli = parse(&["ironflow-cli", "api-key", "scopes"]);
932        assert!(matches!(cli.command, Commands::ApiKey(_)));
933    }
934
935    #[test]
936    fn parse_api_key_create_with_several_scopes() {
937        let cli = parse(&[
938            "ironflow-cli",
939            "api-key",
940            "create",
941            "ci",
942            "--scope",
943            "runs_read",
944            "--scope",
945            "runs_write",
946        ]);
947        let Commands::ApiKey(args) = &cli.command else {
948            panic!("expected ApiKey command");
949        };
950        let ApiKeyCommands::Create { scopes, .. } = &args.command else {
951            panic!("expected Create subcommand");
952        };
953        assert_eq!(scopes.len(), 2);
954    }
955
956    #[test]
957    fn parse_api_key_create_requires_a_scope() {
958        assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci"]).is_err());
959    }
960
961    #[test]
962    fn parse_api_key_create_rejects_an_unknown_scope() {
963        let result =
964            Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci", "--scope", "root"]);
965        assert!(result.is_err());
966    }
967
968    #[test]
969    fn parse_api_key_create_with_expiry() {
970        let cli = parse(&[
971            "ironflow-cli",
972            "api-key",
973            "create",
974            "ci",
975            "--scope",
976            "admin",
977            "--expires-at",
978            "2026-12-31T23:59:59Z",
979        ]);
980        assert!(matches!(cli.command, Commands::ApiKey(_)));
981    }
982
983    #[test]
984    fn parse_api_key_create_rejects_a_malformed_expiry() {
985        let result = Cli::try_parse_from([
986            "ironflow-cli",
987            "api-key",
988            "create",
989            "ci",
990            "--scope",
991            "admin",
992            "--expires-at",
993            "tomorrow",
994        ]);
995        assert!(result.is_err());
996    }
997
998    #[test]
999    fn parse_api_key_delete_rejects_a_non_uuid() {
1000        assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "delete", "abc"]).is_err());
1001    }
1002
1003    // ── Users ──────────────────────────────────────────────────────
1004
1005    #[test]
1006    fn parse_user_list() {
1007        let cli = parse(&["ironflow-cli", "user", "list"]);
1008        assert!(matches!(cli.command, Commands::User(_)));
1009    }
1010
1011    #[test]
1012    fn parse_user_create() {
1013        let cli = parse(&[
1014            "ironflow-cli",
1015            "user",
1016            "create",
1017            "alice",
1018            "--email",
1019            "alice@example.com",
1020            "--password",
1021            "hunter2hunter2",
1022            "--admin",
1023        ]);
1024        let Commands::User(args) = &cli.command else {
1025            panic!("expected User command");
1026        };
1027        let UserCommands::Create { admin, .. } = &args.command else {
1028            panic!("expected Create subcommand");
1029        };
1030        assert!(admin);
1031    }
1032
1033    #[test]
1034    fn parse_user_create_requires_an_email() {
1035        assert!(Cli::try_parse_from(["ironflow-cli", "user", "create", "alice"]).is_err());
1036    }
1037
1038    #[test]
1039    fn parse_user_set_role_admin() {
1040        let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--admin"]);
1041        let Commands::User(args) = &cli.command else {
1042            panic!("expected User command");
1043        };
1044        let UserCommands::SetRole { admin, member, .. } = &args.command else {
1045            panic!("expected SetRole subcommand");
1046        };
1047        assert!(admin);
1048        assert!(!member);
1049    }
1050
1051    #[test]
1052    fn parse_user_set_role_member() {
1053        let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--member"]);
1054        let Commands::User(args) = &cli.command else {
1055            panic!("expected User command");
1056        };
1057        let UserCommands::SetRole { admin, .. } = &args.command else {
1058            panic!("expected SetRole subcommand");
1059        };
1060        assert!(!admin);
1061    }
1062
1063    #[test]
1064    fn parse_user_set_role_requires_a_role() {
1065        assert!(Cli::try_parse_from(["ironflow-cli", "user", "set-role", UUID]).is_err());
1066    }
1067
1068    #[test]
1069    fn parse_user_set_role_rejects_both_roles() {
1070        let result = Cli::try_parse_from([
1071            "ironflow-cli",
1072            "user",
1073            "set-role",
1074            UUID,
1075            "--admin",
1076            "--member",
1077        ]);
1078        assert!(result.is_err());
1079    }
1080
1081    // ── Audit logs ─────────────────────────────────────────────────
1082
1083    #[test]
1084    fn parse_audit_log_list_without_filters() {
1085        let cli = parse(&["ironflow-cli", "audit-log", "list"]);
1086        assert!(matches!(cli.command, Commands::AuditLog(_)));
1087    }
1088
1089    #[test]
1090    fn parse_audit_log_list_with_every_filter() {
1091        let cli = parse(&[
1092            "ironflow-cli",
1093            "audit-log",
1094            "list",
1095            "--run",
1096            UUID,
1097            "--type",
1098            "run_created",
1099            "--from",
1100            "2026-01-01T00:00:00Z",
1101            "--to",
1102            "2026-12-31T23:59:59Z",
1103            "--page",
1104            "2",
1105            "--per-page",
1106            "10",
1107        ]);
1108        let Commands::AuditLog(args) = &cli.command else {
1109            panic!("expected AuditLog command");
1110        };
1111        let AuditLogCommands::List {
1112            run,
1113            event_type,
1114            from,
1115            to,
1116            page,
1117            per_page,
1118        } = &args.command;
1119        assert!(run.is_some());
1120        assert!(event_type.is_some());
1121        assert!(from.is_some());
1122        assert!(to.is_some());
1123        assert_eq!(*page, Some(2));
1124        assert_eq!(*per_page, Some(10));
1125    }
1126
1127    #[test]
1128    fn parse_audit_log_list_rejects_an_unknown_type() {
1129        let result =
1130            Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--type", "exploded"]);
1131        assert!(result.is_err());
1132    }
1133
1134    #[test]
1135    fn parse_audit_log_list_rejects_a_malformed_date() {
1136        let result = Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--from", "hier"]);
1137        assert!(result.is_err());
1138    }
1139
1140    // ── Approval delegations ───────────────────────────────────────
1141
1142    #[test]
1143    fn parse_delegation_list() {
1144        let cli = parse(&["ironflow-cli", "delegation", "list"]);
1145        assert!(matches!(cli.command, Commands::Delegation(_)));
1146    }
1147
1148    #[test]
1149    fn parse_delegation_list_with_every_flag() {
1150        let cli = parse(&[
1151            "ironflow-cli",
1152            "delegation",
1153            "list",
1154            "--from-user",
1155            UUID,
1156            "--to-user",
1157            UUID,
1158            "--page",
1159            "2",
1160            "--per-page",
1161            "10",
1162        ]);
1163        let Commands::Delegation(args) = &cli.command else {
1164            panic!("expected Delegation command");
1165        };
1166        let DelegationCommands::List {
1167            from_user,
1168            to_user,
1169            page,
1170            per_page,
1171        } = &args.command
1172        else {
1173            panic!("expected List subcommand");
1174        };
1175        assert!(from_user.is_some());
1176        assert!(to_user.is_some());
1177        assert_eq!(*page, Some(2));
1178        assert_eq!(*per_page, Some(10));
1179    }
1180
1181    #[test]
1182    fn parse_delegation_create_with_every_flag() {
1183        let cli = parse(&[
1184            "ironflow-cli",
1185            "delegation",
1186            "create",
1187            UUID,
1188            "--until",
1189            "2026-12-31T23:59:59Z",
1190            "--from",
1191            "2026-12-01T00:00:00Z",
1192            "--workflow",
1193            "deploy-*",
1194        ]);
1195        let Commands::Delegation(args) = &cli.command else {
1196            panic!("expected Delegation command");
1197        };
1198        let DelegationCommands::Create {
1199            until,
1200            from,
1201            workflow,
1202            ..
1203        } = &args.command
1204        else {
1205            panic!("expected Create subcommand");
1206        };
1207        assert_eq!(until, "2026-12-31T23:59:59Z");
1208        assert_eq!(from.as_deref(), Some("2026-12-01T00:00:00Z"));
1209        assert_eq!(workflow.as_deref(), Some("deploy-*"));
1210    }
1211
1212    #[test]
1213    fn parse_delegation_create_requires_an_until() {
1214        assert!(Cli::try_parse_from(["ironflow-cli", "delegation", "create", UUID]).is_err());
1215    }
1216
1217    #[test]
1218    fn parse_delegation_create_rejects_a_non_uuid_target() {
1219        let result = Cli::try_parse_from([
1220            "ironflow-cli",
1221            "delegation",
1222            "create",
1223            "alice",
1224            "--until",
1225            "2026-12-31T23:59:59Z",
1226        ]);
1227        assert!(result.is_err());
1228    }
1229
1230    #[test]
1231    fn parse_delegation_delete_defaults_to_confirming() {
1232        let cli = parse(&["ironflow-cli", "delegation", "delete", UUID]);
1233        let Commands::Delegation(args) = &cli.command else {
1234            panic!("expected Delegation command");
1235        };
1236        let DelegationCommands::Delete { yes, .. } = &args.command else {
1237            panic!("expected Delete subcommand");
1238        };
1239        assert!(!yes);
1240    }
1241
1242    // ── Completions & man ───────────────────────────────────────
1243
1244    #[test]
1245    fn parse_completions_bash() {
1246        let cli = parse(&["ironflow-cli", "completions", "bash"]);
1247        let Commands::Completions { shell } = &cli.command else {
1248            panic!("expected Completions command");
1249        };
1250        assert_eq!(*shell, Shell::Bash);
1251    }
1252
1253    #[test]
1254    fn parse_completions_zsh() {
1255        let cli = parse(&["ironflow-cli", "completions", "zsh"]);
1256        let Commands::Completions { shell } = &cli.command else {
1257            panic!("expected Completions command");
1258        };
1259        assert_eq!(*shell, Shell::Zsh);
1260    }
1261
1262    #[test]
1263    fn parse_completions_fish() {
1264        let cli = parse(&["ironflow-cli", "completions", "fish"]);
1265        let Commands::Completions { shell } = &cli.command else {
1266            panic!("expected Completions command");
1267        };
1268        assert_eq!(*shell, Shell::Fish);
1269    }
1270
1271    #[test]
1272    fn parse_completions_powershell() {
1273        let cli = parse(&["ironflow-cli", "completions", "powershell"]);
1274        let Commands::Completions { shell } = &cli.command else {
1275            panic!("expected Completions command");
1276        };
1277        assert_eq!(*shell, Shell::PowerShell);
1278    }
1279
1280    #[test]
1281    fn parse_completions_requires_shell() {
1282        assert!(Cli::try_parse_from(["ironflow-cli", "completions"]).is_err());
1283    }
1284
1285    #[test]
1286    fn parse_completions_rejects_unknown_shell() {
1287        assert!(Cli::try_parse_from(["ironflow-cli", "completions", "nushell"]).is_err());
1288    }
1289
1290    #[test]
1291    fn parse_man() {
1292        let cli = parse(&["ironflow-cli", "man"]);
1293        assert!(matches!(cli.command, Commands::Man));
1294    }
1295
1296    #[test]
1297    fn completions_bash_output_is_valid() {
1298        let mut buf = Vec::new();
1299        super::generate_completions(Shell::Bash, &mut buf).unwrap();
1300        let output = String::from_utf8(buf).unwrap();
1301        assert!(output.contains("ironflow-cli"));
1302    }
1303
1304    #[test]
1305    fn man_page_output_is_valid() {
1306        let mut buf = Vec::new();
1307        super::generate_man_page(&mut buf).unwrap();
1308        let output = String::from_utf8(buf).unwrap();
1309        assert!(output.contains(".TH"));
1310        assert!(output.contains("ironflow-cli"));
1311    }
1312
1313    // ---- template ----
1314
1315    #[test]
1316    fn parse_template_list() {
1317        let cli = parse(&[
1318            "ironflow-cli",
1319            "template",
1320            "list",
1321            "https://github.com/user/templates",
1322        ]);
1323        assert!(matches!(cli.command, Commands::Template(_)));
1324    }
1325
1326    #[test]
1327    fn parse_template_add_with_from() {
1328        let cli = parse(&[
1329            "ironflow-cli",
1330            "template",
1331            "add",
1332            "ci-pipeline",
1333            "--from",
1334            "https://github.com/user/templates",
1335        ]);
1336        assert!(matches!(cli.command, Commands::Template(_)));
1337    }
1338
1339    #[test]
1340    fn parse_template_add_with_output() {
1341        let cli = parse(&[
1342            "ironflow-cli",
1343            "template",
1344            "add",
1345            "ci-pipeline",
1346            "--from",
1347            "https://github.com/user/templates",
1348            "--output",
1349            "my/custom/path",
1350        ]);
1351        assert!(matches!(cli.command, Commands::Template(_)));
1352    }
1353
1354    #[test]
1355    fn parse_template_list_registry() {
1356        let cli = parse(&["ironflow-cli", "template", "list", "--registry"]);
1357        assert!(matches!(cli.command, Commands::Template(_)));
1358    }
1359
1360    #[test]
1361    fn parse_template_update() {
1362        let cli = parse(&["ironflow-cli", "template", "update"]);
1363        assert!(matches!(cli.command, Commands::Template(_)));
1364    }
1365
1366    #[test]
1367    fn parse_template_info() {
1368        let cli = parse(&[
1369            "ironflow-cli",
1370            "template",
1371            "info",
1372            "https://github.com/user/templates",
1373            "ci-pipeline",
1374        ]);
1375        assert!(matches!(cli.command, Commands::Template(_)));
1376    }
1377
1378    #[test]
1379    fn parse_template_requires_subcommand() {
1380        let result = Cli::try_parse_from(["ironflow-cli", "template"]);
1381        assert!(result.is_err());
1382    }
1383
1384    // ── Signals ────────────────────────────────────────────────────
1385
1386    #[test]
1387    fn parse_signal_send_with_every_flag() {
1388        let cli = parse(&[
1389            "ironflow-cli",
1390            "signal",
1391            "send",
1392            "ci.pipeline_finished",
1393            "--key",
1394            "4f2a9c1",
1395            "--payload",
1396            r#"{"status":"success"}"#,
1397            "--idempotency-id",
1398            "delivery-42",
1399        ]);
1400        let Commands::Signal(args) = &cli.command else {
1401            panic!("expected Signal command");
1402        };
1403        let SignalCommands::Send {
1404            name,
1405            key,
1406            payload,
1407            idempotency_id,
1408        } = &args.command
1409        else {
1410            panic!("expected Send subcommand");
1411        };
1412        assert_eq!(name, "ci.pipeline_finished");
1413        assert_eq!(key, "4f2a9c1");
1414        assert_eq!(payload.as_deref(), Some(r#"{"status":"success"}"#));
1415        assert_eq!(idempotency_id.as_deref(), Some("delivery-42"));
1416    }
1417
1418    #[test]
1419    fn parse_signal_send_requires_a_key() {
1420        let result = Cli::try_parse_from(["ironflow-cli", "signal", "send", "demo.done"]);
1421        assert!(result.is_err());
1422    }
1423
1424    #[test]
1425    fn parse_signal_list_with_filters() {
1426        let cli = parse(&[
1427            "ironflow-cli",
1428            "signal",
1429            "list",
1430            "--name",
1431            "demo.done",
1432            "--key",
1433            "k1",
1434            "--page",
1435            "2",
1436            "--per-page",
1437            "10",
1438        ]);
1439        let Commands::Signal(args) = &cli.command else {
1440            panic!("expected Signal command");
1441        };
1442        let SignalCommands::List {
1443            name,
1444            key,
1445            page,
1446            per_page,
1447        } = &args.command
1448        else {
1449            panic!("expected List subcommand");
1450        };
1451        assert_eq!(name.as_deref(), Some("demo.done"));
1452        assert_eq!(key.as_deref(), Some("k1"));
1453        assert_eq!(*page, Some(2));
1454        assert_eq!(*per_page, Some(10));
1455    }
1456
1457    #[test]
1458    fn parse_run_steps_by_role() {
1459        let cli = parse(&["ironflow-cli", "run", "steps", "--role", "reviewer"]);
1460        let Commands::Run(args) = &cli.command else {
1461            panic!("expected Run command");
1462        };
1463        let RunCommands::Steps {
1464            role,
1465            page,
1466            per_page,
1467        } = &args.command
1468        else {
1469            panic!("expected Steps subcommand");
1470        };
1471        assert_eq!(role, "reviewer");
1472        assert_eq!(*page, 1);
1473        assert_eq!(*per_page, 20);
1474    }
1475
1476    #[test]
1477    fn parse_run_steps_requires_a_role() {
1478        let result = Cli::try_parse_from(["ironflow-cli", "run", "steps"]);
1479        assert!(result.is_err());
1480    }
1481
1482    #[test]
1483    fn parse_stats_roles_with_period() {
1484        let cli = parse(&["ironflow-cli", "stats", "roles", "--period", "30d"]);
1485        let Commands::Stats(args) = &cli.command else {
1486            panic!("expected Stats command");
1487        };
1488        let Some(StatsCommands::Roles { period }) = &args.command else {
1489            panic!("expected Roles subcommand");
1490        };
1491        assert_eq!(period, "30d");
1492    }
1493
1494    #[test]
1495    fn parse_stats_roles_defaults_to_7d() {
1496        let cli = parse(&["ironflow-cli", "stats", "roles"]);
1497        let Commands::Stats(args) = &cli.command else {
1498            panic!("expected Stats command");
1499        };
1500        let Some(StatsCommands::Roles { period }) = &args.command else {
1501            panic!("expected Roles subcommand");
1502        };
1503        assert_eq!(period, "7d");
1504    }
1505}