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