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::api_key::ApiKeyArgs;
17use crate::commands::audit_log::AuditLogArgs;
18use crate::commands::dashboard::DashboardArgs;
19use crate::commands::init::InitArgs;
20use crate::commands::logs::LogsArgs;
21use crate::commands::run::RunArgs;
22use crate::commands::schedule::ScheduleArgs;
23use crate::commands::secret::SecretArgs;
24use crate::commands::stats::StatsArgs;
25use crate::commands::template::TemplateArgs;
26use crate::commands::user::UserArgs;
27use crate::commands::workflow::WorkflowArgs;
28
29/// CLI for the Ironflow workflow engine.
30///
31/// # Examples
32///
33/// ```
34/// use clap::Parser;
35/// use ironflow_cli::cli::Cli;
36///
37/// let cli = Cli::try_parse_from(["ironflow-cli", "run", "list"])?;
38/// assert!(!cli.json);
39/// # Ok::<(), clap::Error>(())
40/// ```
41#[derive(Debug, Parser)]
42#[command(
43    name = "ironflow-cli",
44    version,
45    about = "Drive the Ironflow workflow engine from the terminal"
46)]
47pub struct Cli {
48    /// Output raw JSON instead of formatted tables.
49    #[arg(long, global = true)]
50    pub json: bool,
51
52    /// Show verbose output (e.g. full step details in `run get`).
53    #[arg(long, global = true)]
54    pub verbose: bool,
55
56    /// Override the Ironflow API base URL.
57    #[arg(long, global = true, env = "IRONFLOW_URL")]
58    pub url: Option<String>,
59
60    /// Override the API key for authentication.
61    #[arg(long, global = true, env = "IRONFLOW_API_KEY")]
62    pub api_key: Option<String>,
63
64    /// Command to execute.
65    #[command(subcommand)]
66    pub command: Commands,
67}
68
69/// Top-level commands.
70#[derive(Debug, Subcommand)]
71pub enum Commands {
72    /// Manage workflow runs.
73    Run(RunArgs),
74    /// Manage workflows.
75    Workflow(WorkflowArgs),
76    /// Stream run logs via SSE.
77    Logs(LogsArgs),
78    /// Show statistics (aggregate or historical).
79    Stats(StatsArgs),
80    /// Manage secrets (admin only).
81    Secret(SecretArgs),
82    /// Manage API keys.
83    #[command(name = "api-key")]
84    ApiKey(ApiKeyArgs),
85    /// Manage users (admin only).
86    User(UserArgs),
87    /// Inspect audit logs (admin only).
88    #[command(name = "audit-log")]
89    AuditLog(AuditLogArgs),
90    /// Manage workflow schedules.
91    Schedule(ScheduleArgs),
92    /// Manage workflow templates (add, list, info, create).
93    Template(TemplateArgs),
94    /// Scaffold a new Ironflow project.
95    Init(InitArgs),
96    /// Open the Ironflow dashboard in the default browser.
97    Dashboard(DashboardArgs),
98    /// Generate shell completions for the given shell.
99    Completions {
100        /// Target shell.
101        shell: Shell,
102    },
103    /// Generate a man page and write it to stdout.
104    Man,
105}
106
107/// Write shell completions for `shell` to `writer`.
108///
109/// # Errors
110///
111/// Returns an error if writing to `writer` fails.
112///
113/// # Examples
114///
115/// ```no_run
116/// use ironflow_cli::cli::generate_completions;
117/// use clap_complete::Shell;
118///
119/// let mut buf = Vec::new();
120/// generate_completions(Shell::Bash, &mut buf)?;
121/// assert!(!buf.is_empty());
122/// # Ok::<(), anyhow::Error>(())
123/// ```
124pub fn generate_completions(shell: Shell, writer: &mut impl io::Write) -> Result<()> {
125    let mut cmd = Cli::command();
126    clap_complete::generate(shell, &mut cmd, "ironflow-cli", writer);
127    Ok(())
128}
129
130/// Write a roff-formatted man page to `writer`.
131///
132/// # Errors
133///
134/// Returns an error if rendering or writing fails.
135///
136/// # Examples
137///
138/// ```no_run
139/// use ironflow_cli::cli::generate_man_page;
140///
141/// let mut buf = Vec::new();
142/// generate_man_page(&mut buf)?;
143/// assert!(!buf.is_empty());
144/// # Ok::<(), anyhow::Error>(())
145/// ```
146pub fn generate_man_page(writer: &mut impl io::Write) -> Result<()> {
147    let cmd = Cli::command();
148    Man::new(cmd).render(writer)?;
149    Ok(())
150}
151
152/// Dispatch a parsed command against a client.
153///
154/// # Errors
155///
156/// Returns an error on API failure, invalid input, or an unconfirmed
157/// destructive command.
158pub async fn dispatch(client: &IronflowClient, cli: &Cli) -> Result<()> {
159    match &cli.command {
160        Commands::Run(args) => commands::run::execute(client, args, cli.json, cli.verbose).await,
161        Commands::Workflow(args) => commands::workflow::execute(client, args, cli.json).await,
162        Commands::Logs(args) => commands::logs::execute(client, args, cli.json).await,
163        Commands::Stats(args) => commands::stats::execute(client, args, cli.json).await,
164        Commands::Secret(args) => commands::secret::execute(client, args, cli.json).await,
165        Commands::ApiKey(args) => commands::api_key::execute(client, args, cli.json).await,
166        Commands::User(args) => commands::user::execute(client, args, cli.json).await,
167        Commands::AuditLog(args) => commands::audit_log::execute(client, args, cli.json).await,
168        Commands::Schedule(args) => commands::schedule::execute(client, args, cli.json).await,
169        Commands::Template(args) => commands::template::execute(args),
170        Commands::Init(args) => commands::init::execute(args),
171        Commands::Dashboard(args) => commands::dashboard::execute(client, args),
172        Commands::Completions { shell } => generate_completions(*shell, &mut io::stdout()),
173        Commands::Man => generate_man_page(&mut io::stdout()),
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use clap::Parser;
180
181    use crate::commands::api_key::ApiKeyCommands;
182    use crate::commands::audit_log::AuditLogCommands;
183    use crate::commands::secret::SecretCommands;
184    use crate::commands::user::UserCommands;
185
186    use super::*;
187
188    const UUID: &str = "01234567-89ab-cdef-0123-456789abcdef";
189
190    fn parse(args: &[&str]) -> Cli {
191        Cli::try_parse_from(args).unwrap()
192    }
193
194    #[test]
195    fn parse_run_list() {
196        let cli = parse(&["ironflow-cli", "run", "list"]);
197        assert!(!cli.json);
198        assert!(matches!(cli.command, Commands::Run(_)));
199    }
200
201    #[test]
202    fn parse_run_list_with_json() {
203        let cli = parse(&["ironflow-cli", "--json", "run", "list"]);
204        assert!(cli.json);
205    }
206
207    #[test]
208    fn parse_run_create_with_payload() {
209        let cli = parse(&[
210            "ironflow-cli",
211            "run",
212            "create",
213            "deploy",
214            "--payload",
215            r#"{"env": "prod"}"#,
216        ]);
217        assert!(matches!(cli.command, Commands::Run(_)));
218    }
219
220    #[test]
221    fn parse_run_create_with_payload_file() {
222        let cli = parse(&[
223            "ironflow-cli",
224            "run",
225            "create",
226            "deploy",
227            "--payload-file",
228            "/tmp/payload.json",
229        ]);
230        assert!(matches!(cli.command, Commands::Run(_)));
231    }
232
233    #[test]
234    fn parse_run_create_payload_and_file_conflict() {
235        let result = Cli::try_parse_from([
236            "ironflow-cli",
237            "run",
238            "create",
239            "deploy",
240            "--payload",
241            "{}",
242            "--payload-file",
243            "/tmp/p.json",
244        ]);
245        assert!(result.is_err());
246    }
247
248    #[test]
249    fn parse_run_get() {
250        let cli = parse(&["ironflow-cli", "run", "get", UUID]);
251        assert!(matches!(cli.command, Commands::Run(_)));
252    }
253
254    #[test]
255    fn parse_run_cancel() {
256        let cli = parse(&["ironflow-cli", "run", "cancel", UUID]);
257        assert!(matches!(cli.command, Commands::Run(_)));
258    }
259
260    #[test]
261    fn parse_run_approve() {
262        let cli = parse(&["ironflow-cli", "run", "approve", UUID]);
263        assert!(matches!(cli.command, Commands::Run(_)));
264    }
265
266    #[test]
267    fn parse_run_reject() {
268        let cli = parse(&["ironflow-cli", "run", "reject", UUID]);
269        assert!(matches!(cli.command, Commands::Run(_)));
270    }
271
272    #[test]
273    fn parse_run_reject_requires_an_id() {
274        assert!(Cli::try_parse_from(["ironflow-cli", "run", "reject"]).is_err());
275    }
276
277    #[test]
278    fn parse_run_retry() {
279        let cli = parse(&["ironflow-cli", "run", "retry", UUID]);
280        assert!(matches!(cli.command, Commands::Run(_)));
281    }
282
283    #[test]
284    fn parse_run_list_with_filters() {
285        let cli = parse(&[
286            "ironflow-cli",
287            "run",
288            "list",
289            "--status",
290            "completed",
291            "--workflow",
292            "deploy",
293            "--page",
294            "2",
295            "--per-page",
296            "50",
297        ]);
298        assert!(matches!(cli.command, Commands::Run(_)));
299    }
300
301    #[test]
302    fn parse_workflow_list() {
303        let cli = parse(&["ironflow-cli", "workflow", "list"]);
304        assert!(matches!(cli.command, Commands::Workflow(_)));
305    }
306
307    #[test]
308    fn parse_workflow_get() {
309        let cli = parse(&["ironflow-cli", "workflow", "get", "deploy"]);
310        assert!(matches!(cli.command, Commands::Workflow(_)));
311    }
312
313    #[test]
314    fn parse_logs() {
315        let cli = parse(&["ironflow-cli", "logs", UUID]);
316        assert!(matches!(cli.command, Commands::Logs(_)));
317    }
318
319    #[test]
320    fn parse_logs_follow() {
321        let cli = parse(&["ironflow-cli", "logs", UUID, "--follow"]);
322        let Commands::Logs(args) = &cli.command else {
323            panic!("expected Logs command");
324        };
325        assert!(args.follow);
326    }
327
328    #[test]
329    fn parse_stats() {
330        let cli = parse(&["ironflow-cli", "stats"]);
331        assert!(matches!(cli.command, Commands::Stats(_)));
332    }
333
334    #[test]
335    fn parse_verbose_flag() {
336        let cli = parse(&["ironflow-cli", "--verbose", "stats"]);
337        assert!(cli.verbose);
338    }
339
340    #[test]
341    fn parse_url_override() {
342        let cli = parse(&[
343            "ironflow-cli",
344            "--url",
345            "https://custom.example.com",
346            "stats",
347        ]);
348        assert_eq!(cli.url.as_deref(), Some("https://custom.example.com"));
349    }
350
351    #[test]
352    fn parse_invalid_uuid_rejected() {
353        assert!(Cli::try_parse_from(["ironflow-cli", "run", "get", "not-a-uuid"]).is_err());
354    }
355
356    #[test]
357    fn parse_no_command_fails() {
358        assert!(Cli::try_parse_from(["ironflow-cli"]).is_err());
359    }
360
361    // ── Secrets ────────────────────────────────────────────────────
362
363    #[test]
364    fn parse_secret_list() {
365        let cli = parse(&["ironflow-cli", "secret", "list"]);
366        assert!(matches!(cli.command, Commands::Secret(_)));
367    }
368
369    #[test]
370    fn parse_secret_set_with_inline_value() {
371        let cli = parse(&["ironflow-cli", "secret", "set", "db/password", "hunter2"]);
372        let Commands::Secret(args) = &cli.command else {
373            panic!("expected Secret command");
374        };
375        let SecretCommands::Set { key, value } = &args.command else {
376            panic!("expected Set subcommand");
377        };
378        assert_eq!(key, "db/password");
379        assert_eq!(value.as_deref(), Some("hunter2"));
380    }
381
382    #[test]
383    fn parse_secret_set_without_value_defers_to_stdin() {
384        let cli = parse(&["ironflow-cli", "secret", "set", "db/password"]);
385        let Commands::Secret(args) = &cli.command else {
386            panic!("expected Secret command");
387        };
388        let SecretCommands::Set { value, .. } = &args.command else {
389            panic!("expected Set subcommand");
390        };
391        assert!(value.is_none());
392    }
393
394    #[test]
395    fn parse_secret_set_requires_a_key() {
396        assert!(Cli::try_parse_from(["ironflow-cli", "secret", "set"]).is_err());
397    }
398
399    #[test]
400    fn parse_secret_update() {
401        let cli = parse(&["ironflow-cli", "secret", "update", "db/password", "new"]);
402        assert!(matches!(cli.command, Commands::Secret(_)));
403    }
404
405    #[test]
406    fn parse_secret_delete_with_yes() {
407        let cli = parse(&["ironflow-cli", "secret", "delete", "db/password", "--yes"]);
408        let Commands::Secret(args) = &cli.command else {
409            panic!("expected Secret command");
410        };
411        let SecretCommands::Delete { yes, .. } = &args.command else {
412            panic!("expected Delete subcommand");
413        };
414        assert!(yes);
415    }
416
417    #[test]
418    fn parse_secret_delete_defaults_to_confirming() {
419        let cli = parse(&["ironflow-cli", "secret", "delete", "db/password"]);
420        let Commands::Secret(args) = &cli.command else {
421            panic!("expected Secret command");
422        };
423        let SecretCommands::Delete { yes, .. } = &args.command else {
424            panic!("expected Delete subcommand");
425        };
426        assert!(!yes);
427    }
428
429    #[test]
430    fn parse_secret_rotate_defaults_to_the_active_version() {
431        let cli = parse(&["ironflow-cli", "secret", "rotate"]);
432        let Commands::Secret(args) = &cli.command else {
433            panic!("expected Secret command");
434        };
435        let SecretCommands::Rotate(rotate) = &args.command else {
436            panic!("expected Rotate subcommand");
437        };
438        assert!(rotate.to_version.is_none());
439        assert_eq!(rotate.batch_size, 100);
440    }
441
442    #[test]
443    fn parse_secret_rotate_with_version_and_batch_size() {
444        let cli = parse(&[
445            "ironflow-cli",
446            "secret",
447            "rotate",
448            "--to-version",
449            "2",
450            "--batch-size",
451            "50",
452        ]);
453        let Commands::Secret(args) = &cli.command else {
454            panic!("expected Secret command");
455        };
456        let SecretCommands::Rotate(rotate) = &args.command else {
457            panic!("expected Rotate subcommand");
458        };
459        assert_eq!(rotate.to_version, Some(2));
460        assert_eq!(rotate.batch_size, 50);
461    }
462
463    #[test]
464    fn parse_secret_rotate_rejects_a_non_positive_version() {
465        let zero = ["ironflow-cli", "secret", "rotate", "--to-version", "0"];
466        let negative = ["ironflow-cli", "secret", "rotate", "--to-version", "-1"];
467        assert!(Cli::try_parse_from(zero).is_err());
468        assert!(Cli::try_parse_from(negative).is_err());
469    }
470
471    #[test]
472    fn parse_secret_rotate_rejects_an_out_of_range_batch_size() {
473        let zero = ["ironflow-cli", "secret", "rotate", "--batch-size", "0"];
474        let too_large = ["ironflow-cli", "secret", "rotate", "--batch-size", "1001"];
475        assert!(Cli::try_parse_from(zero).is_err());
476        assert!(Cli::try_parse_from(too_large).is_err());
477    }
478
479    #[test]
480    fn parse_secret_key_status_takes_no_arguments() {
481        let cli = parse(&["ironflow-cli", "secret", "key-status"]);
482        let Commands::Secret(args) = &cli.command else {
483            panic!("expected Secret command");
484        };
485        assert!(matches!(args.command, SecretCommands::KeyStatus));
486        assert!(Cli::try_parse_from(["ironflow-cli", "secret", "key-status", "extra"]).is_err());
487    }
488
489    // ── API keys ───────────────────────────────────────────────────
490
491    #[test]
492    fn parse_api_key_list() {
493        let cli = parse(&["ironflow-cli", "api-key", "list"]);
494        assert!(matches!(cli.command, Commands::ApiKey(_)));
495    }
496
497    #[test]
498    fn parse_api_key_scopes() {
499        let cli = parse(&["ironflow-cli", "api-key", "scopes"]);
500        assert!(matches!(cli.command, Commands::ApiKey(_)));
501    }
502
503    #[test]
504    fn parse_api_key_create_with_several_scopes() {
505        let cli = parse(&[
506            "ironflow-cli",
507            "api-key",
508            "create",
509            "ci",
510            "--scope",
511            "runs_read",
512            "--scope",
513            "runs_write",
514        ]);
515        let Commands::ApiKey(args) = &cli.command else {
516            panic!("expected ApiKey command");
517        };
518        let ApiKeyCommands::Create { scopes, .. } = &args.command else {
519            panic!("expected Create subcommand");
520        };
521        assert_eq!(scopes.len(), 2);
522    }
523
524    #[test]
525    fn parse_api_key_create_requires_a_scope() {
526        assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci"]).is_err());
527    }
528
529    #[test]
530    fn parse_api_key_create_rejects_an_unknown_scope() {
531        let result =
532            Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci", "--scope", "root"]);
533        assert!(result.is_err());
534    }
535
536    #[test]
537    fn parse_api_key_create_with_expiry() {
538        let cli = parse(&[
539            "ironflow-cli",
540            "api-key",
541            "create",
542            "ci",
543            "--scope",
544            "admin",
545            "--expires-at",
546            "2026-12-31T23:59:59Z",
547        ]);
548        assert!(matches!(cli.command, Commands::ApiKey(_)));
549    }
550
551    #[test]
552    fn parse_api_key_create_rejects_a_malformed_expiry() {
553        let result = Cli::try_parse_from([
554            "ironflow-cli",
555            "api-key",
556            "create",
557            "ci",
558            "--scope",
559            "admin",
560            "--expires-at",
561            "tomorrow",
562        ]);
563        assert!(result.is_err());
564    }
565
566    #[test]
567    fn parse_api_key_delete_rejects_a_non_uuid() {
568        assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "delete", "abc"]).is_err());
569    }
570
571    // ── Users ──────────────────────────────────────────────────────
572
573    #[test]
574    fn parse_user_list() {
575        let cli = parse(&["ironflow-cli", "user", "list"]);
576        assert!(matches!(cli.command, Commands::User(_)));
577    }
578
579    #[test]
580    fn parse_user_create() {
581        let cli = parse(&[
582            "ironflow-cli",
583            "user",
584            "create",
585            "alice",
586            "--email",
587            "alice@example.com",
588            "--password",
589            "hunter2hunter2",
590            "--admin",
591        ]);
592        let Commands::User(args) = &cli.command else {
593            panic!("expected User command");
594        };
595        let UserCommands::Create { admin, .. } = &args.command else {
596            panic!("expected Create subcommand");
597        };
598        assert!(admin);
599    }
600
601    #[test]
602    fn parse_user_create_requires_an_email() {
603        assert!(Cli::try_parse_from(["ironflow-cli", "user", "create", "alice"]).is_err());
604    }
605
606    #[test]
607    fn parse_user_set_role_admin() {
608        let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--admin"]);
609        let Commands::User(args) = &cli.command else {
610            panic!("expected User command");
611        };
612        let UserCommands::SetRole { admin, member, .. } = &args.command else {
613            panic!("expected SetRole subcommand");
614        };
615        assert!(admin);
616        assert!(!member);
617    }
618
619    #[test]
620    fn parse_user_set_role_member() {
621        let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--member"]);
622        let Commands::User(args) = &cli.command else {
623            panic!("expected User command");
624        };
625        let UserCommands::SetRole { admin, .. } = &args.command else {
626            panic!("expected SetRole subcommand");
627        };
628        assert!(!admin);
629    }
630
631    #[test]
632    fn parse_user_set_role_requires_a_role() {
633        assert!(Cli::try_parse_from(["ironflow-cli", "user", "set-role", UUID]).is_err());
634    }
635
636    #[test]
637    fn parse_user_set_role_rejects_both_roles() {
638        let result = Cli::try_parse_from([
639            "ironflow-cli",
640            "user",
641            "set-role",
642            UUID,
643            "--admin",
644            "--member",
645        ]);
646        assert!(result.is_err());
647    }
648
649    // ── Audit logs ─────────────────────────────────────────────────
650
651    #[test]
652    fn parse_audit_log_list_without_filters() {
653        let cli = parse(&["ironflow-cli", "audit-log", "list"]);
654        assert!(matches!(cli.command, Commands::AuditLog(_)));
655    }
656
657    #[test]
658    fn parse_audit_log_list_with_every_filter() {
659        let cli = parse(&[
660            "ironflow-cli",
661            "audit-log",
662            "list",
663            "--run",
664            UUID,
665            "--type",
666            "run_created",
667            "--from",
668            "2026-01-01T00:00:00Z",
669            "--to",
670            "2026-12-31T23:59:59Z",
671            "--page",
672            "2",
673            "--per-page",
674            "10",
675        ]);
676        let Commands::AuditLog(args) = &cli.command else {
677            panic!("expected AuditLog command");
678        };
679        let AuditLogCommands::List {
680            run,
681            event_type,
682            from,
683            to,
684            page,
685            per_page,
686        } = &args.command;
687        assert!(run.is_some());
688        assert!(event_type.is_some());
689        assert!(from.is_some());
690        assert!(to.is_some());
691        assert_eq!(*page, Some(2));
692        assert_eq!(*per_page, Some(10));
693    }
694
695    #[test]
696    fn parse_audit_log_list_rejects_an_unknown_type() {
697        let result =
698            Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--type", "exploded"]);
699        assert!(result.is_err());
700    }
701
702    #[test]
703    fn parse_audit_log_list_rejects_a_malformed_date() {
704        let result = Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--from", "hier"]);
705        assert!(result.is_err());
706    }
707
708    // ── Completions & man ───────────────────────────────────────
709
710    #[test]
711    fn parse_completions_bash() {
712        let cli = parse(&["ironflow-cli", "completions", "bash"]);
713        let Commands::Completions { shell } = &cli.command else {
714            panic!("expected Completions command");
715        };
716        assert_eq!(*shell, Shell::Bash);
717    }
718
719    #[test]
720    fn parse_completions_zsh() {
721        let cli = parse(&["ironflow-cli", "completions", "zsh"]);
722        let Commands::Completions { shell } = &cli.command else {
723            panic!("expected Completions command");
724        };
725        assert_eq!(*shell, Shell::Zsh);
726    }
727
728    #[test]
729    fn parse_completions_fish() {
730        let cli = parse(&["ironflow-cli", "completions", "fish"]);
731        let Commands::Completions { shell } = &cli.command else {
732            panic!("expected Completions command");
733        };
734        assert_eq!(*shell, Shell::Fish);
735    }
736
737    #[test]
738    fn parse_completions_powershell() {
739        let cli = parse(&["ironflow-cli", "completions", "powershell"]);
740        let Commands::Completions { shell } = &cli.command else {
741            panic!("expected Completions command");
742        };
743        assert_eq!(*shell, Shell::PowerShell);
744    }
745
746    #[test]
747    fn parse_completions_requires_shell() {
748        assert!(Cli::try_parse_from(["ironflow-cli", "completions"]).is_err());
749    }
750
751    #[test]
752    fn parse_completions_rejects_unknown_shell() {
753        assert!(Cli::try_parse_from(["ironflow-cli", "completions", "nushell"]).is_err());
754    }
755
756    #[test]
757    fn parse_man() {
758        let cli = parse(&["ironflow-cli", "man"]);
759        assert!(matches!(cli.command, Commands::Man));
760    }
761
762    #[test]
763    fn completions_bash_output_is_valid() {
764        let mut buf = Vec::new();
765        super::generate_completions(Shell::Bash, &mut buf).unwrap();
766        let output = String::from_utf8(buf).unwrap();
767        assert!(output.contains("ironflow-cli"));
768    }
769
770    #[test]
771    fn man_page_output_is_valid() {
772        let mut buf = Vec::new();
773        super::generate_man_page(&mut buf).unwrap();
774        let output = String::from_utf8(buf).unwrap();
775        assert!(output.contains(".TH"));
776        assert!(output.contains("ironflow-cli"));
777    }
778
779    // ---- template ----
780
781    #[test]
782    fn parse_template_list() {
783        let cli = parse(&[
784            "ironflow-cli",
785            "template",
786            "list",
787            "https://github.com/user/templates",
788        ]);
789        assert!(matches!(cli.command, Commands::Template(_)));
790    }
791
792    #[test]
793    fn parse_template_add_with_from() {
794        let cli = parse(&[
795            "ironflow-cli",
796            "template",
797            "add",
798            "ci-pipeline",
799            "--from",
800            "https://github.com/user/templates",
801        ]);
802        assert!(matches!(cli.command, Commands::Template(_)));
803    }
804
805    #[test]
806    fn parse_template_add_with_output() {
807        let cli = parse(&[
808            "ironflow-cli",
809            "template",
810            "add",
811            "ci-pipeline",
812            "--from",
813            "https://github.com/user/templates",
814            "--output",
815            "my/custom/path",
816        ]);
817        assert!(matches!(cli.command, Commands::Template(_)));
818    }
819
820    #[test]
821    fn parse_template_list_registry() {
822        let cli = parse(&["ironflow-cli", "template", "list", "--registry"]);
823        assert!(matches!(cli.command, Commands::Template(_)));
824    }
825
826    #[test]
827    fn parse_template_update() {
828        let cli = parse(&["ironflow-cli", "template", "update"]);
829        assert!(matches!(cli.command, Commands::Template(_)));
830    }
831
832    #[test]
833    fn parse_template_info() {
834        let cli = parse(&[
835            "ironflow-cli",
836            "template",
837            "info",
838            "https://github.com/user/templates",
839            "ci-pipeline",
840        ]);
841        assert!(matches!(cli.command, Commands::Template(_)));
842    }
843
844    #[test]
845    fn parse_template_requires_subcommand() {
846        let result = Cli::try_parse_from(["ironflow-cli", "template"]);
847        assert!(result.is_err());
848    }
849}