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::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_rejects_an_out_of_range_priority() {
487 let result = Cli::try_parse_from([
488 "ironflow-cli",
489 "schedule",
490 "create",
491 "deploy",
492 "0 0 * * *",
493 "--priority",
494 "150",
495 ]);
496 assert!(result.is_err());
497 }
498
499 #[test]
500 fn parse_run_create_payload_and_file_conflict() {
501 let result = Cli::try_parse_from([
502 "ironflow-cli",
503 "run",
504 "create",
505 "deploy",
506 "--payload",
507 "{}",
508 "--payload-file",
509 "/tmp/p.json",
510 ]);
511 assert!(result.is_err());
512 }
513
514 #[test]
515 fn parse_run_get() {
516 let cli = parse(&["ironflow-cli", "run", "get", UUID]);
517 assert!(matches!(cli.command, Commands::Run(_)));
518 }
519
520 #[test]
521 fn parse_run_cancel() {
522 let cli = parse(&["ironflow-cli", "run", "cancel", UUID]);
523 assert!(matches!(cli.command, Commands::Run(_)));
524 }
525
526 #[test]
527 fn parse_run_approve() {
528 let cli = parse(&["ironflow-cli", "run", "approve", UUID]);
529 assert!(matches!(cli.command, Commands::Run(_)));
530 }
531
532 #[test]
533 fn parse_run_reject() {
534 let cli = parse(&["ironflow-cli", "run", "reject", UUID]);
535 assert!(matches!(cli.command, Commands::Run(_)));
536 }
537
538 #[test]
539 fn parse_run_reject_requires_an_id() {
540 assert!(Cli::try_parse_from(["ironflow-cli", "run", "reject"]).is_err());
541 }
542
543 #[test]
544 fn parse_run_retry() {
545 let cli = parse(&["ironflow-cli", "run", "retry", UUID]);
546 assert!(matches!(cli.command, Commands::Run(_)));
547 }
548
549 #[test]
550 fn parse_run_list_with_filters() {
551 let cli = parse(&[
552 "ironflow-cli",
553 "run",
554 "list",
555 "--status",
556 "completed",
557 "--workflow",
558 "deploy",
559 "--page",
560 "2",
561 "--per-page",
562 "50",
563 ]);
564 assert!(matches!(cli.command, Commands::Run(_)));
565 }
566
567 #[test]
568 fn parse_workflow_list() {
569 let cli = parse(&["ironflow-cli", "workflow", "list"]);
570 assert!(matches!(cli.command, Commands::Workflow(_)));
571 }
572
573 #[test]
574 fn parse_workflow_get() {
575 let cli = parse(&["ironflow-cli", "workflow", "get", "deploy"]);
576 assert!(matches!(cli.command, Commands::Workflow(_)));
577 }
578
579 #[test]
580 fn parse_logs() {
581 let cli = parse(&["ironflow-cli", "logs", UUID]);
582 assert!(matches!(cli.command, Commands::Logs(_)));
583 }
584
585 #[test]
586 fn parse_logs_follow() {
587 let cli = parse(&["ironflow-cli", "logs", UUID, "--follow"]);
588 let Commands::Logs(args) = &cli.command else {
589 panic!("expected Logs command");
590 };
591 assert!(args.follow);
592 }
593
594 #[test]
595 fn parse_stats() {
596 let cli = parse(&["ironflow-cli", "stats"]);
597 assert!(matches!(cli.command, Commands::Stats(_)));
598 }
599
600 #[test]
601 fn parse_verbose_flag() {
602 let cli = parse(&["ironflow-cli", "--verbose", "stats"]);
603 assert!(cli.verbose);
604 }
605
606 #[test]
607 fn parse_url_override() {
608 let cli = parse(&[
609 "ironflow-cli",
610 "--url",
611 "https://custom.example.com",
612 "stats",
613 ]);
614 assert_eq!(cli.url.as_deref(), Some("https://custom.example.com"));
615 }
616
617 #[test]
618 fn parse_invalid_uuid_rejected() {
619 assert!(Cli::try_parse_from(["ironflow-cli", "run", "get", "not-a-uuid"]).is_err());
620 }
621
622 #[test]
623 fn parse_no_command_fails() {
624 assert!(Cli::try_parse_from(["ironflow-cli"]).is_err());
625 }
626
627 #[test]
630 fn parse_accounts_add_with_token_stdin() {
631 let cli = parse(&["ironflow-cli", "accounts", "add", "perso", "--token-stdin"]);
632 let Commands::Accounts(args) = &cli.command else {
633 panic!("expected Accounts command");
634 };
635 let AccountCommands::Add {
636 name,
637 kind,
638 token_stdin,
639 ..
640 } = &args.command
641 else {
642 panic!("expected Add subcommand");
643 };
644 assert_eq!(name, "perso");
645 assert_eq!(kind, "claude_subscription");
646 assert!(*token_stdin);
647 }
648
649 #[test]
650 fn parse_accounts_add_requires_token_stdin() {
651 assert!(Cli::try_parse_from(["ironflow-cli", "accounts", "add", "perso"]).is_err());
652 }
653
654 #[test]
655 fn parse_accounts_remove_with_yes() {
656 let cli = parse(&["ironflow-cli", "accounts", "remove", "perso", "--yes"]);
657 let Commands::Accounts(args) = &cli.command else {
658 panic!("expected Accounts command");
659 };
660 let AccountCommands::Remove { account, yes } = &args.command else {
661 panic!("expected Remove subcommand");
662 };
663 assert_eq!(account, "perso");
664 assert!(*yes);
665 }
666
667 #[test]
668 fn parse_accounts_list() {
669 let cli = parse(&["ironflow-cli", "accounts", "list"]);
670 assert!(matches!(cli.command, Commands::Accounts(_)));
671 }
672
673 #[test]
674 fn parse_accounts_update_rejects_enable_and_disable() {
675 let args = [
676 "ironflow-cli",
677 "accounts",
678 "update",
679 "perso",
680 "--enable",
681 "--disable",
682 ];
683 assert!(Cli::try_parse_from(args).is_err());
684 }
685
686 #[test]
689 fn parse_secret_list() {
690 let cli = parse(&["ironflow-cli", "secret", "list"]);
691 assert!(matches!(cli.command, Commands::Secret(_)));
692 }
693
694 #[test]
695 fn parse_secret_set_with_inline_value() {
696 let cli = parse(&["ironflow-cli", "secret", "set", "db/password", "hunter2"]);
697 let Commands::Secret(args) = &cli.command else {
698 panic!("expected Secret command");
699 };
700 let SecretCommands::Set { key, value } = &args.command else {
701 panic!("expected Set subcommand");
702 };
703 assert_eq!(key, "db/password");
704 assert_eq!(value.as_deref(), Some("hunter2"));
705 }
706
707 #[test]
708 fn parse_secret_set_without_value_defers_to_stdin() {
709 let cli = parse(&["ironflow-cli", "secret", "set", "db/password"]);
710 let Commands::Secret(args) = &cli.command else {
711 panic!("expected Secret command");
712 };
713 let SecretCommands::Set { value, .. } = &args.command else {
714 panic!("expected Set subcommand");
715 };
716 assert!(value.is_none());
717 }
718
719 #[test]
720 fn parse_secret_set_requires_a_key() {
721 assert!(Cli::try_parse_from(["ironflow-cli", "secret", "set"]).is_err());
722 }
723
724 #[test]
725 fn parse_secret_update() {
726 let cli = parse(&["ironflow-cli", "secret", "update", "db/password", "new"]);
727 assert!(matches!(cli.command, Commands::Secret(_)));
728 }
729
730 #[test]
731 fn parse_secret_delete_with_yes() {
732 let cli = parse(&["ironflow-cli", "secret", "delete", "db/password", "--yes"]);
733 let Commands::Secret(args) = &cli.command else {
734 panic!("expected Secret command");
735 };
736 let SecretCommands::Delete { yes, .. } = &args.command else {
737 panic!("expected Delete subcommand");
738 };
739 assert!(yes);
740 }
741
742 #[test]
743 fn parse_secret_delete_defaults_to_confirming() {
744 let cli = parse(&["ironflow-cli", "secret", "delete", "db/password"]);
745 let Commands::Secret(args) = &cli.command else {
746 panic!("expected Secret command");
747 };
748 let SecretCommands::Delete { yes, .. } = &args.command else {
749 panic!("expected Delete subcommand");
750 };
751 assert!(!yes);
752 }
753
754 #[test]
755 fn parse_secret_rotate_defaults_to_the_active_version() {
756 let cli = parse(&["ironflow-cli", "secret", "rotate"]);
757 let Commands::Secret(args) = &cli.command else {
758 panic!("expected Secret command");
759 };
760 let SecretCommands::Rotate(rotate) = &args.command else {
761 panic!("expected Rotate subcommand");
762 };
763 assert!(rotate.to_version.is_none());
764 assert_eq!(rotate.batch_size, 100);
765 }
766
767 #[test]
768 fn parse_secret_rotate_with_version_and_batch_size() {
769 let cli = parse(&[
770 "ironflow-cli",
771 "secret",
772 "rotate",
773 "--to-version",
774 "2",
775 "--batch-size",
776 "50",
777 ]);
778 let Commands::Secret(args) = &cli.command else {
779 panic!("expected Secret command");
780 };
781 let SecretCommands::Rotate(rotate) = &args.command else {
782 panic!("expected Rotate subcommand");
783 };
784 assert_eq!(rotate.to_version, Some(2));
785 assert_eq!(rotate.batch_size, 50);
786 }
787
788 #[test]
789 fn parse_secret_rotate_rejects_a_non_positive_version() {
790 let zero = ["ironflow-cli", "secret", "rotate", "--to-version", "0"];
791 let negative = ["ironflow-cli", "secret", "rotate", "--to-version", "-1"];
792 assert!(Cli::try_parse_from(zero).is_err());
793 assert!(Cli::try_parse_from(negative).is_err());
794 }
795
796 #[test]
797 fn parse_secret_rotate_rejects_an_out_of_range_batch_size() {
798 let zero = ["ironflow-cli", "secret", "rotate", "--batch-size", "0"];
799 let too_large = ["ironflow-cli", "secret", "rotate", "--batch-size", "1001"];
800 assert!(Cli::try_parse_from(zero).is_err());
801 assert!(Cli::try_parse_from(too_large).is_err());
802 }
803
804 #[test]
805 fn parse_secret_key_status_takes_no_arguments() {
806 let cli = parse(&["ironflow-cli", "secret", "key-status"]);
807 let Commands::Secret(args) = &cli.command else {
808 panic!("expected Secret command");
809 };
810 assert!(matches!(args.command, SecretCommands::KeyStatus));
811 assert!(Cli::try_parse_from(["ironflow-cli", "secret", "key-status", "extra"]).is_err());
812 }
813
814 #[test]
817 fn parse_api_key_list() {
818 let cli = parse(&["ironflow-cli", "api-key", "list"]);
819 assert!(matches!(cli.command, Commands::ApiKey(_)));
820 }
821
822 #[test]
823 fn parse_api_key_scopes() {
824 let cli = parse(&["ironflow-cli", "api-key", "scopes"]);
825 assert!(matches!(cli.command, Commands::ApiKey(_)));
826 }
827
828 #[test]
829 fn parse_api_key_create_with_several_scopes() {
830 let cli = parse(&[
831 "ironflow-cli",
832 "api-key",
833 "create",
834 "ci",
835 "--scope",
836 "runs_read",
837 "--scope",
838 "runs_write",
839 ]);
840 let Commands::ApiKey(args) = &cli.command else {
841 panic!("expected ApiKey command");
842 };
843 let ApiKeyCommands::Create { scopes, .. } = &args.command else {
844 panic!("expected Create subcommand");
845 };
846 assert_eq!(scopes.len(), 2);
847 }
848
849 #[test]
850 fn parse_api_key_create_requires_a_scope() {
851 assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci"]).is_err());
852 }
853
854 #[test]
855 fn parse_api_key_create_rejects_an_unknown_scope() {
856 let result =
857 Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci", "--scope", "root"]);
858 assert!(result.is_err());
859 }
860
861 #[test]
862 fn parse_api_key_create_with_expiry() {
863 let cli = parse(&[
864 "ironflow-cli",
865 "api-key",
866 "create",
867 "ci",
868 "--scope",
869 "admin",
870 "--expires-at",
871 "2026-12-31T23:59:59Z",
872 ]);
873 assert!(matches!(cli.command, Commands::ApiKey(_)));
874 }
875
876 #[test]
877 fn parse_api_key_create_rejects_a_malformed_expiry() {
878 let result = Cli::try_parse_from([
879 "ironflow-cli",
880 "api-key",
881 "create",
882 "ci",
883 "--scope",
884 "admin",
885 "--expires-at",
886 "tomorrow",
887 ]);
888 assert!(result.is_err());
889 }
890
891 #[test]
892 fn parse_api_key_delete_rejects_a_non_uuid() {
893 assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "delete", "abc"]).is_err());
894 }
895
896 #[test]
899 fn parse_user_list() {
900 let cli = parse(&["ironflow-cli", "user", "list"]);
901 assert!(matches!(cli.command, Commands::User(_)));
902 }
903
904 #[test]
905 fn parse_user_create() {
906 let cli = parse(&[
907 "ironflow-cli",
908 "user",
909 "create",
910 "alice",
911 "--email",
912 "alice@example.com",
913 "--password",
914 "hunter2hunter2",
915 "--admin",
916 ]);
917 let Commands::User(args) = &cli.command else {
918 panic!("expected User command");
919 };
920 let UserCommands::Create { admin, .. } = &args.command else {
921 panic!("expected Create subcommand");
922 };
923 assert!(admin);
924 }
925
926 #[test]
927 fn parse_user_create_requires_an_email() {
928 assert!(Cli::try_parse_from(["ironflow-cli", "user", "create", "alice"]).is_err());
929 }
930
931 #[test]
932 fn parse_user_set_role_admin() {
933 let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--admin"]);
934 let Commands::User(args) = &cli.command else {
935 panic!("expected User command");
936 };
937 let UserCommands::SetRole { admin, member, .. } = &args.command else {
938 panic!("expected SetRole subcommand");
939 };
940 assert!(admin);
941 assert!(!member);
942 }
943
944 #[test]
945 fn parse_user_set_role_member() {
946 let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--member"]);
947 let Commands::User(args) = &cli.command else {
948 panic!("expected User command");
949 };
950 let UserCommands::SetRole { admin, .. } = &args.command else {
951 panic!("expected SetRole subcommand");
952 };
953 assert!(!admin);
954 }
955
956 #[test]
957 fn parse_user_set_role_requires_a_role() {
958 assert!(Cli::try_parse_from(["ironflow-cli", "user", "set-role", UUID]).is_err());
959 }
960
961 #[test]
962 fn parse_user_set_role_rejects_both_roles() {
963 let result = Cli::try_parse_from([
964 "ironflow-cli",
965 "user",
966 "set-role",
967 UUID,
968 "--admin",
969 "--member",
970 ]);
971 assert!(result.is_err());
972 }
973
974 #[test]
977 fn parse_audit_log_list_without_filters() {
978 let cli = parse(&["ironflow-cli", "audit-log", "list"]);
979 assert!(matches!(cli.command, Commands::AuditLog(_)));
980 }
981
982 #[test]
983 fn parse_audit_log_list_with_every_filter() {
984 let cli = parse(&[
985 "ironflow-cli",
986 "audit-log",
987 "list",
988 "--run",
989 UUID,
990 "--type",
991 "run_created",
992 "--from",
993 "2026-01-01T00:00:00Z",
994 "--to",
995 "2026-12-31T23:59:59Z",
996 "--page",
997 "2",
998 "--per-page",
999 "10",
1000 ]);
1001 let Commands::AuditLog(args) = &cli.command else {
1002 panic!("expected AuditLog command");
1003 };
1004 let AuditLogCommands::List {
1005 run,
1006 event_type,
1007 from,
1008 to,
1009 page,
1010 per_page,
1011 } = &args.command;
1012 assert!(run.is_some());
1013 assert!(event_type.is_some());
1014 assert!(from.is_some());
1015 assert!(to.is_some());
1016 assert_eq!(*page, Some(2));
1017 assert_eq!(*per_page, Some(10));
1018 }
1019
1020 #[test]
1021 fn parse_audit_log_list_rejects_an_unknown_type() {
1022 let result =
1023 Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--type", "exploded"]);
1024 assert!(result.is_err());
1025 }
1026
1027 #[test]
1028 fn parse_audit_log_list_rejects_a_malformed_date() {
1029 let result = Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--from", "hier"]);
1030 assert!(result.is_err());
1031 }
1032
1033 #[test]
1036 fn parse_delegation_list() {
1037 let cli = parse(&["ironflow-cli", "delegation", "list"]);
1038 assert!(matches!(cli.command, Commands::Delegation(_)));
1039 }
1040
1041 #[test]
1042 fn parse_delegation_list_with_every_flag() {
1043 let cli = parse(&[
1044 "ironflow-cli",
1045 "delegation",
1046 "list",
1047 "--from-user",
1048 UUID,
1049 "--to-user",
1050 UUID,
1051 "--page",
1052 "2",
1053 "--per-page",
1054 "10",
1055 ]);
1056 let Commands::Delegation(args) = &cli.command else {
1057 panic!("expected Delegation command");
1058 };
1059 let DelegationCommands::List {
1060 from_user,
1061 to_user,
1062 page,
1063 per_page,
1064 } = &args.command
1065 else {
1066 panic!("expected List subcommand");
1067 };
1068 assert!(from_user.is_some());
1069 assert!(to_user.is_some());
1070 assert_eq!(*page, Some(2));
1071 assert_eq!(*per_page, Some(10));
1072 }
1073
1074 #[test]
1075 fn parse_delegation_create_with_every_flag() {
1076 let cli = parse(&[
1077 "ironflow-cli",
1078 "delegation",
1079 "create",
1080 UUID,
1081 "--until",
1082 "2026-12-31T23:59:59Z",
1083 "--from",
1084 "2026-12-01T00:00:00Z",
1085 "--workflow",
1086 "deploy-*",
1087 ]);
1088 let Commands::Delegation(args) = &cli.command else {
1089 panic!("expected Delegation command");
1090 };
1091 let DelegationCommands::Create {
1092 until,
1093 from,
1094 workflow,
1095 ..
1096 } = &args.command
1097 else {
1098 panic!("expected Create subcommand");
1099 };
1100 assert_eq!(until, "2026-12-31T23:59:59Z");
1101 assert_eq!(from.as_deref(), Some("2026-12-01T00:00:00Z"));
1102 assert_eq!(workflow.as_deref(), Some("deploy-*"));
1103 }
1104
1105 #[test]
1106 fn parse_delegation_create_requires_an_until() {
1107 assert!(Cli::try_parse_from(["ironflow-cli", "delegation", "create", UUID]).is_err());
1108 }
1109
1110 #[test]
1111 fn parse_delegation_create_rejects_a_non_uuid_target() {
1112 let result = Cli::try_parse_from([
1113 "ironflow-cli",
1114 "delegation",
1115 "create",
1116 "alice",
1117 "--until",
1118 "2026-12-31T23:59:59Z",
1119 ]);
1120 assert!(result.is_err());
1121 }
1122
1123 #[test]
1124 fn parse_delegation_delete_defaults_to_confirming() {
1125 let cli = parse(&["ironflow-cli", "delegation", "delete", UUID]);
1126 let Commands::Delegation(args) = &cli.command else {
1127 panic!("expected Delegation command");
1128 };
1129 let DelegationCommands::Delete { yes, .. } = &args.command else {
1130 panic!("expected Delete subcommand");
1131 };
1132 assert!(!yes);
1133 }
1134
1135 #[test]
1138 fn parse_completions_bash() {
1139 let cli = parse(&["ironflow-cli", "completions", "bash"]);
1140 let Commands::Completions { shell } = &cli.command else {
1141 panic!("expected Completions command");
1142 };
1143 assert_eq!(*shell, Shell::Bash);
1144 }
1145
1146 #[test]
1147 fn parse_completions_zsh() {
1148 let cli = parse(&["ironflow-cli", "completions", "zsh"]);
1149 let Commands::Completions { shell } = &cli.command else {
1150 panic!("expected Completions command");
1151 };
1152 assert_eq!(*shell, Shell::Zsh);
1153 }
1154
1155 #[test]
1156 fn parse_completions_fish() {
1157 let cli = parse(&["ironflow-cli", "completions", "fish"]);
1158 let Commands::Completions { shell } = &cli.command else {
1159 panic!("expected Completions command");
1160 };
1161 assert_eq!(*shell, Shell::Fish);
1162 }
1163
1164 #[test]
1165 fn parse_completions_powershell() {
1166 let cli = parse(&["ironflow-cli", "completions", "powershell"]);
1167 let Commands::Completions { shell } = &cli.command else {
1168 panic!("expected Completions command");
1169 };
1170 assert_eq!(*shell, Shell::PowerShell);
1171 }
1172
1173 #[test]
1174 fn parse_completions_requires_shell() {
1175 assert!(Cli::try_parse_from(["ironflow-cli", "completions"]).is_err());
1176 }
1177
1178 #[test]
1179 fn parse_completions_rejects_unknown_shell() {
1180 assert!(Cli::try_parse_from(["ironflow-cli", "completions", "nushell"]).is_err());
1181 }
1182
1183 #[test]
1184 fn parse_man() {
1185 let cli = parse(&["ironflow-cli", "man"]);
1186 assert!(matches!(cli.command, Commands::Man));
1187 }
1188
1189 #[test]
1190 fn completions_bash_output_is_valid() {
1191 let mut buf = Vec::new();
1192 super::generate_completions(Shell::Bash, &mut buf).unwrap();
1193 let output = String::from_utf8(buf).unwrap();
1194 assert!(output.contains("ironflow-cli"));
1195 }
1196
1197 #[test]
1198 fn man_page_output_is_valid() {
1199 let mut buf = Vec::new();
1200 super::generate_man_page(&mut buf).unwrap();
1201 let output = String::from_utf8(buf).unwrap();
1202 assert!(output.contains(".TH"));
1203 assert!(output.contains("ironflow-cli"));
1204 }
1205
1206 #[test]
1209 fn parse_template_list() {
1210 let cli = parse(&[
1211 "ironflow-cli",
1212 "template",
1213 "list",
1214 "https://github.com/user/templates",
1215 ]);
1216 assert!(matches!(cli.command, Commands::Template(_)));
1217 }
1218
1219 #[test]
1220 fn parse_template_add_with_from() {
1221 let cli = parse(&[
1222 "ironflow-cli",
1223 "template",
1224 "add",
1225 "ci-pipeline",
1226 "--from",
1227 "https://github.com/user/templates",
1228 ]);
1229 assert!(matches!(cli.command, Commands::Template(_)));
1230 }
1231
1232 #[test]
1233 fn parse_template_add_with_output() {
1234 let cli = parse(&[
1235 "ironflow-cli",
1236 "template",
1237 "add",
1238 "ci-pipeline",
1239 "--from",
1240 "https://github.com/user/templates",
1241 "--output",
1242 "my/custom/path",
1243 ]);
1244 assert!(matches!(cli.command, Commands::Template(_)));
1245 }
1246
1247 #[test]
1248 fn parse_template_list_registry() {
1249 let cli = parse(&["ironflow-cli", "template", "list", "--registry"]);
1250 assert!(matches!(cli.command, Commands::Template(_)));
1251 }
1252
1253 #[test]
1254 fn parse_template_update() {
1255 let cli = parse(&["ironflow-cli", "template", "update"]);
1256 assert!(matches!(cli.command, Commands::Template(_)));
1257 }
1258
1259 #[test]
1260 fn parse_template_info() {
1261 let cli = parse(&[
1262 "ironflow-cli",
1263 "template",
1264 "info",
1265 "https://github.com/user/templates",
1266 "ci-pipeline",
1267 ]);
1268 assert!(matches!(cli.command, Commands::Template(_)));
1269 }
1270
1271 #[test]
1272 fn parse_template_requires_subcommand() {
1273 let result = Cli::try_parse_from(["ironflow-cli", "template"]);
1274 assert!(result.is_err());
1275 }
1276
1277 #[test]
1280 fn parse_signal_send_with_every_flag() {
1281 let cli = parse(&[
1282 "ironflow-cli",
1283 "signal",
1284 "send",
1285 "ci.pipeline_finished",
1286 "--key",
1287 "4f2a9c1",
1288 "--payload",
1289 r#"{"status":"success"}"#,
1290 "--idempotency-id",
1291 "delivery-42",
1292 ]);
1293 let Commands::Signal(args) = &cli.command else {
1294 panic!("expected Signal command");
1295 };
1296 let SignalCommands::Send {
1297 name,
1298 key,
1299 payload,
1300 idempotency_id,
1301 } = &args.command
1302 else {
1303 panic!("expected Send subcommand");
1304 };
1305 assert_eq!(name, "ci.pipeline_finished");
1306 assert_eq!(key, "4f2a9c1");
1307 assert_eq!(payload.as_deref(), Some(r#"{"status":"success"}"#));
1308 assert_eq!(idempotency_id.as_deref(), Some("delivery-42"));
1309 }
1310
1311 #[test]
1312 fn parse_signal_send_requires_a_key() {
1313 let result = Cli::try_parse_from(["ironflow-cli", "signal", "send", "demo.done"]);
1314 assert!(result.is_err());
1315 }
1316
1317 #[test]
1318 fn parse_signal_list_with_filters() {
1319 let cli = parse(&[
1320 "ironflow-cli",
1321 "signal",
1322 "list",
1323 "--name",
1324 "demo.done",
1325 "--key",
1326 "k1",
1327 "--page",
1328 "2",
1329 "--per-page",
1330 "10",
1331 ]);
1332 let Commands::Signal(args) = &cli.command else {
1333 panic!("expected Signal command");
1334 };
1335 let SignalCommands::List {
1336 name,
1337 key,
1338 page,
1339 per_page,
1340 } = &args.command
1341 else {
1342 panic!("expected List subcommand");
1343 };
1344 assert_eq!(name.as_deref(), Some("demo.done"));
1345 assert_eq!(key.as_deref(), Some("k1"));
1346 assert_eq!(*page, Some(2));
1347 assert_eq!(*per_page, Some(10));
1348 }
1349}