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