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