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