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::api_key::ApiKeyArgs;
17use crate::commands::audit_log::AuditLogArgs;
18use crate::commands::logs::LogsArgs;
19use crate::commands::run::RunArgs;
20use crate::commands::secret::SecretArgs;
21use crate::commands::template::TemplateArgs;
22use crate::commands::user::UserArgs;
23use crate::commands::workflow::WorkflowArgs;
24
25#[derive(Debug, Parser)]
38#[command(
39 name = "ironflow-cli",
40 version,
41 about = "Drive the Ironflow workflow engine from the terminal"
42)]
43pub struct Cli {
44 #[arg(long, global = true)]
46 pub json: bool,
47
48 #[arg(long, global = true)]
50 pub verbose: bool,
51
52 #[arg(long, global = true, env = "IRONFLOW_URL")]
54 pub url: Option<String>,
55
56 #[arg(long, global = true, env = "IRONFLOW_API_KEY")]
58 pub api_key: Option<String>,
59
60 #[command(subcommand)]
62 pub command: Commands,
63}
64
65#[derive(Debug, Subcommand)]
67pub enum Commands {
68 Run(RunArgs),
70 Workflow(WorkflowArgs),
72 Logs(LogsArgs),
74 Stats,
76 Secret(SecretArgs),
78 #[command(name = "api-key")]
80 ApiKey(ApiKeyArgs),
81 User(UserArgs),
83 #[command(name = "audit-log")]
85 AuditLog(AuditLogArgs),
86 Template(TemplateArgs),
88 Completions {
90 shell: Shell,
92 },
93 Man,
95}
96
97pub fn generate_completions(shell: Shell, writer: &mut impl io::Write) -> Result<()> {
115 let mut cmd = Cli::command();
116 clap_complete::generate(shell, &mut cmd, "ironflow-cli", writer);
117 Ok(())
118}
119
120pub fn generate_man_page(writer: &mut impl io::Write) -> Result<()> {
137 let cmd = Cli::command();
138 Man::new(cmd).render(writer)?;
139 Ok(())
140}
141
142pub async fn dispatch(client: &IronflowClient, cli: &Cli) -> Result<()> {
149 match &cli.command {
150 Commands::Run(args) => commands::run::execute(client, args, cli.json, cli.verbose).await,
151 Commands::Workflow(args) => commands::workflow::execute(client, args, cli.json).await,
152 Commands::Logs(args) => commands::logs::execute(client, args, cli.json).await,
153 Commands::Stats => commands::stats::execute(client, cli.json).await,
154 Commands::Secret(args) => commands::secret::execute(client, args, cli.json).await,
155 Commands::ApiKey(args) => commands::api_key::execute(client, args, cli.json).await,
156 Commands::User(args) => commands::user::execute(client, args, cli.json).await,
157 Commands::AuditLog(args) => commands::audit_log::execute(client, args, cli.json).await,
158 Commands::Template(args) => commands::template::execute(args),
159 Commands::Completions { shell } => generate_completions(*shell, &mut io::stdout()),
160 Commands::Man => generate_man_page(&mut io::stdout()),
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use clap::Parser;
167
168 use crate::commands::api_key::ApiKeyCommands;
169 use crate::commands::audit_log::AuditLogCommands;
170 use crate::commands::secret::SecretCommands;
171 use crate::commands::user::UserCommands;
172
173 use super::*;
174
175 const UUID: &str = "01234567-89ab-cdef-0123-456789abcdef";
176
177 fn parse(args: &[&str]) -> Cli {
178 Cli::try_parse_from(args).unwrap()
179 }
180
181 #[test]
182 fn parse_run_list() {
183 let cli = parse(&["ironflow-cli", "run", "list"]);
184 assert!(!cli.json);
185 assert!(matches!(cli.command, Commands::Run(_)));
186 }
187
188 #[test]
189 fn parse_run_list_with_json() {
190 let cli = parse(&["ironflow-cli", "--json", "run", "list"]);
191 assert!(cli.json);
192 }
193
194 #[test]
195 fn parse_run_create_with_payload() {
196 let cli = parse(&[
197 "ironflow-cli",
198 "run",
199 "create",
200 "deploy",
201 "--payload",
202 r#"{"env": "prod"}"#,
203 ]);
204 assert!(matches!(cli.command, Commands::Run(_)));
205 }
206
207 #[test]
208 fn parse_run_create_with_payload_file() {
209 let cli = parse(&[
210 "ironflow-cli",
211 "run",
212 "create",
213 "deploy",
214 "--payload-file",
215 "/tmp/payload.json",
216 ]);
217 assert!(matches!(cli.command, Commands::Run(_)));
218 }
219
220 #[test]
221 fn parse_run_create_payload_and_file_conflict() {
222 let result = Cli::try_parse_from([
223 "ironflow-cli",
224 "run",
225 "create",
226 "deploy",
227 "--payload",
228 "{}",
229 "--payload-file",
230 "/tmp/p.json",
231 ]);
232 assert!(result.is_err());
233 }
234
235 #[test]
236 fn parse_run_get() {
237 let cli = parse(&["ironflow-cli", "run", "get", UUID]);
238 assert!(matches!(cli.command, Commands::Run(_)));
239 }
240
241 #[test]
242 fn parse_run_cancel() {
243 let cli = parse(&["ironflow-cli", "run", "cancel", UUID]);
244 assert!(matches!(cli.command, Commands::Run(_)));
245 }
246
247 #[test]
248 fn parse_run_approve() {
249 let cli = parse(&["ironflow-cli", "run", "approve", UUID]);
250 assert!(matches!(cli.command, Commands::Run(_)));
251 }
252
253 #[test]
254 fn parse_run_reject() {
255 let cli = parse(&["ironflow-cli", "run", "reject", UUID]);
256 assert!(matches!(cli.command, Commands::Run(_)));
257 }
258
259 #[test]
260 fn parse_run_reject_requires_an_id() {
261 assert!(Cli::try_parse_from(["ironflow-cli", "run", "reject"]).is_err());
262 }
263
264 #[test]
265 fn parse_run_retry() {
266 let cli = parse(&["ironflow-cli", "run", "retry", UUID]);
267 assert!(matches!(cli.command, Commands::Run(_)));
268 }
269
270 #[test]
271 fn parse_run_list_with_filters() {
272 let cli = parse(&[
273 "ironflow-cli",
274 "run",
275 "list",
276 "--status",
277 "completed",
278 "--workflow",
279 "deploy",
280 "--page",
281 "2",
282 "--per-page",
283 "50",
284 ]);
285 assert!(matches!(cli.command, Commands::Run(_)));
286 }
287
288 #[test]
289 fn parse_workflow_list() {
290 let cli = parse(&["ironflow-cli", "workflow", "list"]);
291 assert!(matches!(cli.command, Commands::Workflow(_)));
292 }
293
294 #[test]
295 fn parse_workflow_get() {
296 let cli = parse(&["ironflow-cli", "workflow", "get", "deploy"]);
297 assert!(matches!(cli.command, Commands::Workflow(_)));
298 }
299
300 #[test]
301 fn parse_logs() {
302 let cli = parse(&["ironflow-cli", "logs", UUID]);
303 assert!(matches!(cli.command, Commands::Logs(_)));
304 }
305
306 #[test]
307 fn parse_logs_follow() {
308 let cli = parse(&["ironflow-cli", "logs", UUID, "--follow"]);
309 let Commands::Logs(args) = &cli.command else {
310 panic!("expected Logs command");
311 };
312 assert!(args.follow);
313 }
314
315 #[test]
316 fn parse_stats() {
317 let cli = parse(&["ironflow-cli", "stats"]);
318 assert!(matches!(cli.command, Commands::Stats));
319 }
320
321 #[test]
322 fn parse_verbose_flag() {
323 let cli = parse(&["ironflow-cli", "--verbose", "stats"]);
324 assert!(cli.verbose);
325 }
326
327 #[test]
328 fn parse_url_override() {
329 let cli = parse(&[
330 "ironflow-cli",
331 "--url",
332 "https://custom.example.com",
333 "stats",
334 ]);
335 assert_eq!(cli.url.as_deref(), Some("https://custom.example.com"));
336 }
337
338 #[test]
339 fn parse_invalid_uuid_rejected() {
340 assert!(Cli::try_parse_from(["ironflow-cli", "run", "get", "not-a-uuid"]).is_err());
341 }
342
343 #[test]
344 fn parse_no_command_fails() {
345 assert!(Cli::try_parse_from(["ironflow-cli"]).is_err());
346 }
347
348 #[test]
351 fn parse_secret_list() {
352 let cli = parse(&["ironflow-cli", "secret", "list"]);
353 assert!(matches!(cli.command, Commands::Secret(_)));
354 }
355
356 #[test]
357 fn parse_secret_set_with_inline_value() {
358 let cli = parse(&["ironflow-cli", "secret", "set", "db/password", "hunter2"]);
359 let Commands::Secret(args) = &cli.command else {
360 panic!("expected Secret command");
361 };
362 let SecretCommands::Set { key, value } = &args.command else {
363 panic!("expected Set subcommand");
364 };
365 assert_eq!(key, "db/password");
366 assert_eq!(value.as_deref(), Some("hunter2"));
367 }
368
369 #[test]
370 fn parse_secret_set_without_value_defers_to_stdin() {
371 let cli = parse(&["ironflow-cli", "secret", "set", "db/password"]);
372 let Commands::Secret(args) = &cli.command else {
373 panic!("expected Secret command");
374 };
375 let SecretCommands::Set { value, .. } = &args.command else {
376 panic!("expected Set subcommand");
377 };
378 assert!(value.is_none());
379 }
380
381 #[test]
382 fn parse_secret_set_requires_a_key() {
383 assert!(Cli::try_parse_from(["ironflow-cli", "secret", "set"]).is_err());
384 }
385
386 #[test]
387 fn parse_secret_update() {
388 let cli = parse(&["ironflow-cli", "secret", "update", "db/password", "new"]);
389 assert!(matches!(cli.command, Commands::Secret(_)));
390 }
391
392 #[test]
393 fn parse_secret_delete_with_yes() {
394 let cli = parse(&["ironflow-cli", "secret", "delete", "db/password", "--yes"]);
395 let Commands::Secret(args) = &cli.command else {
396 panic!("expected Secret command");
397 };
398 let SecretCommands::Delete { yes, .. } = &args.command else {
399 panic!("expected Delete subcommand");
400 };
401 assert!(yes);
402 }
403
404 #[test]
405 fn parse_secret_delete_defaults_to_confirming() {
406 let cli = parse(&["ironflow-cli", "secret", "delete", "db/password"]);
407 let Commands::Secret(args) = &cli.command else {
408 panic!("expected Secret command");
409 };
410 let SecretCommands::Delete { yes, .. } = &args.command else {
411 panic!("expected Delete subcommand");
412 };
413 assert!(!yes);
414 }
415
416 #[test]
417 fn parse_secret_rotate_defaults_to_the_active_version() {
418 let cli = parse(&["ironflow-cli", "secret", "rotate"]);
419 let Commands::Secret(args) = &cli.command else {
420 panic!("expected Secret command");
421 };
422 let SecretCommands::Rotate(rotate) = &args.command else {
423 panic!("expected Rotate subcommand");
424 };
425 assert!(rotate.to_version.is_none());
426 assert_eq!(rotate.batch_size, 100);
427 }
428
429 #[test]
430 fn parse_secret_rotate_with_version_and_batch_size() {
431 let cli = parse(&[
432 "ironflow-cli",
433 "secret",
434 "rotate",
435 "--to-version",
436 "2",
437 "--batch-size",
438 "50",
439 ]);
440 let Commands::Secret(args) = &cli.command else {
441 panic!("expected Secret command");
442 };
443 let SecretCommands::Rotate(rotate) = &args.command else {
444 panic!("expected Rotate subcommand");
445 };
446 assert_eq!(rotate.to_version, Some(2));
447 assert_eq!(rotate.batch_size, 50);
448 }
449
450 #[test]
451 fn parse_secret_rotate_rejects_a_non_positive_version() {
452 let zero = ["ironflow-cli", "secret", "rotate", "--to-version", "0"];
453 let negative = ["ironflow-cli", "secret", "rotate", "--to-version", "-1"];
454 assert!(Cli::try_parse_from(zero).is_err());
455 assert!(Cli::try_parse_from(negative).is_err());
456 }
457
458 #[test]
459 fn parse_secret_rotate_rejects_an_out_of_range_batch_size() {
460 let zero = ["ironflow-cli", "secret", "rotate", "--batch-size", "0"];
461 let too_large = ["ironflow-cli", "secret", "rotate", "--batch-size", "1001"];
462 assert!(Cli::try_parse_from(zero).is_err());
463 assert!(Cli::try_parse_from(too_large).is_err());
464 }
465
466 #[test]
467 fn parse_secret_key_status_takes_no_arguments() {
468 let cli = parse(&["ironflow-cli", "secret", "key-status"]);
469 let Commands::Secret(args) = &cli.command else {
470 panic!("expected Secret command");
471 };
472 assert!(matches!(args.command, SecretCommands::KeyStatus));
473 assert!(Cli::try_parse_from(["ironflow-cli", "secret", "key-status", "extra"]).is_err());
474 }
475
476 #[test]
479 fn parse_api_key_list() {
480 let cli = parse(&["ironflow-cli", "api-key", "list"]);
481 assert!(matches!(cli.command, Commands::ApiKey(_)));
482 }
483
484 #[test]
485 fn parse_api_key_scopes() {
486 let cli = parse(&["ironflow-cli", "api-key", "scopes"]);
487 assert!(matches!(cli.command, Commands::ApiKey(_)));
488 }
489
490 #[test]
491 fn parse_api_key_create_with_several_scopes() {
492 let cli = parse(&[
493 "ironflow-cli",
494 "api-key",
495 "create",
496 "ci",
497 "--scope",
498 "runs_read",
499 "--scope",
500 "runs_write",
501 ]);
502 let Commands::ApiKey(args) = &cli.command else {
503 panic!("expected ApiKey command");
504 };
505 let ApiKeyCommands::Create { scopes, .. } = &args.command else {
506 panic!("expected Create subcommand");
507 };
508 assert_eq!(scopes.len(), 2);
509 }
510
511 #[test]
512 fn parse_api_key_create_requires_a_scope() {
513 assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci"]).is_err());
514 }
515
516 #[test]
517 fn parse_api_key_create_rejects_an_unknown_scope() {
518 let result =
519 Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci", "--scope", "root"]);
520 assert!(result.is_err());
521 }
522
523 #[test]
524 fn parse_api_key_create_with_expiry() {
525 let cli = parse(&[
526 "ironflow-cli",
527 "api-key",
528 "create",
529 "ci",
530 "--scope",
531 "admin",
532 "--expires-at",
533 "2026-12-31T23:59:59Z",
534 ]);
535 assert!(matches!(cli.command, Commands::ApiKey(_)));
536 }
537
538 #[test]
539 fn parse_api_key_create_rejects_a_malformed_expiry() {
540 let result = Cli::try_parse_from([
541 "ironflow-cli",
542 "api-key",
543 "create",
544 "ci",
545 "--scope",
546 "admin",
547 "--expires-at",
548 "tomorrow",
549 ]);
550 assert!(result.is_err());
551 }
552
553 #[test]
554 fn parse_api_key_delete_rejects_a_non_uuid() {
555 assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "delete", "abc"]).is_err());
556 }
557
558 #[test]
561 fn parse_user_list() {
562 let cli = parse(&["ironflow-cli", "user", "list"]);
563 assert!(matches!(cli.command, Commands::User(_)));
564 }
565
566 #[test]
567 fn parse_user_create() {
568 let cli = parse(&[
569 "ironflow-cli",
570 "user",
571 "create",
572 "alice",
573 "--email",
574 "alice@example.com",
575 "--password",
576 "hunter2hunter2",
577 "--admin",
578 ]);
579 let Commands::User(args) = &cli.command else {
580 panic!("expected User command");
581 };
582 let UserCommands::Create { admin, .. } = &args.command else {
583 panic!("expected Create subcommand");
584 };
585 assert!(admin);
586 }
587
588 #[test]
589 fn parse_user_create_requires_an_email() {
590 assert!(Cli::try_parse_from(["ironflow-cli", "user", "create", "alice"]).is_err());
591 }
592
593 #[test]
594 fn parse_user_set_role_admin() {
595 let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--admin"]);
596 let Commands::User(args) = &cli.command else {
597 panic!("expected User command");
598 };
599 let UserCommands::SetRole { admin, member, .. } = &args.command else {
600 panic!("expected SetRole subcommand");
601 };
602 assert!(admin);
603 assert!(!member);
604 }
605
606 #[test]
607 fn parse_user_set_role_member() {
608 let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--member"]);
609 let Commands::User(args) = &cli.command else {
610 panic!("expected User command");
611 };
612 let UserCommands::SetRole { admin, .. } = &args.command else {
613 panic!("expected SetRole subcommand");
614 };
615 assert!(!admin);
616 }
617
618 #[test]
619 fn parse_user_set_role_requires_a_role() {
620 assert!(Cli::try_parse_from(["ironflow-cli", "user", "set-role", UUID]).is_err());
621 }
622
623 #[test]
624 fn parse_user_set_role_rejects_both_roles() {
625 let result = Cli::try_parse_from([
626 "ironflow-cli",
627 "user",
628 "set-role",
629 UUID,
630 "--admin",
631 "--member",
632 ]);
633 assert!(result.is_err());
634 }
635
636 #[test]
639 fn parse_audit_log_list_without_filters() {
640 let cli = parse(&["ironflow-cli", "audit-log", "list"]);
641 assert!(matches!(cli.command, Commands::AuditLog(_)));
642 }
643
644 #[test]
645 fn parse_audit_log_list_with_every_filter() {
646 let cli = parse(&[
647 "ironflow-cli",
648 "audit-log",
649 "list",
650 "--run",
651 UUID,
652 "--type",
653 "run_created",
654 "--from",
655 "2026-01-01T00:00:00Z",
656 "--to",
657 "2026-12-31T23:59:59Z",
658 "--page",
659 "2",
660 "--per-page",
661 "10",
662 ]);
663 let Commands::AuditLog(args) = &cli.command else {
664 panic!("expected AuditLog command");
665 };
666 let AuditLogCommands::List {
667 run,
668 event_type,
669 from,
670 to,
671 page,
672 per_page,
673 } = &args.command;
674 assert!(run.is_some());
675 assert!(event_type.is_some());
676 assert!(from.is_some());
677 assert!(to.is_some());
678 assert_eq!(*page, Some(2));
679 assert_eq!(*per_page, Some(10));
680 }
681
682 #[test]
683 fn parse_audit_log_list_rejects_an_unknown_type() {
684 let result =
685 Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--type", "exploded"]);
686 assert!(result.is_err());
687 }
688
689 #[test]
690 fn parse_audit_log_list_rejects_a_malformed_date() {
691 let result = Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--from", "hier"]);
692 assert!(result.is_err());
693 }
694
695 #[test]
698 fn parse_completions_bash() {
699 let cli = parse(&["ironflow-cli", "completions", "bash"]);
700 let Commands::Completions { shell } = &cli.command else {
701 panic!("expected Completions command");
702 };
703 assert_eq!(*shell, Shell::Bash);
704 }
705
706 #[test]
707 fn parse_completions_zsh() {
708 let cli = parse(&["ironflow-cli", "completions", "zsh"]);
709 let Commands::Completions { shell } = &cli.command else {
710 panic!("expected Completions command");
711 };
712 assert_eq!(*shell, Shell::Zsh);
713 }
714
715 #[test]
716 fn parse_completions_fish() {
717 let cli = parse(&["ironflow-cli", "completions", "fish"]);
718 let Commands::Completions { shell } = &cli.command else {
719 panic!("expected Completions command");
720 };
721 assert_eq!(*shell, Shell::Fish);
722 }
723
724 #[test]
725 fn parse_completions_powershell() {
726 let cli = parse(&["ironflow-cli", "completions", "powershell"]);
727 let Commands::Completions { shell } = &cli.command else {
728 panic!("expected Completions command");
729 };
730 assert_eq!(*shell, Shell::PowerShell);
731 }
732
733 #[test]
734 fn parse_completions_requires_shell() {
735 assert!(Cli::try_parse_from(["ironflow-cli", "completions"]).is_err());
736 }
737
738 #[test]
739 fn parse_completions_rejects_unknown_shell() {
740 assert!(Cli::try_parse_from(["ironflow-cli", "completions", "nushell"]).is_err());
741 }
742
743 #[test]
744 fn parse_man() {
745 let cli = parse(&["ironflow-cli", "man"]);
746 assert!(matches!(cli.command, Commands::Man));
747 }
748
749 #[test]
750 fn completions_bash_output_is_valid() {
751 let mut buf = Vec::new();
752 super::generate_completions(Shell::Bash, &mut buf).unwrap();
753 let output = String::from_utf8(buf).unwrap();
754 assert!(output.contains("ironflow-cli"));
755 }
756
757 #[test]
758 fn man_page_output_is_valid() {
759 let mut buf = Vec::new();
760 super::generate_man_page(&mut buf).unwrap();
761 let output = String::from_utf8(buf).unwrap();
762 assert!(output.contains(".TH"));
763 assert!(output.contains("ironflow-cli"));
764 }
765
766 #[test]
769 fn parse_template_list() {
770 let cli = parse(&[
771 "ironflow-cli",
772 "template",
773 "list",
774 "https://github.com/user/templates",
775 ]);
776 assert!(matches!(cli.command, Commands::Template(_)));
777 }
778
779 #[test]
780 fn parse_template_add() {
781 let cli = parse(&[
782 "ironflow-cli",
783 "template",
784 "add",
785 "https://github.com/user/templates",
786 "ci-pipeline",
787 ]);
788 assert!(matches!(cli.command, Commands::Template(_)));
789 }
790
791 #[test]
792 fn parse_template_add_with_output() {
793 let cli = parse(&[
794 "ironflow-cli",
795 "template",
796 "add",
797 "https://github.com/user/templates",
798 "ci-pipeline",
799 "--output",
800 "my/custom/path",
801 ]);
802 assert!(matches!(cli.command, Commands::Template(_)));
803 }
804
805 #[test]
806 fn parse_template_info() {
807 let cli = parse(&[
808 "ironflow-cli",
809 "template",
810 "info",
811 "https://github.com/user/templates",
812 "ci-pipeline",
813 ]);
814 assert!(matches!(cli.command, Commands::Template(_)));
815 }
816
817 #[test]
818 fn parse_template_requires_subcommand() {
819 let result = Cli::try_parse_from(["ironflow-cli", "template"]);
820 assert!(result.is_err());
821 }
822}