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