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