1use 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#[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 #[arg(long, global = true)]
47 pub json: bool,
48
49 #[arg(long, global = true)]
51 pub verbose: bool,
52
53 #[arg(long, global = true, env = "IRONFLOW_URL")]
55 pub url: Option<String>,
56
57 #[arg(long, global = true, env = "IRONFLOW_API_KEY")]
59 pub api_key: Option<String>,
60
61 #[command(subcommand)]
63 pub command: Commands,
64}
65
66#[derive(Debug, Subcommand)]
68pub enum Commands {
69 Run(RunArgs),
71 Workflow(WorkflowArgs),
73 Logs(LogsArgs),
75 Stats,
77 Secret(SecretArgs),
79 #[command(name = "api-key")]
81 ApiKey(ApiKeyArgs),
82 User(UserArgs),
84 #[command(name = "audit-log")]
86 AuditLog(AuditLogArgs),
87 Schedule(ScheduleArgs),
89 Template(TemplateArgs),
91 Completions {
93 shell: Shell,
95 },
96 Man,
98}
99
100pub 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
123pub 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
145pub 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 #[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 #[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 #[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 #[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 #[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 #[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}