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