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