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