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