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