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