1use anyhow::Result;
8use clap::{Parser, Subcommand};
9use ironflow_sdk::IronflowClient;
10
11use crate::commands;
12use crate::commands::api_key::ApiKeyArgs;
13use crate::commands::audit_log::AuditLogArgs;
14use crate::commands::logs::LogsArgs;
15use crate::commands::run::RunArgs;
16use crate::commands::secret::SecretArgs;
17use crate::commands::template::TemplateArgs;
18use crate::commands::user::UserArgs;
19use crate::commands::workflow::WorkflowArgs;
20
21#[derive(Debug, Parser)]
34#[command(
35 name = "ironflow-cli",
36 version,
37 about = "Drive the Ironflow workflow engine from the terminal"
38)]
39pub struct Cli {
40 #[arg(long, global = true)]
42 pub json: bool,
43
44 #[arg(long, global = true)]
46 pub verbose: bool,
47
48 #[arg(long, global = true, env = "IRONFLOW_URL")]
50 pub url: Option<String>,
51
52 #[arg(long, global = true, env = "IRONFLOW_API_KEY")]
54 pub api_key: Option<String>,
55
56 #[command(subcommand)]
58 pub command: Commands,
59}
60
61#[derive(Debug, Subcommand)]
63pub enum Commands {
64 Run(RunArgs),
66 Workflow(WorkflowArgs),
68 Logs(LogsArgs),
70 Stats,
72 Secret(SecretArgs),
74 #[command(name = "api-key")]
76 ApiKey(ApiKeyArgs),
77 User(UserArgs),
79 #[command(name = "audit-log")]
81 AuditLog(AuditLogArgs),
82 Template(TemplateArgs),
84}
85
86pub async fn dispatch(client: &IronflowClient, cli: &Cli) -> Result<()> {
93 match &cli.command {
94 Commands::Run(args) => commands::run::execute(client, args, cli.json, cli.verbose).await,
95 Commands::Workflow(args) => commands::workflow::execute(client, args, cli.json).await,
96 Commands::Logs(args) => commands::logs::execute(client, args, cli.json).await,
97 Commands::Stats => commands::stats::execute(client, cli.json).await,
98 Commands::Secret(args) => commands::secret::execute(client, args, cli.json).await,
99 Commands::ApiKey(args) => commands::api_key::execute(client, args, cli.json).await,
100 Commands::User(args) => commands::user::execute(client, args, cli.json).await,
101 Commands::AuditLog(args) => commands::audit_log::execute(client, args, cli.json).await,
102 Commands::Template(args) => commands::template::execute(args),
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use clap::Parser;
109
110 use crate::commands::api_key::ApiKeyCommands;
111 use crate::commands::audit_log::AuditLogCommands;
112 use crate::commands::secret::SecretCommands;
113 use crate::commands::user::UserCommands;
114
115 use super::*;
116
117 const UUID: &str = "01234567-89ab-cdef-0123-456789abcdef";
118
119 fn parse(args: &[&str]) -> Cli {
120 Cli::try_parse_from(args).unwrap()
121 }
122
123 #[test]
124 fn parse_run_list() {
125 let cli = parse(&["ironflow-cli", "run", "list"]);
126 assert!(!cli.json);
127 assert!(matches!(cli.command, Commands::Run(_)));
128 }
129
130 #[test]
131 fn parse_run_list_with_json() {
132 let cli = parse(&["ironflow-cli", "--json", "run", "list"]);
133 assert!(cli.json);
134 }
135
136 #[test]
137 fn parse_run_create_with_payload() {
138 let cli = parse(&[
139 "ironflow-cli",
140 "run",
141 "create",
142 "deploy",
143 "--payload",
144 r#"{"env": "prod"}"#,
145 ]);
146 assert!(matches!(cli.command, Commands::Run(_)));
147 }
148
149 #[test]
150 fn parse_run_create_with_payload_file() {
151 let cli = parse(&[
152 "ironflow-cli",
153 "run",
154 "create",
155 "deploy",
156 "--payload-file",
157 "/tmp/payload.json",
158 ]);
159 assert!(matches!(cli.command, Commands::Run(_)));
160 }
161
162 #[test]
163 fn parse_run_create_payload_and_file_conflict() {
164 let result = Cli::try_parse_from([
165 "ironflow-cli",
166 "run",
167 "create",
168 "deploy",
169 "--payload",
170 "{}",
171 "--payload-file",
172 "/tmp/p.json",
173 ]);
174 assert!(result.is_err());
175 }
176
177 #[test]
178 fn parse_run_get() {
179 let cli = parse(&["ironflow-cli", "run", "get", UUID]);
180 assert!(matches!(cli.command, Commands::Run(_)));
181 }
182
183 #[test]
184 fn parse_run_cancel() {
185 let cli = parse(&["ironflow-cli", "run", "cancel", UUID]);
186 assert!(matches!(cli.command, Commands::Run(_)));
187 }
188
189 #[test]
190 fn parse_run_approve() {
191 let cli = parse(&["ironflow-cli", "run", "approve", UUID]);
192 assert!(matches!(cli.command, Commands::Run(_)));
193 }
194
195 #[test]
196 fn parse_run_reject() {
197 let cli = parse(&["ironflow-cli", "run", "reject", UUID]);
198 assert!(matches!(cli.command, Commands::Run(_)));
199 }
200
201 #[test]
202 fn parse_run_reject_requires_an_id() {
203 assert!(Cli::try_parse_from(["ironflow-cli", "run", "reject"]).is_err());
204 }
205
206 #[test]
207 fn parse_run_retry() {
208 let cli = parse(&["ironflow-cli", "run", "retry", UUID]);
209 assert!(matches!(cli.command, Commands::Run(_)));
210 }
211
212 #[test]
213 fn parse_run_list_with_filters() {
214 let cli = parse(&[
215 "ironflow-cli",
216 "run",
217 "list",
218 "--status",
219 "completed",
220 "--workflow",
221 "deploy",
222 "--page",
223 "2",
224 "--per-page",
225 "50",
226 ]);
227 assert!(matches!(cli.command, Commands::Run(_)));
228 }
229
230 #[test]
231 fn parse_workflow_list() {
232 let cli = parse(&["ironflow-cli", "workflow", "list"]);
233 assert!(matches!(cli.command, Commands::Workflow(_)));
234 }
235
236 #[test]
237 fn parse_workflow_get() {
238 let cli = parse(&["ironflow-cli", "workflow", "get", "deploy"]);
239 assert!(matches!(cli.command, Commands::Workflow(_)));
240 }
241
242 #[test]
243 fn parse_logs() {
244 let cli = parse(&["ironflow-cli", "logs", UUID]);
245 assert!(matches!(cli.command, Commands::Logs(_)));
246 }
247
248 #[test]
249 fn parse_logs_follow() {
250 let cli = parse(&["ironflow-cli", "logs", UUID, "--follow"]);
251 let Commands::Logs(args) = &cli.command else {
252 panic!("expected Logs command");
253 };
254 assert!(args.follow);
255 }
256
257 #[test]
258 fn parse_stats() {
259 let cli = parse(&["ironflow-cli", "stats"]);
260 assert!(matches!(cli.command, Commands::Stats));
261 }
262
263 #[test]
264 fn parse_verbose_flag() {
265 let cli = parse(&["ironflow-cli", "--verbose", "stats"]);
266 assert!(cli.verbose);
267 }
268
269 #[test]
270 fn parse_url_override() {
271 let cli = parse(&[
272 "ironflow-cli",
273 "--url",
274 "https://custom.example.com",
275 "stats",
276 ]);
277 assert_eq!(cli.url.as_deref(), Some("https://custom.example.com"));
278 }
279
280 #[test]
281 fn parse_invalid_uuid_rejected() {
282 assert!(Cli::try_parse_from(["ironflow-cli", "run", "get", "not-a-uuid"]).is_err());
283 }
284
285 #[test]
286 fn parse_no_command_fails() {
287 assert!(Cli::try_parse_from(["ironflow-cli"]).is_err());
288 }
289
290 #[test]
293 fn parse_secret_list() {
294 let cli = parse(&["ironflow-cli", "secret", "list"]);
295 assert!(matches!(cli.command, Commands::Secret(_)));
296 }
297
298 #[test]
299 fn parse_secret_set_with_inline_value() {
300 let cli = parse(&["ironflow-cli", "secret", "set", "db/password", "hunter2"]);
301 let Commands::Secret(args) = &cli.command else {
302 panic!("expected Secret command");
303 };
304 let SecretCommands::Set { key, value } = &args.command else {
305 panic!("expected Set subcommand");
306 };
307 assert_eq!(key, "db/password");
308 assert_eq!(value.as_deref(), Some("hunter2"));
309 }
310
311 #[test]
312 fn parse_secret_set_without_value_defers_to_stdin() {
313 let cli = parse(&["ironflow-cli", "secret", "set", "db/password"]);
314 let Commands::Secret(args) = &cli.command else {
315 panic!("expected Secret command");
316 };
317 let SecretCommands::Set { value, .. } = &args.command else {
318 panic!("expected Set subcommand");
319 };
320 assert!(value.is_none());
321 }
322
323 #[test]
324 fn parse_secret_set_requires_a_key() {
325 assert!(Cli::try_parse_from(["ironflow-cli", "secret", "set"]).is_err());
326 }
327
328 #[test]
329 fn parse_secret_update() {
330 let cli = parse(&["ironflow-cli", "secret", "update", "db/password", "new"]);
331 assert!(matches!(cli.command, Commands::Secret(_)));
332 }
333
334 #[test]
335 fn parse_secret_delete_with_yes() {
336 let cli = parse(&["ironflow-cli", "secret", "delete", "db/password", "--yes"]);
337 let Commands::Secret(args) = &cli.command else {
338 panic!("expected Secret command");
339 };
340 let SecretCommands::Delete { yes, .. } = &args.command else {
341 panic!("expected Delete subcommand");
342 };
343 assert!(yes);
344 }
345
346 #[test]
347 fn parse_secret_delete_defaults_to_confirming() {
348 let cli = parse(&["ironflow-cli", "secret", "delete", "db/password"]);
349 let Commands::Secret(args) = &cli.command else {
350 panic!("expected Secret command");
351 };
352 let SecretCommands::Delete { yes, .. } = &args.command else {
353 panic!("expected Delete subcommand");
354 };
355 assert!(!yes);
356 }
357
358 #[test]
359 fn parse_secret_rotate_defaults_to_the_active_version() {
360 let cli = parse(&["ironflow-cli", "secret", "rotate"]);
361 let Commands::Secret(args) = &cli.command else {
362 panic!("expected Secret command");
363 };
364 let SecretCommands::Rotate(rotate) = &args.command else {
365 panic!("expected Rotate subcommand");
366 };
367 assert!(rotate.to_version.is_none());
368 assert_eq!(rotate.batch_size, 100);
369 }
370
371 #[test]
372 fn parse_secret_rotate_with_version_and_batch_size() {
373 let cli = parse(&[
374 "ironflow-cli",
375 "secret",
376 "rotate",
377 "--to-version",
378 "2",
379 "--batch-size",
380 "50",
381 ]);
382 let Commands::Secret(args) = &cli.command else {
383 panic!("expected Secret command");
384 };
385 let SecretCommands::Rotate(rotate) = &args.command else {
386 panic!("expected Rotate subcommand");
387 };
388 assert_eq!(rotate.to_version, Some(2));
389 assert_eq!(rotate.batch_size, 50);
390 }
391
392 #[test]
393 fn parse_secret_rotate_rejects_a_non_positive_version() {
394 let zero = ["ironflow-cli", "secret", "rotate", "--to-version", "0"];
395 let negative = ["ironflow-cli", "secret", "rotate", "--to-version", "-1"];
396 assert!(Cli::try_parse_from(zero).is_err());
397 assert!(Cli::try_parse_from(negative).is_err());
398 }
399
400 #[test]
401 fn parse_secret_rotate_rejects_an_out_of_range_batch_size() {
402 let zero = ["ironflow-cli", "secret", "rotate", "--batch-size", "0"];
403 let too_large = ["ironflow-cli", "secret", "rotate", "--batch-size", "1001"];
404 assert!(Cli::try_parse_from(zero).is_err());
405 assert!(Cli::try_parse_from(too_large).is_err());
406 }
407
408 #[test]
409 fn parse_secret_key_status_takes_no_arguments() {
410 let cli = parse(&["ironflow-cli", "secret", "key-status"]);
411 let Commands::Secret(args) = &cli.command else {
412 panic!("expected Secret command");
413 };
414 assert!(matches!(args.command, SecretCommands::KeyStatus));
415 assert!(Cli::try_parse_from(["ironflow-cli", "secret", "key-status", "extra"]).is_err());
416 }
417
418 #[test]
421 fn parse_api_key_list() {
422 let cli = parse(&["ironflow-cli", "api-key", "list"]);
423 assert!(matches!(cli.command, Commands::ApiKey(_)));
424 }
425
426 #[test]
427 fn parse_api_key_scopes() {
428 let cli = parse(&["ironflow-cli", "api-key", "scopes"]);
429 assert!(matches!(cli.command, Commands::ApiKey(_)));
430 }
431
432 #[test]
433 fn parse_api_key_create_with_several_scopes() {
434 let cli = parse(&[
435 "ironflow-cli",
436 "api-key",
437 "create",
438 "ci",
439 "--scope",
440 "runs_read",
441 "--scope",
442 "runs_write",
443 ]);
444 let Commands::ApiKey(args) = &cli.command else {
445 panic!("expected ApiKey command");
446 };
447 let ApiKeyCommands::Create { scopes, .. } = &args.command else {
448 panic!("expected Create subcommand");
449 };
450 assert_eq!(scopes.len(), 2);
451 }
452
453 #[test]
454 fn parse_api_key_create_requires_a_scope() {
455 assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci"]).is_err());
456 }
457
458 #[test]
459 fn parse_api_key_create_rejects_an_unknown_scope() {
460 let result =
461 Cli::try_parse_from(["ironflow-cli", "api-key", "create", "ci", "--scope", "root"]);
462 assert!(result.is_err());
463 }
464
465 #[test]
466 fn parse_api_key_create_with_expiry() {
467 let cli = parse(&[
468 "ironflow-cli",
469 "api-key",
470 "create",
471 "ci",
472 "--scope",
473 "admin",
474 "--expires-at",
475 "2026-12-31T23:59:59Z",
476 ]);
477 assert!(matches!(cli.command, Commands::ApiKey(_)));
478 }
479
480 #[test]
481 fn parse_api_key_create_rejects_a_malformed_expiry() {
482 let result = Cli::try_parse_from([
483 "ironflow-cli",
484 "api-key",
485 "create",
486 "ci",
487 "--scope",
488 "admin",
489 "--expires-at",
490 "tomorrow",
491 ]);
492 assert!(result.is_err());
493 }
494
495 #[test]
496 fn parse_api_key_delete_rejects_a_non_uuid() {
497 assert!(Cli::try_parse_from(["ironflow-cli", "api-key", "delete", "abc"]).is_err());
498 }
499
500 #[test]
503 fn parse_user_list() {
504 let cli = parse(&["ironflow-cli", "user", "list"]);
505 assert!(matches!(cli.command, Commands::User(_)));
506 }
507
508 #[test]
509 fn parse_user_create() {
510 let cli = parse(&[
511 "ironflow-cli",
512 "user",
513 "create",
514 "alice",
515 "--email",
516 "alice@example.com",
517 "--password",
518 "hunter2hunter2",
519 "--admin",
520 ]);
521 let Commands::User(args) = &cli.command else {
522 panic!("expected User command");
523 };
524 let UserCommands::Create { admin, .. } = &args.command else {
525 panic!("expected Create subcommand");
526 };
527 assert!(admin);
528 }
529
530 #[test]
531 fn parse_user_create_requires_an_email() {
532 assert!(Cli::try_parse_from(["ironflow-cli", "user", "create", "alice"]).is_err());
533 }
534
535 #[test]
536 fn parse_user_set_role_admin() {
537 let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--admin"]);
538 let Commands::User(args) = &cli.command else {
539 panic!("expected User command");
540 };
541 let UserCommands::SetRole { admin, member, .. } = &args.command else {
542 panic!("expected SetRole subcommand");
543 };
544 assert!(admin);
545 assert!(!member);
546 }
547
548 #[test]
549 fn parse_user_set_role_member() {
550 let cli = parse(&["ironflow-cli", "user", "set-role", UUID, "--member"]);
551 let Commands::User(args) = &cli.command else {
552 panic!("expected User command");
553 };
554 let UserCommands::SetRole { admin, .. } = &args.command else {
555 panic!("expected SetRole subcommand");
556 };
557 assert!(!admin);
558 }
559
560 #[test]
561 fn parse_user_set_role_requires_a_role() {
562 assert!(Cli::try_parse_from(["ironflow-cli", "user", "set-role", UUID]).is_err());
563 }
564
565 #[test]
566 fn parse_user_set_role_rejects_both_roles() {
567 let result = Cli::try_parse_from([
568 "ironflow-cli",
569 "user",
570 "set-role",
571 UUID,
572 "--admin",
573 "--member",
574 ]);
575 assert!(result.is_err());
576 }
577
578 #[test]
581 fn parse_audit_log_list_without_filters() {
582 let cli = parse(&["ironflow-cli", "audit-log", "list"]);
583 assert!(matches!(cli.command, Commands::AuditLog(_)));
584 }
585
586 #[test]
587 fn parse_audit_log_list_with_every_filter() {
588 let cli = parse(&[
589 "ironflow-cli",
590 "audit-log",
591 "list",
592 "--run",
593 UUID,
594 "--type",
595 "run_created",
596 "--from",
597 "2026-01-01T00:00:00Z",
598 "--to",
599 "2026-12-31T23:59:59Z",
600 "--page",
601 "2",
602 "--per-page",
603 "10",
604 ]);
605 let Commands::AuditLog(args) = &cli.command else {
606 panic!("expected AuditLog command");
607 };
608 let AuditLogCommands::List {
609 run,
610 event_type,
611 from,
612 to,
613 page,
614 per_page,
615 } = &args.command;
616 assert!(run.is_some());
617 assert!(event_type.is_some());
618 assert!(from.is_some());
619 assert!(to.is_some());
620 assert_eq!(*page, Some(2));
621 assert_eq!(*per_page, Some(10));
622 }
623
624 #[test]
625 fn parse_audit_log_list_rejects_an_unknown_type() {
626 let result =
627 Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--type", "exploded"]);
628 assert!(result.is_err());
629 }
630
631 #[test]
632 fn parse_audit_log_list_rejects_a_malformed_date() {
633 let result = Cli::try_parse_from(["ironflow-cli", "audit-log", "list", "--from", "hier"]);
634 assert!(result.is_err());
635 }
636
637 #[test]
640 fn parse_template_list() {
641 let cli = parse(&[
642 "ironflow-cli",
643 "template",
644 "list",
645 "https://github.com/user/templates",
646 ]);
647 assert!(matches!(cli.command, Commands::Template(_)));
648 }
649
650 #[test]
651 fn parse_template_add() {
652 let cli = parse(&[
653 "ironflow-cli",
654 "template",
655 "add",
656 "https://github.com/user/templates",
657 "ci-pipeline",
658 ]);
659 assert!(matches!(cli.command, Commands::Template(_)));
660 }
661
662 #[test]
663 fn parse_template_add_with_output() {
664 let cli = parse(&[
665 "ironflow-cli",
666 "template",
667 "add",
668 "https://github.com/user/templates",
669 "ci-pipeline",
670 "--output",
671 "my/custom/path",
672 ]);
673 assert!(matches!(cli.command, Commands::Template(_)));
674 }
675
676 #[test]
677 fn parse_template_info() {
678 let cli = parse(&[
679 "ironflow-cli",
680 "template",
681 "info",
682 "https://github.com/user/templates",
683 "ci-pipeline",
684 ]);
685 assert!(matches!(cli.command, Commands::Template(_)));
686 }
687
688 #[test]
689 fn parse_template_requires_subcommand() {
690 let result = Cli::try_parse_from(["ironflow-cli", "template"]);
691 assert!(result.is_err());
692 }
693}