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::account::AccountArgs;
17use crate::commands::api_key::ApiKeyArgs;
18use crate::commands::audit_log::AuditLogArgs;
19use crate::commands::dashboard::DashboardArgs;
20use crate::commands::delegation::DelegationArgs;
21use crate::commands::init::InitArgs;
22use crate::commands::logs::LogsArgs;
23use crate::commands::run::RunArgs;
24use crate::commands::schedule::ScheduleArgs;
25use crate::commands::secret::SecretArgs;
26use crate::commands::signal::SignalArgs;
27use crate::commands::stats::StatsArgs;
28use crate::commands::template::TemplateArgs;
29use crate::commands::user::UserArgs;
30use crate::commands::workflow::WorkflowArgs;
31
32#[derive(Debug, Parser)]
45#[command(
46 name = "ironflow-cli",
47 version,
48 about = "Drive the Ironflow workflow engine from the terminal"
49)]
50pub struct Cli {
51 #[arg(long, global = true)]
53 pub json: bool,
54
55 #[arg(long, global = true)]
57 pub verbose: bool,
58
59 #[arg(long, global = true, env = "IRONFLOW_URL")]
61 pub url: Option<String>,
62
63 #[arg(long, global = true, env = "IRONFLOW_API_KEY")]
65 pub api_key: Option<String>,
66
67 #[command(subcommand)]
69 pub command: Commands,
70}
71
72#[derive(Debug, Subcommand)]
74pub enum Commands {
75 Run(RunArgs),
77 Workflow(WorkflowArgs),
79 Logs(LogsArgs),
81 Stats(StatsArgs),
83 Secret(SecretArgs),
85 #[command(name = "accounts")]
87 Accounts(AccountArgs),
88 #[command(name = "api-key")]
90 ApiKey(ApiKeyArgs),
91 User(UserArgs),
93 #[command(name = "audit-log")]
95 AuditLog(AuditLogArgs),
96 Schedule(ScheduleArgs),
98 Delegation(DelegationArgs),
100 Signal(SignalArgs),
102 Template(TemplateArgs),
104 Init(InitArgs),
106 Dashboard(DashboardArgs),
108 Completions {
110 shell: Shell,
112 },
113 Man,
115}
116
117pub fn generate_completions(shell: Shell, writer: &mut impl io::Write) -> Result<()> {
135 let mut cmd = Cli::command();
136 clap_complete::generate(shell, &mut cmd, "ironflow-cli", writer);
137 Ok(())
138}
139
140pub fn generate_man_page(writer: &mut impl io::Write) -> Result<()> {
157 let cmd = Cli::command();
158 Man::new(cmd).render(writer)?;
159 Ok(())
160}
161
162pub async fn dispatch(client: &IronflowClient, cli: &Cli) -> Result<()> {
169 match &cli.command {
170 Commands::Run(args) => commands::run::execute(client, args, cli.json, cli.verbose).await,
171 Commands::Workflow(args) => commands::workflow::execute(client, args, cli.json).await,
172 Commands::Logs(args) => commands::logs::execute(client, args, cli.json).await,
173 Commands::Stats(args) => commands::stats::execute(client, args, cli.json).await,
174 Commands::Secret(args) => commands::secret::execute(client, args, cli.json).await,
175 Commands::Accounts(args) => commands::account::execute(client, args, cli.json).await,
176 Commands::ApiKey(args) => commands::api_key::execute(client, args, cli.json).await,
177 Commands::User(args) => commands::user::execute(client, args, cli.json).await,
178 Commands::AuditLog(args) => commands::audit_log::execute(client, args, cli.json).await,
179 Commands::Schedule(args) => commands::schedule::execute(client, args, cli.json).await,
180 Commands::Delegation(args) => commands::delegation::execute(client, args, cli.json).await,
181 Commands::Signal(args) => commands::signal::execute(client, args, cli.json).await,
182 Commands::Template(args) => commands::template::execute(args),
183 Commands::Init(args) => commands::init::execute(args),
184 Commands::Dashboard(args) => commands::dashboard::execute(client, args),
185 Commands::Completions { shell } => generate_completions(*shell, &mut io::stdout()),
186 Commands::Man => generate_man_page(&mut io::stdout()),
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use clap::Parser;
193
194 use crate::commands::account::AccountCommands;
195 use crate::commands::api_key::ApiKeyCommands;
196 use crate::commands::audit_log::AuditLogCommands;
197 use crate::commands::delegation::DelegationCommands;
198 use crate::commands::run::RunCommands;
199 use crate::commands::schedule::{CatchupArg, OverlapArg, ScheduleCommands};
200 use crate::commands::secret::SecretCommands;
201 use crate::commands::signal::SignalCommands;
202 use crate::commands::stats::StatsCommands;
203 use crate::commands::user::UserCommands;
204
205 use super::*;
206
207 const UUID: &str = "01234567-89ab-cdef-0123-456789abcdef";
208
209 fn parse(args: &[&str]) -> Cli {
210 Cli::try_parse_from(args).unwrap()
211 }
212
213 #[test]
214 fn parse_run_list() {
215 let cli = parse(&["ironflow-cli", "run", "list"]);
216 assert!(!cli.json);
217 assert!(matches!(cli.command, Commands::Run(_)));
218 }
219
220 #[test]
221 fn parse_run_list_with_json() {
222 let cli = parse(&["ironflow-cli", "--json", "run", "list"]);
223 assert!(cli.json);
224 }
225
226 #[test]
227 fn parse_run_create_with_payload() {
228 let cli = parse(&[
229 "ironflow-cli",
230 "run",
231 "create",
232 "deploy",
233 "--payload",
234 r#"{"env": "prod"}"#,
235 ]);
236 assert!(matches!(cli.command, Commands::Run(_)));
237 }
238
239 #[test]
240 fn parse_run_create_with_payload_file() {
241 let cli = parse(&[
242 "ironflow-cli",
243 "run",
244 "create",
245 "deploy",
246 "--payload-file",
247 "/tmp/payload.json",
248 ]);
249 assert!(matches!(cli.command, Commands::Run(_)));
250 }
251
252 #[test]
253 fn parse_run_create_with_concurrency_key() {
254 let cli = parse(&[
255 "ironflow-cli",
256 "run",
257 "create",
258 "deploy",
259 "--concurrency-key",
260 "issue:12",
261 ]);
262 let Commands::Run(args) = &cli.command else {
263 panic!("expected Run command");
264 };
265 let RunCommands::Create {
266 concurrency_key, ..
267 } = &args.command
268 else {
269 panic!("expected Create subcommand");
270 };
271 assert_eq!(concurrency_key.as_deref(), Some("issue:12"));
272 }
273
274 #[test]
275 fn parse_run_create_without_concurrency_key() {
276 let cli = parse(&["ironflow-cli", "run", "create", "deploy"]);
277 let Commands::Run(args) = &cli.command else {
278 panic!("expected Run command");
279 };
280 let RunCommands::Create {
281 concurrency_key, ..
282 } = &args.command
283 else {
284 panic!("expected Create subcommand");
285 };
286 assert!(concurrency_key.is_none());
287 }
288
289 #[test]
290 fn parse_run_create_with_repeated_concurrency_limits() {
291 let cli = parse(&[
292 "ironflow-cli",
293 "run",
294 "create",
295 "deploy",
296 "--concurrency-limit",
297 "repo:acme=2",
298 "--concurrency-limit",
299 "tenant:42=1",
300 ]);
301 let Commands::Run(args) = &cli.command else {
302 panic!("expected Run command");
303 };
304 let RunCommands::Create {
305 concurrency_limits, ..
306 } = &args.command
307 else {
308 panic!("expected Create subcommand");
309 };
310 let parsed: Vec<(&str, i32)> = concurrency_limits
311 .iter()
312 .map(|l| (l.group.as_str(), l.limit))
313 .collect();
314 assert_eq!(parsed, vec![("repo:acme", 2), ("tenant:42", 1)]);
315 }
316
317 #[test]
318 fn parse_run_create_without_concurrency_limits() {
319 let cli = parse(&["ironflow-cli", "run", "create", "deploy"]);
320 let Commands::Run(args) = &cli.command else {
321 panic!("expected Run command");
322 };
323 let RunCommands::Create {
324 concurrency_limits, ..
325 } = &args.command
326 else {
327 panic!("expected Create subcommand");
328 };
329 assert!(concurrency_limits.is_empty());
330 }
331
332 #[test]
333 fn parse_run_create_with_repeated_worker_tags() {
334 let cli = parse(&[
335 "ironflow-cli",
336 "run",
337 "create",
338 "deploy",
339 "--worker-tag",
340 "gpu",
341 "--worker-tag",
342 "region:eu",
343 ]);
344 let Commands::Run(args) = &cli.command else {
345 panic!("expected Run command");
346 };
347 let RunCommands::Create { worker_tags, .. } = &args.command else {
348 panic!("expected Create subcommand");
349 };
350 assert_eq!(
351 worker_tags,
352 &vec!["gpu".to_string(), "region:eu".to_string()]
353 );
354 }
355
356 #[test]
357 fn parse_run_create_without_worker_tags() {
358 let cli = parse(&["ironflow-cli", "run", "create", "deploy"]);
359 let Commands::Run(args) = &cli.command else {
360 panic!("expected Run command");
361 };
362 let RunCommands::Create { worker_tags, .. } = &args.command else {
363 panic!("expected Create subcommand");
364 };
365 assert!(worker_tags.is_empty());
366 }
367
368 #[test]
369 fn parse_run_create_rejects_a_malformed_concurrency_limit() {
370 let result = Cli::try_parse_from([
371 "ironflow-cli",
372 "run",
373 "create",
374 "deploy",
375 "--concurrency-limit",
376 "repo:acme",
377 ]);
378 assert!(result.is_err());
379 }
380
381 #[test]
382 fn parse_run_list_with_concurrency_group() {
383 let cli = parse(&[
384 "ironflow-cli",
385 "run",
386 "list",
387 "--concurrency-group",
388 "repo:acme",
389 ]);
390 let Commands::Run(args) = &cli.command else {
391 panic!("expected Run command");
392 };
393 let RunCommands::List {
394 concurrency_group, ..
395 } = &args.command
396 else {
397 panic!("expected List subcommand");
398 };
399 assert_eq!(concurrency_group.as_deref(), Some("repo:acme"));
400 }
401
402 #[test]
403 fn parse_run_create_with_a_negative_priority() {
404 let cli = parse(&[
405 "ironflow-cli",
406 "run",
407 "create",
408 "deploy",
409 "--priority",
410 "-40",
411 ]);
412 let Commands::Run(args) = &cli.command else {
413 panic!("expected Run command");
414 };
415 let RunCommands::Create { priority, .. } = &args.command else {
416 panic!("expected Create subcommand");
417 };
418 assert_eq!(*priority, Some(-40));
419 }
420
421 #[test]
422 fn parse_run_create_without_priority() {
423 let cli = parse(&["ironflow-cli", "run", "create", "deploy"]);
424 let Commands::Run(args) = &cli.command else {
425 panic!("expected Run command");
426 };
427 let RunCommands::Create { priority, .. } = &args.command else {
428 panic!("expected Create subcommand");
429 };
430 assert!(priority.is_none());
431 }
432
433 #[test]
434 fn parse_run_create_rejects_an_out_of_range_priority() {
435 for value in ["101", "-101", "high"] {
436 let result = Cli::try_parse_from([
437 "ironflow-cli",
438 "run",
439 "create",
440 "deploy",
441 "--priority",
442 value,
443 ]);
444 assert!(result.is_err(), "--priority {value} must be rejected");
445 }
446 }
447
448 #[test]
449 fn parse_run_list_with_priority() {
450 let cli = parse(&["ironflow-cli", "run", "list", "--priority", "100"]);
451 let Commands::Run(args) = &cli.command else {
452 panic!("expected Run command");
453 };
454 let RunCommands::List { priority, .. } = &args.command else {
455 panic!("expected List subcommand");
456 };
457 assert_eq!(*priority, Some(100));
458 }
459
460 #[test]
461 fn parse_run_list_rejects_an_out_of_range_priority() {
462 let result = Cli::try_parse_from(["ironflow-cli", "run", "list", "--priority", "-500"]);
463 assert!(result.is_err());
464 }
465
466 #[test]
467 fn parse_schedule_create_with_priority() {
468 let cli = parse(&[
469 "ironflow-cli",
470 "schedule",
471 "create",
472 "deploy",
473 "0 0 * * *",
474 "--priority",
475 "-5",
476 ]);
477 let Commands::Schedule(args) = &cli.command else {
478 panic!("expected Schedule command");
479 };
480 let ScheduleCommands::Create { priority, .. } = &args.command else {
481 panic!("expected Create subcommand");
482 };
483 assert_eq!(*priority, Some(-5));
484 }
485
486 #[test]
487 fn parse_schedule_create_with_catchup_and_timezone() {
488 let cli = parse(&[
489 "ironflow-cli",
490 "schedule",
491 "create",
492 "deploy",
493 "0 9 * * *",
494 "--catchup",
495 "all",
496 "--catchup-max",
497 "24",
498 "--catchup-window",
499 "3600",
500 "--overlap",
501 "skip",
502 "--timezone",
503 "Europe/Paris",
504 ]);
505 let Commands::Schedule(args) = &cli.command else {
506 panic!("expected Schedule command");
507 };
508 let ScheduleCommands::Create {
509 catchup,
510 catchup_max,
511 catchup_window_secs,
512 overlap,
513 timezone,
514 ..
515 } = &args.command
516 else {
517 panic!("expected Create subcommand");
518 };
519 assert_eq!(*catchup, Some(CatchupArg::All));
520 assert_eq!(*catchup_max, Some(24));
521 assert_eq!(*catchup_window_secs, Some(3600));
522 assert_eq!(*overlap, Some(OverlapArg::Skip));
523 assert_eq!(timezone.as_deref(), Some("Europe/Paris"));
524 }
525
526 #[test]
527 fn parse_schedule_create_defaults_leave_the_policy_to_the_server() {
528 let cli = parse(&["ironflow-cli", "schedule", "create", "deploy", "0 9 * * *"]);
529 let Commands::Schedule(args) = &cli.command else {
530 panic!("expected Schedule command");
531 };
532 let ScheduleCommands::Create {
533 catchup,
534 catchup_max,
535 catchup_window_secs,
536 overlap,
537 timezone,
538 ..
539 } = &args.command
540 else {
541 panic!("expected Create subcommand");
542 };
543 assert_eq!(*catchup, None);
544 assert_eq!(*catchup_max, None);
545 assert_eq!(*catchup_window_secs, None);
546 assert_eq!(*overlap, None);
547 assert_eq!(*timezone, None);
548 }
549
550 #[test]
551 fn parse_schedule_create_rejects_catchup_max_zero() {
552 let result = Cli::try_parse_from([
553 "ironflow-cli",
554 "schedule",
555 "create",
556 "deploy",
557 "0 0 * * *",
558 "--catchup-max",
559 "0",
560 ]);
561 assert!(result.is_err());
562 }
563
564 #[test]
565 fn parse_schedule_create_rejects_a_catchup_window_below_a_minute() {
566 let result = Cli::try_parse_from([
567 "ironflow-cli",
568 "schedule",
569 "create",
570 "deploy",
571 "0 0 * * *",
572 "--catchup-window",
573 "59",
574 ]);
575 assert!(result.is_err());
576 }
577
578 #[test]
579 fn parse_schedule_create_rejects_an_unknown_catchup() {
580 let result = Cli::try_parse_from([
581 "ironflow-cli",
582 "schedule",
583 "create",
584 "deploy",
585 "0 0 * * *",
586 "--catchup",
587 "sometimes",
588 ]);
589 assert!(result.is_err());
590 }
591
592 #[test]
593 fn parse_schedule_create_rejects_an_out_of_range_priority() {
594 let result = Cli::try_parse_from([
595 "ironflow-cli",
596 "schedule",
597 "create",
598 "deploy",
599 "0 0 * * *",
600 "--priority",
601 "150",
602 ]);
603 assert!(result.is_err());
604 }
605
606 #[test]
607 fn parse_run_create_payload_and_file_conflict() {
608 let result = Cli::try_parse_from([
609 "ironflow-cli",
610 "run",
611 "create",
612 "deploy",
613 "--payload",
614 "{}",
615 "--payload-file",
616 "/tmp/p.json",
617 ]);
618 assert!(result.is_err());
619 }
620
621 #[test]
622 fn parse_run_get() {
623 let cli = parse(&["ironflow-cli", "run", "get", UUID]);
624 assert!(matches!(cli.command, Commands::Run(_)));
625 }
626
627 #[test]
628 fn parse_run_cancel() {
629 let cli = parse(&["ironflow-cli", "run", "cancel", UUID]);
630 assert!(matches!(cli.command, Commands::Run(_)));
631 }
632
633 #[test]
634 fn parse_run_approve() {
635 let cli = parse(&["ironflow-cli", "run", "approve", UUID]);
636 assert!(matches!(cli.command, Commands::Run(_)));
637 }
638
639 #[test]
640 fn parse_run_reject() {
641 let cli = parse(&["ironflow-cli", "run", "reject", UUID]);
642 assert!(matches!(cli.command, Commands::Run(_)));
643 }
644
645 #[test]
646 fn parse_run_reject_requires_an_id() {
647 assert!(Cli::try_parse_from(["ironflow-cli", "run", "reject"]).is_err());
648 }
649
650 #[test]
651 fn parse_run_retry() {
652 let cli = parse(&["ironflow-cli", "run", "retry", UUID]);
653 assert!(matches!(cli.command, Commands::Run(_)));
654 }
655
656 #[test]
657 fn parse_run_list_with_filters() {
658 let cli = parse(&[
659 "ironflow-cli",
660 "run",
661 "list",
662 "--status",
663 "completed",
664 "--workflow",
665 "deploy",
666 "--page",
667 "2",
668 "--per-page",
669 "50",
670 ]);
671 assert!(matches!(cli.command, Commands::Run(_)));
672 }
673
674 #[test]
675 fn parse_workflow_list() {
676 let cli = parse(&["ironflow-cli", "workflow", "list"]);
677 assert!(matches!(cli.command, Commands::Workflow(_)));
678 }
679
680 #[test]
681 fn parse_workflow_get() {
682 let cli = parse(&["ironflow-cli", "workflow", "get", "deploy"]);
683 assert!(matches!(cli.command, Commands::Workflow(_)));
684 }
685
686 #[test]
687 fn parse_logs() {
688 let cli = parse(&["ironflow-cli", "logs", UUID]);
689 assert!(matches!(cli.command, Commands::Logs(_)));
690 }
691
692 #[test]
693 fn parse_logs_follow() {
694 let cli = parse(&["ironflow-cli", "logs", UUID, "--follow"]);
695 let Commands::Logs(args) = &cli.command else {
696 panic!("expected Logs command");
697 };
698 assert!(args.follow);
699 }
700
701 #[test]
702 fn parse_stats() {
703 let cli = parse(&["ironflow-cli", "stats"]);
704 assert!(matches!(cli.command, Commands::Stats(_)));
705 }
706
707 #[test]
708 fn parse_verbose_flag() {
709 let cli = parse(&["ironflow-cli", "--verbose", "stats"]);
710 assert!(cli.verbose);
711 }
712
713 #[test]
714 fn parse_url_override() {
715 let cli = parse(&[
716 "ironflow-cli",
717 "--url",
718 "https://custom.example.com",
719 "stats",
720 ]);
721 assert_eq!(cli.url.as_deref(), Some("https://custom.example.com"));
722 }
723
724 #[test]
725 fn parse_invalid_uuid_rejected() {
726 assert!(Cli::try_parse_from(["ironflow-cli", "run", "get", "not-a-uuid"]).is_err());
727 }
728
729 #[test]
730 fn parse_no_command_fails() {
731 assert!(Cli::try_parse_from(["ironflow-cli"]).is_err());
732 }
733
734 #[test]
737 fn parse_accounts_add_with_token_stdin() {
738 let cli = parse(&["ironflow-cli", "accounts", "add", "perso", "--token-stdin"]);
739 let Commands::Accounts(args) = &cli.command else {
740 panic!("expected Accounts command");
741 };
742 let AccountCommands::Add {
743 name,
744 kind,
745 token_stdin,
746 ..
747 } = &args.command
748 else {
749 panic!("expected Add subcommand");
750 };
751 assert_eq!(name, "perso");
752 assert_eq!(kind, "claude_subscription");
753 assert!(*token_stdin);
754 }
755
756 #[test]
757 fn parse_accounts_add_requires_token_stdin() {
758 assert!(Cli::try_parse_from(["ironflow-cli", "accounts", "add", "perso"]).is_err());
759 }
760
761 #[test]
762 fn parse_accounts_remove_with_yes() {
763 let cli = parse(&["ironflow-cli", "accounts", "remove", "perso", "--yes"]);
764 let Commands::Accounts(args) = &cli.command else {
765 panic!("expected Accounts command");
766 };
767 let AccountCommands::Remove { account, yes } = &args.command else {
768 panic!("expected Remove subcommand");
769 };
770 assert_eq!(account, "perso");
771 assert!(*yes);
772 }
773
774 #[test]
775 fn parse_accounts_list() {
776 let cli = parse(&["ironflow-cli", "accounts", "list"]);
777 assert!(matches!(cli.command, Commands::Accounts(_)));
778 }
779
780 #[test]
781 fn parse_accounts_update_rejects_enable_and_disable() {
782 let args = [
783 "ironflow-cli",
784 "accounts",
785 "update",
786 "perso",
787 "--enable",
788 "--disable",
789 ];
790 assert!(Cli::try_parse_from(args).is_err());
791 }
792
793 #[test]
796 fn parse_secret_list() {
797 let cli = parse(&["ironflow-cli", "secret", "list"]);
798 assert!(matches!(cli.command, Commands::Secret(_)));
799 }
800
801 #[test]
802 fn parse_secret_set_with_inline_value() {
803 let cli = parse(&["ironflow-cli", "secret", "set", "db/password", "hunter2"]);
804 let Commands::Secret(args) = &cli.command else {
805 panic!("expected Secret command");
806 };
807 let SecretCommands::Set { key, value } = &args.command else {
808 panic!("expected Set subcommand");
809 };
810 assert_eq!(key, "db/password");
811 assert_eq!(value.as_deref(), Some("hunter2"));
812 }
813
814 #[test]
815 fn parse_secret_set_without_value_defers_to_stdin() {
816 let cli = parse(&["ironflow-cli", "secret", "set", "db/password"]);
817 let Commands::Secret(args) = &cli.command else {
818 panic!("expected Secret command");
819 };
820 let SecretCommands::Set { value, .. } = &args.command else {
821 panic!("expected Set subcommand");
822 };
823 assert!(value.is_none());
824 }
825
826 #[test]
827 fn parse_secret_set_requires_a_key() {
828 assert!(Cli::try_parse_from(["ironflow-cli", "secret", "set"]).is_err());
829 }
830
831 #[test]
832 fn parse_secret_update() {
833 let cli = parse(&["ironflow-cli", "secret", "update", "db/password", "new"]);
834 assert!(matches!(cli.command, Commands::Secret(_)));
835 }
836
837 #[test]
838 fn parse_secret_delete_with_yes() {
839 let cli = parse(&["ironflow-cli", "secret", "delete", "db/password", "--yes"]);
840 let Commands::Secret(args) = &cli.command else {
841 panic!("expected Secret command");
842 };
843 let SecretCommands::Delete { yes, .. } = &args.command else {
844 panic!("expected Delete subcommand");
845 };
846 assert!(yes);
847 }
848
849 #[test]
850 fn parse_secret_delete_defaults_to_confirming() {
851 let cli = parse(&["ironflow-cli", "secret", "delete", "db/password"]);
852 let Commands::Secret(args) = &cli.command else {
853 panic!("expected Secret command");
854 };
855 let SecretCommands::Delete { yes, .. } = &args.command else {
856 panic!("expected Delete subcommand");
857 };
858 assert!(!yes);
859 }
860
861 #[test]
862 fn parse_secret_rotate_defaults_to_the_active_version() {
863 let cli = parse(&["ironflow-cli", "secret", "rotate"]);
864 let Commands::Secret(args) = &cli.command else {
865 panic!("expected Secret command");
866 };
867 let SecretCommands::Rotate(rotate) = &args.command else {
868 panic!("expected Rotate subcommand");
869 };
870 assert!(rotate.to_version.is_none());
871 assert_eq!(rotate.batch_size, 100);
872 }
873
874 #[test]
875 fn parse_secret_rotate_with_version_and_batch_size() {
876 let cli = parse(&[
877 "ironflow-cli",
878 "secret",
879 "rotate",
880 "--to-version",
881 "2",
882 "--batch-size",
883 "50",
884 ]);
885 let Commands::Secret(args) = &cli.command else {
886 panic!("expected Secret command");
887 };
888 let SecretCommands::Rotate(rotate) = &args.command else {
889 panic!("expected Rotate subcommand");
890 };
891 assert_eq!(rotate.to_version, Some(2));
892 assert_eq!(rotate.batch_size, 50);
893 }
894
895 #[test]
896 fn parse_secret_rotate_rejects_a_non_positive_version() {
897 let zero = ["ironflow-cli", "secret", "rotate", "--to-version", "0"];
898 let negative = ["ironflow-cli", "secret", "rotate", "--to-version", "-1"];
899 assert!(Cli::try_parse_from(zero).is_err());
900 assert!(Cli::try_parse_from(negative).is_err());
901 }
902
903 #[test]
904 fn parse_secret_rotate_rejects_an_out_of_range_batch_size() {
905 let zero = ["ironflow-cli", "secret", "rotate", "--batch-size", "0"];
906 let too_large = ["ironflow-cli", "secret", "rotate", "--batch-size", "1001"];
907 assert!(Cli::try_parse_from(zero).is_err());
908 assert!(Cli::try_parse_from(too_large).is_err());
909 }
910
911 #[test]
912 fn parse_secret_key_status_takes_no_arguments() {
913 let cli = parse(&["ironflow-cli", "secret", "key-status"]);
914 let Commands::Secret(args) = &cli.command else {
915 panic!("expected Secret command");
916 };
917 assert!(matches!(args.command, SecretCommands::KeyStatus));
918 assert!(Cli::try_parse_from(["ironflow-cli", "secret", "key-status", "extra"]).is_err());
919 }
920
921 #[test]
924 fn parse_api_key_list() {
925 let cli = parse(&["ironflow-cli", "api-key", "list"]);
926 assert!(matches!(cli.command, Commands::ApiKey(_)));
927 }
928
929 #[test]
930 fn parse_api_key_scopes() {
931 let cli = parse(&["ironflow-cli", "api-key", "scopes"]);
932 assert!(matches!(cli.command, Commands::ApiKey(_)));
933 }
934
935 #[test]
936 fn parse_api_key_create_with_several_scopes() {
937 let cli = parse(&[
938 "ironflow-cli",
939 "api-key",
940 "create",
941 "ci",
942 "--scope",
943 "runs_read",
944 "--scope",
945 "runs_write",
946 ]);
947 let Commands::ApiKey(args) = &cli.command else {
948 panic!("expected ApiKey command");
949 };
950 let ApiKeyCommands::Create { scopes, .. } = &args.command else {
951 panic!("expected Create subcommand");
952 };
953 assert_eq!(scopes.len(), 2);
954 }
955
956 #[test]
957 fn parse_api_key_create_requires_a_scope() {
958 assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci"]).is_err());
959 }
960
961 #[test]
962 fn parse_api_key_create_rejects_an_unknown_scope() {
963 let result =
964 Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci", "--scope", "root"]);
965 assert!(result.is_err());
966 }
967
968 #[test]
969 fn parse_api_key_create_with_expiry() {
970 let cli = parse(&[
971 "ironflow-cli",
972 "api-key",
973 "create",
974 "ci",
975 "--scope",
976 "admin",
977 "--expires-at",
978 "2026-12-31T23:59:59Z",
979 ]);
980 assert!(matches!(cli.command, Commands::ApiKey(_)));
981 }
982
983 #[test]
984 fn parse_api_key_create_rejects_a_malformed_expiry() {
985 let result = Cli::try_parse_from([
986 "ironflow-cli",
987 "api-key",
988 "create",
989 "ci",
990 "--scope",
991 "admin",
992 "--expires-at",
993 "tomorrow",
994 ]);
995 assert!(result.is_err());
996 }
997
998 #[test]
999 fn parse_api_key_delete_rejects_a_non_uuid() {
1000 assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "delete", "abc"]).is_err());
1001 }
1002
1003 #[test]
1006 fn parse_user_list() {
1007 let cli = parse(&["ironflow-cli", "user", "list"]);
1008 assert!(matches!(cli.command, Commands::User(_)));
1009 }
1010
1011 #[test]
1012 fn parse_user_create() {
1013 let cli = parse(&[
1014 "ironflow-cli",
1015 "user",
1016 "create",
1017 "alice",
1018 "--email",
1019 "alice@example.com",
1020 "--password",
1021 "hunter2hunter2",
1022 "--admin",
1023 ]);
1024 let Commands::User(args) = &cli.command else {
1025 panic!("expected User command");
1026 };
1027 let UserCommands::Create { admin, .. } = &args.command else {
1028 panic!("expected Create subcommand");
1029 };
1030 assert!(admin);
1031 }
1032
1033 #[test]
1034 fn parse_user_create_requires_an_email() {
1035 assert!(Cli::try_parse_from(["ironflow-cli", "user", "create", "alice"]).is_err());
1036 }
1037
1038 #[test]
1039 fn parse_user_set_role_admin() {
1040 let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--admin"]);
1041 let Commands::User(args) = &cli.command else {
1042 panic!("expected User command");
1043 };
1044 let UserCommands::SetRole { admin, member, .. } = &args.command else {
1045 panic!("expected SetRole subcommand");
1046 };
1047 assert!(admin);
1048 assert!(!member);
1049 }
1050
1051 #[test]
1052 fn parse_user_set_role_member() {
1053 let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--member"]);
1054 let Commands::User(args) = &cli.command else {
1055 panic!("expected User command");
1056 };
1057 let UserCommands::SetRole { admin, .. } = &args.command else {
1058 panic!("expected SetRole subcommand");
1059 };
1060 assert!(!admin);
1061 }
1062
1063 #[test]
1064 fn parse_user_set_role_requires_a_role() {
1065 assert!(Cli::try_parse_from(["ironflow-cli", "user", "set-role", UUID]).is_err());
1066 }
1067
1068 #[test]
1069 fn parse_user_set_role_rejects_both_roles() {
1070 let result = Cli::try_parse_from([
1071 "ironflow-cli",
1072 "user",
1073 "set-role",
1074 UUID,
1075 "--admin",
1076 "--member",
1077 ]);
1078 assert!(result.is_err());
1079 }
1080
1081 #[test]
1084 fn parse_audit_log_list_without_filters() {
1085 let cli = parse(&["ironflow-cli", "audit-log", "list"]);
1086 assert!(matches!(cli.command, Commands::AuditLog(_)));
1087 }
1088
1089 #[test]
1090 fn parse_audit_log_list_with_every_filter() {
1091 let cli = parse(&[
1092 "ironflow-cli",
1093 "audit-log",
1094 "list",
1095 "--run",
1096 UUID,
1097 "--type",
1098 "run_created",
1099 "--from",
1100 "2026-01-01T00:00:00Z",
1101 "--to",
1102 "2026-12-31T23:59:59Z",
1103 "--page",
1104 "2",
1105 "--per-page",
1106 "10",
1107 ]);
1108 let Commands::AuditLog(args) = &cli.command else {
1109 panic!("expected AuditLog command");
1110 };
1111 let AuditLogCommands::List {
1112 run,
1113 event_type,
1114 from,
1115 to,
1116 page,
1117 per_page,
1118 } = &args.command;
1119 assert!(run.is_some());
1120 assert!(event_type.is_some());
1121 assert!(from.is_some());
1122 assert!(to.is_some());
1123 assert_eq!(*page, Some(2));
1124 assert_eq!(*per_page, Some(10));
1125 }
1126
1127 #[test]
1128 fn parse_audit_log_list_rejects_an_unknown_type() {
1129 let result =
1130 Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--type", "exploded"]);
1131 assert!(result.is_err());
1132 }
1133
1134 #[test]
1135 fn parse_audit_log_list_rejects_a_malformed_date() {
1136 let result = Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--from", "hier"]);
1137 assert!(result.is_err());
1138 }
1139
1140 #[test]
1143 fn parse_delegation_list() {
1144 let cli = parse(&["ironflow-cli", "delegation", "list"]);
1145 assert!(matches!(cli.command, Commands::Delegation(_)));
1146 }
1147
1148 #[test]
1149 fn parse_delegation_list_with_every_flag() {
1150 let cli = parse(&[
1151 "ironflow-cli",
1152 "delegation",
1153 "list",
1154 "--from-user",
1155 UUID,
1156 "--to-user",
1157 UUID,
1158 "--page",
1159 "2",
1160 "--per-page",
1161 "10",
1162 ]);
1163 let Commands::Delegation(args) = &cli.command else {
1164 panic!("expected Delegation command");
1165 };
1166 let DelegationCommands::List {
1167 from_user,
1168 to_user,
1169 page,
1170 per_page,
1171 } = &args.command
1172 else {
1173 panic!("expected List subcommand");
1174 };
1175 assert!(from_user.is_some());
1176 assert!(to_user.is_some());
1177 assert_eq!(*page, Some(2));
1178 assert_eq!(*per_page, Some(10));
1179 }
1180
1181 #[test]
1182 fn parse_delegation_create_with_every_flag() {
1183 let cli = parse(&[
1184 "ironflow-cli",
1185 "delegation",
1186 "create",
1187 UUID,
1188 "--until",
1189 "2026-12-31T23:59:59Z",
1190 "--from",
1191 "2026-12-01T00:00:00Z",
1192 "--workflow",
1193 "deploy-*",
1194 ]);
1195 let Commands::Delegation(args) = &cli.command else {
1196 panic!("expected Delegation command");
1197 };
1198 let DelegationCommands::Create {
1199 until,
1200 from,
1201 workflow,
1202 ..
1203 } = &args.command
1204 else {
1205 panic!("expected Create subcommand");
1206 };
1207 assert_eq!(until, "2026-12-31T23:59:59Z");
1208 assert_eq!(from.as_deref(), Some("2026-12-01T00:00:00Z"));
1209 assert_eq!(workflow.as_deref(), Some("deploy-*"));
1210 }
1211
1212 #[test]
1213 fn parse_delegation_create_requires_an_until() {
1214 assert!(Cli::try_parse_from(["ironflow-cli", "delegation", "create", UUID]).is_err());
1215 }
1216
1217 #[test]
1218 fn parse_delegation_create_rejects_a_non_uuid_target() {
1219 let result = Cli::try_parse_from([
1220 "ironflow-cli",
1221 "delegation",
1222 "create",
1223 "alice",
1224 "--until",
1225 "2026-12-31T23:59:59Z",
1226 ]);
1227 assert!(result.is_err());
1228 }
1229
1230 #[test]
1231 fn parse_delegation_delete_defaults_to_confirming() {
1232 let cli = parse(&["ironflow-cli", "delegation", "delete", UUID]);
1233 let Commands::Delegation(args) = &cli.command else {
1234 panic!("expected Delegation command");
1235 };
1236 let DelegationCommands::Delete { yes, .. } = &args.command else {
1237 panic!("expected Delete subcommand");
1238 };
1239 assert!(!yes);
1240 }
1241
1242 #[test]
1245 fn parse_completions_bash() {
1246 let cli = parse(&["ironflow-cli", "completions", "bash"]);
1247 let Commands::Completions { shell } = &cli.command else {
1248 panic!("expected Completions command");
1249 };
1250 assert_eq!(*shell, Shell::Bash);
1251 }
1252
1253 #[test]
1254 fn parse_completions_zsh() {
1255 let cli = parse(&["ironflow-cli", "completions", "zsh"]);
1256 let Commands::Completions { shell } = &cli.command else {
1257 panic!("expected Completions command");
1258 };
1259 assert_eq!(*shell, Shell::Zsh);
1260 }
1261
1262 #[test]
1263 fn parse_completions_fish() {
1264 let cli = parse(&["ironflow-cli", "completions", "fish"]);
1265 let Commands::Completions { shell } = &cli.command else {
1266 panic!("expected Completions command");
1267 };
1268 assert_eq!(*shell, Shell::Fish);
1269 }
1270
1271 #[test]
1272 fn parse_completions_powershell() {
1273 let cli = parse(&["ironflow-cli", "completions", "powershell"]);
1274 let Commands::Completions { shell } = &cli.command else {
1275 panic!("expected Completions command");
1276 };
1277 assert_eq!(*shell, Shell::PowerShell);
1278 }
1279
1280 #[test]
1281 fn parse_completions_requires_shell() {
1282 assert!(Cli::try_parse_from(["ironflow-cli", "completions"]).is_err());
1283 }
1284
1285 #[test]
1286 fn parse_completions_rejects_unknown_shell() {
1287 assert!(Cli::try_parse_from(["ironflow-cli", "completions", "nushell"]).is_err());
1288 }
1289
1290 #[test]
1291 fn parse_man() {
1292 let cli = parse(&["ironflow-cli", "man"]);
1293 assert!(matches!(cli.command, Commands::Man));
1294 }
1295
1296 #[test]
1297 fn completions_bash_output_is_valid() {
1298 let mut buf = Vec::new();
1299 super::generate_completions(Shell::Bash, &mut buf).unwrap();
1300 let output = String::from_utf8(buf).unwrap();
1301 assert!(output.contains("ironflow-cli"));
1302 }
1303
1304 #[test]
1305 fn man_page_output_is_valid() {
1306 let mut buf = Vec::new();
1307 super::generate_man_page(&mut buf).unwrap();
1308 let output = String::from_utf8(buf).unwrap();
1309 assert!(output.contains(".TH"));
1310 assert!(output.contains("ironflow-cli"));
1311 }
1312
1313 #[test]
1316 fn parse_template_list() {
1317 let cli = parse(&[
1318 "ironflow-cli",
1319 "template",
1320 "list",
1321 "https://github.com/user/templates",
1322 ]);
1323 assert!(matches!(cli.command, Commands::Template(_)));
1324 }
1325
1326 #[test]
1327 fn parse_template_add_with_from() {
1328 let cli = parse(&[
1329 "ironflow-cli",
1330 "template",
1331 "add",
1332 "ci-pipeline",
1333 "--from",
1334 "https://github.com/user/templates",
1335 ]);
1336 assert!(matches!(cli.command, Commands::Template(_)));
1337 }
1338
1339 #[test]
1340 fn parse_template_add_with_output() {
1341 let cli = parse(&[
1342 "ironflow-cli",
1343 "template",
1344 "add",
1345 "ci-pipeline",
1346 "--from",
1347 "https://github.com/user/templates",
1348 "--output",
1349 "my/custom/path",
1350 ]);
1351 assert!(matches!(cli.command, Commands::Template(_)));
1352 }
1353
1354 #[test]
1355 fn parse_template_list_registry() {
1356 let cli = parse(&["ironflow-cli", "template", "list", "--registry"]);
1357 assert!(matches!(cli.command, Commands::Template(_)));
1358 }
1359
1360 #[test]
1361 fn parse_template_update() {
1362 let cli = parse(&["ironflow-cli", "template", "update"]);
1363 assert!(matches!(cli.command, Commands::Template(_)));
1364 }
1365
1366 #[test]
1367 fn parse_template_info() {
1368 let cli = parse(&[
1369 "ironflow-cli",
1370 "template",
1371 "info",
1372 "https://github.com/user/templates",
1373 "ci-pipeline",
1374 ]);
1375 assert!(matches!(cli.command, Commands::Template(_)));
1376 }
1377
1378 #[test]
1379 fn parse_template_requires_subcommand() {
1380 let result = Cli::try_parse_from(["ironflow-cli", "template"]);
1381 assert!(result.is_err());
1382 }
1383
1384 #[test]
1387 fn parse_signal_send_with_every_flag() {
1388 let cli = parse(&[
1389 "ironflow-cli",
1390 "signal",
1391 "send",
1392 "ci.pipeline_finished",
1393 "--key",
1394 "4f2a9c1",
1395 "--payload",
1396 r#"{"status":"success"}"#,
1397 "--idempotency-id",
1398 "delivery-42",
1399 ]);
1400 let Commands::Signal(args) = &cli.command else {
1401 panic!("expected Signal command");
1402 };
1403 let SignalCommands::Send {
1404 name,
1405 key,
1406 payload,
1407 idempotency_id,
1408 } = &args.command
1409 else {
1410 panic!("expected Send subcommand");
1411 };
1412 assert_eq!(name, "ci.pipeline_finished");
1413 assert_eq!(key, "4f2a9c1");
1414 assert_eq!(payload.as_deref(), Some(r#"{"status":"success"}"#));
1415 assert_eq!(idempotency_id.as_deref(), Some("delivery-42"));
1416 }
1417
1418 #[test]
1419 fn parse_signal_send_requires_a_key() {
1420 let result = Cli::try_parse_from(["ironflow-cli", "signal", "send", "demo.done"]);
1421 assert!(result.is_err());
1422 }
1423
1424 #[test]
1425 fn parse_signal_list_with_filters() {
1426 let cli = parse(&[
1427 "ironflow-cli",
1428 "signal",
1429 "list",
1430 "--name",
1431 "demo.done",
1432 "--key",
1433 "k1",
1434 "--page",
1435 "2",
1436 "--per-page",
1437 "10",
1438 ]);
1439 let Commands::Signal(args) = &cli.command else {
1440 panic!("expected Signal command");
1441 };
1442 let SignalCommands::List {
1443 name,
1444 key,
1445 page,
1446 per_page,
1447 } = &args.command
1448 else {
1449 panic!("expected List subcommand");
1450 };
1451 assert_eq!(name.as_deref(), Some("demo.done"));
1452 assert_eq!(key.as_deref(), Some("k1"));
1453 assert_eq!(*page, Some(2));
1454 assert_eq!(*per_page, Some(10));
1455 }
1456
1457 #[test]
1458 fn parse_run_steps_by_role() {
1459 let cli = parse(&["ironflow-cli", "run", "steps", "--role", "reviewer"]);
1460 let Commands::Run(args) = &cli.command else {
1461 panic!("expected Run command");
1462 };
1463 let RunCommands::Steps {
1464 role,
1465 page,
1466 per_page,
1467 } = &args.command
1468 else {
1469 panic!("expected Steps subcommand");
1470 };
1471 assert_eq!(role, "reviewer");
1472 assert_eq!(*page, 1);
1473 assert_eq!(*per_page, 20);
1474 }
1475
1476 #[test]
1477 fn parse_run_steps_requires_a_role() {
1478 let result = Cli::try_parse_from(["ironflow-cli", "run", "steps"]);
1479 assert!(result.is_err());
1480 }
1481
1482 #[test]
1483 fn parse_stats_roles_with_period() {
1484 let cli = parse(&["ironflow-cli", "stats", "roles", "--period", "30d"]);
1485 let Commands::Stats(args) = &cli.command else {
1486 panic!("expected Stats command");
1487 };
1488 let Some(StatsCommands::Roles { period }) = &args.command else {
1489 panic!("expected Roles subcommand");
1490 };
1491 assert_eq!(period, "30d");
1492 }
1493
1494 #[test]
1495 fn parse_stats_roles_defaults_to_7d() {
1496 let cli = parse(&["ironflow-cli", "stats", "roles"]);
1497 let Commands::Stats(args) = &cli.command else {
1498 panic!("expected Stats command");
1499 };
1500 let Some(StatsCommands::Roles { period }) = &args.command else {
1501 panic!("expected Roles subcommand");
1502 };
1503 assert_eq!(period, "7d");
1504 }
1505}