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