1use 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#[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 #[arg(long, global = true)]
41 pub json: bool,
42
43 #[arg(long, global = true)]
45 pub verbose: bool,
46
47 #[arg(long, global = true, env = "IRONFLOW_URL")]
49 pub url: Option<String>,
50
51 #[arg(long, global = true, env = "IRONFLOW_API_KEY")]
53 pub api_key: Option<String>,
54
55 #[command(subcommand)]
57 pub command: Commands,
58}
59
60#[derive(Debug, Subcommand)]
62pub enum Commands {
63 Run(RunArgs),
65 Workflow(WorkflowArgs),
67 Logs(LogsArgs),
69 Stats,
71 Secret(SecretArgs),
73 #[command(name = "api-key")]
75 ApiKey(ApiKeyArgs),
76 User(UserArgs),
78 #[command(name = "audit-log")]
80 AuditLog(AuditLogArgs),
81}
82
83pub 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 #[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 #[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 #[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 #[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}