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