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