1use std::{ffi::OsString, path::PathBuf};
2
3use clap::{ArgAction, Args, Command, CommandFactory, FromArgMatches, Parser, Subcommand};
4use clap_complete::Shell;
5
6use crate::backend::ContainerRuntime;
7
8#[derive(Debug, Parser, PartialEq, Eq)]
9#[command(
10 name = "orodruin",
11 version = crate::build_info::VERSION,
12 long_version = crate::build_info::LONG_VERSION,
13 about
14)]
15pub struct Cli {
16 #[arg(long, global = true, action = ArgAction::SetTrue, help = "Enable debug logging")]
17 pub debug: bool,
18 #[arg(long, short = 'y', global = true, action = ArgAction::SetTrue, help = "Automatically answer yes to interactive prompts")]
19 pub yes: bool,
20 #[arg(long, global = true, help = "Path to the orodruin config file")]
21 pub config: Option<PathBuf>,
22 #[command(subcommand)]
23 pub command: Commands,
24}
25
26struct UnsupportedCommand {
27 path: &'static [&'static str],
28 hidden_name: &'static str,
29}
30
31const PODMAN_UNSUPPORTED_COMMANDS: &[UnsupportedCommand] = &[
32 UnsupportedCommand {
33 path: &["registry", "list"],
34 hidden_name: "__hidden__registry__list",
35 },
36 UnsupportedCommand {
37 path: &["builder", "start"],
38 hidden_name: "__hidden__builder__start",
39 },
40 UnsupportedCommand {
41 path: &["builder", "stop"],
42 hidden_name: "__hidden__builder__stop",
43 },
44 UnsupportedCommand {
45 path: &["system", "dns"],
46 hidden_name: "__hidden__system__dns",
47 },
48 UnsupportedCommand {
49 path: &["system", "kernel"],
50 hidden_name: "__hidden__system__kernel",
51 },
52 UnsupportedCommand {
53 path: &["system", "property"],
54 hidden_name: "__hidden__system__property",
55 },
56 UnsupportedCommand {
57 path: &["system", "start"],
58 hidden_name: "__hidden__system__start",
59 },
60 UnsupportedCommand {
61 path: &["system", "stop"],
62 hidden_name: "__hidden__system__stop",
63 },
64 UnsupportedCommand {
65 path: &["machine", "set-default"],
66 hidden_name: "__hidden__machine__set_default",
67 },
68];
69
70impl Cli {
71 pub fn parse_for_runtime<I, T>(args: I, runtime: ContainerRuntime) -> Result<Self, clap::Error>
72 where
73 I: IntoIterator<Item = T>,
74 T: Into<OsString> + Clone,
75 {
76 Self::parse_for_runtime_named(args, runtime, "orodruin")
77 }
78
79 pub fn parse_for_runtime_named<I, T>(
80 args: I,
81 runtime: ContainerRuntime,
82 program_name: &str,
83 ) -> Result<Self, clap::Error>
84 where
85 I: IntoIterator<Item = T>,
86 T: Into<OsString> + Clone,
87 {
88 let matches = Self::parsing_command_for_runtime_named(runtime, program_name)
89 .try_get_matches_from(args)?;
90 Self::from_arg_matches(&matches)
91 }
92
93 pub fn command_for_runtime(runtime: ContainerRuntime) -> Command {
94 Self::command_for_runtime_named(runtime, "orodruin")
95 }
96
97 pub fn command_for_runtime_named(runtime: ContainerRuntime, program_name: &str) -> Command {
98 prune_command_for_runtime(Self::base_command_named(program_name), runtime, &[])
99 }
100
101 fn parsing_command_for_runtime_named(runtime: ContainerRuntime, program_name: &str) -> Command {
102 hide_unsupported_commands_for_runtime(Self::base_command_named(program_name), runtime, &[])
103 }
104
105 fn base_command_named(program_name: &str) -> Command {
106 Self::command().bin_name(program_name)
107 }
108}
109
110fn prune_command_for_runtime(
111 command: Command,
112 runtime: ContainerRuntime,
113 parent_path: &[String],
114) -> Command {
115 let mut path = parent_path.to_vec();
116 path.push(command.get_name().to_string());
117
118 if let Some(unsupported) = unsupported_command(runtime, &path) {
119 return command
120 .name(unsupported.hidden_name)
121 .aliases(Vec::<&str>::new())
122 .visible_aliases(Vec::<&str>::new())
123 .hide(true);
124 }
125
126 command.mut_subcommands(|child| prune_command_for_runtime(child, runtime, &path))
127}
128
129fn hide_unsupported_commands_for_runtime(
130 command: Command,
131 runtime: ContainerRuntime,
132 parent_path: &[String],
133) -> Command {
134 let mut path = parent_path.to_vec();
135 path.push(command.get_name().to_string());
136
137 if unsupported_command(runtime, &path).is_some() {
138 return command.hide(true);
139 }
140
141 command.mut_subcommands(|child| hide_unsupported_commands_for_runtime(child, runtime, &path))
142}
143
144fn unsupported_command(
145 runtime: ContainerRuntime,
146 path: &[String],
147) -> Option<&'static UnsupportedCommand> {
148 if runtime == ContainerRuntime::AppleContainer {
149 return None;
150 }
151
152 let relative_path = path.iter().skip(1).map(String::as_str).collect::<Vec<_>>();
153 PODMAN_UNSUPPORTED_COMMANDS
154 .iter()
155 .find(|command| command.path == relative_path.as_slice())
156}
157
158#[derive(Debug, Subcommand, PartialEq, Eq)]
159pub enum Commands {
160 #[command(about = "Create a starter orodruin.toml in the current directory")]
161 Init,
162 #[command(about = "Create the container for an environment and start it if needed")]
163 Create(EnvironmentName),
164 #[command(about = "Open an interactive shell inside an environment container")]
165 Enter(EnterCommand),
166 #[command(about = "Run a command in an environment container")]
167 Run(RunCommand),
168 #[command(about = "List configured environments and their container state")]
169 List(ListCommand),
170 #[command(about = "Remove an environment container")]
171 Rm(EnvironmentName),
172 #[command(about = "Show the resolved configuration and container details for an environment")]
173 Inspect(InspectCommand),
174 #[command(about = "Pull an image with the configured container runtime")]
175 Pull(RequiredPassthroughArgs),
176 #[command(about = "List local images with the configured container runtime")]
177 Images(OptionalPassthroughArgs),
178 #[command(about = "Remove an image with the configured container runtime")]
179 Rmi(RequiredPassthroughArgs),
180 #[command(about = "List containers with the configured container runtime")]
181 Ps(OptionalPassthroughArgs),
182 #[command(about = "Show container logs with the configured container runtime")]
183 Logs(OptionalPassthroughArgs),
184 #[command(about = "Build an image with the configured container runtime")]
185 Build(RequiredPassthroughArgs),
186 #[command(
187 about = "Copy files with the configured container runtime",
188 visible_alias = "cp"
189 )]
190 Copy(RequiredPassthroughArgs),
191 #[command(about = "Log in to a registry with the configured container runtime")]
192 Login(OptionalPassthroughArgs),
193 #[command(about = "Log out from a registry with the configured container runtime")]
194 Logout(OptionalPassthroughArgs),
195 #[command(
196 subcommand,
197 about = "Run image commands with the configured container runtime"
198 )]
199 Image(ImageCommands),
200 #[command(
201 subcommand,
202 about = "Run container commands with the configured container runtime"
203 )]
204 Container(ContainerCommands),
205 #[command(
206 subcommand,
207 about = "Run registry commands with the configured container runtime"
208 )]
209 Registry(RegistryCommands),
210 #[command(
211 subcommand,
212 about = "Run volume commands with the configured container runtime"
213 )]
214 Volume(ResourceCommands),
215 #[command(
216 subcommand,
217 about = "Run network commands with the configured container runtime"
218 )]
219 Network(ResourceCommands),
220 #[command(
221 subcommand,
222 about = "Run builder commands with the configured container runtime"
223 )]
224 Builder(BuilderCommands),
225 #[command(
226 subcommand,
227 about = "Run system commands with the configured container runtime"
228 )]
229 System(SystemCommands),
230 #[command(
231 subcommand,
232 about = "Run machine commands with the configured container runtime"
233 )]
234 Machine(MachineCommands),
235 #[command(about = "Generate shell completion scripts")]
236 Completions(CompletionsCommand),
237 #[command(about = "Show version, commit, date, and build information")]
238 Version,
239}
240
241#[derive(Debug, Args, Clone, PartialEq, Eq)]
242pub struct CompletionsCommand {
243 #[arg(value_enum, help = "Shell to generate completions for")]
244 pub shell: Shell,
245}
246
247#[derive(Debug, Args, PartialEq, Eq)]
248pub struct EnvironmentName {
249 #[arg(help = "Environment name from orodruin.toml")]
250 pub env: String,
251}
252
253#[derive(Debug, Args, Clone, PartialEq, Eq)]
254pub struct ListCommand {
255 #[arg(long, action = ArgAction::SetTrue, help = "Print structured JSON output")]
256 pub json: bool,
257}
258
259#[derive(Debug, Args, Clone, PartialEq, Eq)]
260pub struct InspectCommand {
261 #[arg(help = "Environment name from orodruin.toml")]
262 pub env: String,
263 #[arg(long, action = ArgAction::SetTrue, help = "Print structured JSON output")]
264 pub json: bool,
265}
266
267#[derive(Debug, Args, Clone, PartialEq, Eq)]
268pub struct EnterCommand {
269 #[arg(
270 help = "Environment name from orodruin.toml; defaults from project.default_env or the sole environment"
271 )]
272 pub env: Option<String>,
273}
274
275#[derive(Debug, Args, PartialEq, Eq)]
276pub struct RunCommand {
277 #[arg(
278 help = "Environment name from orodruin.toml; defaults from project.default_env or the sole environment"
279 )]
280 pub env: Option<String>,
281 #[arg(
282 last = true,
283 help = "Command to execute after `--`; uses the environment default if omitted"
284 )]
285 pub command: Vec<String>,
286}
287
288#[derive(Debug, Args, Clone, PartialEq, Eq)]
289pub struct OptionalPassthroughArgs {
290 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
291 pub args: Vec<String>,
292}
293
294#[derive(Debug, Args, Clone, PartialEq, Eq)]
295pub struct RequiredPassthroughArgs {
296 #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
297 pub args: Vec<String>,
298}
299
300#[derive(Debug, Subcommand, PartialEq, Eq)]
301pub enum ImageCommands {
302 #[command(about = "Pull an image")]
303 Pull(RequiredPassthroughArgs),
304 #[command(name = "list", about = "List local images", visible_alias = "ls")]
305 List(OptionalPassthroughArgs),
306 #[command(about = "Inspect an image", visible_alias = "i")]
307 Inspect(RequiredPassthroughArgs),
308 #[command(about = "Load an image")]
309 Load(OptionalPassthroughArgs),
310 #[command(name = "remove", about = "Remove an image", visible_alias = "rm")]
311 Remove(RequiredPassthroughArgs),
312 #[command(about = "Push an image")]
313 Push(RequiredPassthroughArgs),
314 #[command(about = "Remove unused images")]
315 Prune(OptionalPassthroughArgs),
316 #[command(about = "Save an image")]
317 Save(OptionalPassthroughArgs),
318 #[command(about = "Tag an image")]
319 Tag(RequiredPassthroughArgs),
320}
321
322#[derive(Debug, Subcommand, PartialEq, Eq)]
323pub enum ContainerCommands {
324 #[command(about = "Create a container")]
325 Create(OptionalPassthroughArgs),
326 #[command(about = "Execute a command in a container")]
327 Exec(OptionalPassthroughArgs),
328 #[command(about = "Export a container")]
329 Export(OptionalPassthroughArgs),
330 #[command(about = "Kill a container")]
331 Kill(OptionalPassthroughArgs),
332 #[command(name = "list", about = "List containers", visible_alias = "ls")]
333 List(OptionalPassthroughArgs),
334 #[command(about = "Inspect a container", visible_alias = "i")]
335 Inspect(RequiredPassthroughArgs),
336 #[command(about = "Show container logs")]
337 Logs(OptionalPassthroughArgs),
338 #[command(about = "Remove stopped containers")]
339 Prune(OptionalPassthroughArgs),
340 #[command(name = "remove", about = "Remove a container", visible_alias = "rm")]
341 Remove(RequiredPassthroughArgs),
342 #[command(about = "Run a container")]
343 Run(OptionalPassthroughArgs),
344 #[command(about = "Start a container")]
345 Start(RequiredPassthroughArgs),
346 #[command(about = "Show container stats")]
347 Stats(OptionalPassthroughArgs),
348 #[command(about = "Stop a container")]
349 Stop(RequiredPassthroughArgs),
350}
351
352#[derive(Debug, Subcommand, PartialEq, Eq)]
353pub enum RegistryCommands {
354 #[command(
355 name = "list",
356 about = "List configured registries",
357 visible_alias = "ls"
358 )]
359 List(OptionalPassthroughArgs),
360 #[command(about = "Log in to a registry")]
361 Login(OptionalPassthroughArgs),
362 #[command(about = "Log out from a registry")]
363 Logout(OptionalPassthroughArgs),
364}
365
366#[derive(Debug, Subcommand, PartialEq, Eq)]
367pub enum ResourceCommands {
368 #[command(name = "list", about = "List resources", visible_alias = "ls")]
369 List(OptionalPassthroughArgs),
370 #[command(about = "Create a resource", visible_alias = "c")]
371 Create(RequiredPassthroughArgs),
372 #[command(about = "Inspect a resource", visible_alias = "i")]
373 Inspect(RequiredPassthroughArgs),
374 #[command(about = "Remove unused resources")]
375 Prune(OptionalPassthroughArgs),
376 #[command(name = "remove", about = "Remove a resource", visible_alias = "rm")]
377 Remove(RequiredPassthroughArgs),
378}
379
380#[derive(Debug, Subcommand, PartialEq, Eq)]
381pub enum BuilderCommands {
382 #[command(name = "remove", about = "Remove the builder", visible_alias = "rm")]
383 Remove(OptionalPassthroughArgs),
384 #[command(about = "Start the builder")]
385 Start(OptionalPassthroughArgs),
386 #[command(about = "Show builder status")]
387 Status(OptionalPassthroughArgs),
388 #[command(about = "Stop the builder")]
389 Stop(OptionalPassthroughArgs),
390}
391
392#[derive(Debug, Subcommand, PartialEq, Eq)]
393pub enum SystemCommands {
394 #[command(about = "Show system disk usage")]
395 Df(OptionalPassthroughArgs),
396 #[command(about = "Manage system DNS")]
397 Dns(OptionalPassthroughArgs),
398 #[command(about = "Manage kernel configuration")]
399 Kernel(OptionalPassthroughArgs),
400 #[command(about = "Show system logs")]
401 Logs(OptionalPassthroughArgs),
402 #[command(about = "Manage system properties")]
403 Property(OptionalPassthroughArgs),
404 #[command(about = "Start container services")]
405 Start(OptionalPassthroughArgs),
406 #[command(about = "Show service status")]
407 Status(OptionalPassthroughArgs),
408 #[command(about = "Stop container services")]
409 Stop(OptionalPassthroughArgs),
410 #[command(about = "Show system version")]
411 Version(OptionalPassthroughArgs),
412}
413
414#[derive(Debug, Subcommand, PartialEq, Eq)]
415pub enum MachineCommands {
416 #[command(about = "Create a container machine")]
417 Create(OptionalPassthroughArgs),
418 #[command(about = "Inspect a container machine")]
419 Inspect(OptionalPassthroughArgs),
420 #[command(name = "list", about = "List container machines", visible_alias = "ls")]
421 List(OptionalPassthroughArgs),
422 #[command(about = "Show machine logs")]
423 Logs(OptionalPassthroughArgs),
424 #[command(
425 name = "remove",
426 about = "Remove a container machine",
427 visible_alias = "rm"
428 )]
429 Remove(OptionalPassthroughArgs),
430 #[command(about = "Run a command in a container machine")]
431 Run(OptionalPassthroughArgs),
432 #[command(about = "Set machine configuration")]
433 Set(OptionalPassthroughArgs),
434 #[command(name = "set-default", about = "Set the default container machine")]
435 SetDefault(OptionalPassthroughArgs),
436 #[command(about = "Stop a container machine")]
437 Stop(OptionalPassthroughArgs),
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[test]
445 fn parses_image_aliases() {
446 let cli = Cli::parse_from(["orodruin", "images"]);
447 assert_eq!(
448 cli.command,
449 Commands::Images(OptionalPassthroughArgs { args: vec![] })
450 );
451
452 let cli = Cli::parse_from(["orodruin", "image", "ls"]);
453 assert_eq!(
454 cli.command,
455 Commands::Image(ImageCommands::List(OptionalPassthroughArgs {
456 args: vec![]
457 }))
458 );
459
460 let cli = Cli::parse_from(["orodruin", "rmi", "alpine:latest"]);
461 assert_eq!(
462 cli.command,
463 Commands::Rmi(RequiredPassthroughArgs {
464 args: vec!["alpine:latest".into()],
465 })
466 );
467
468 let cli = Cli::parse_from(["orodruin", "image", "i", "alpine:latest"]);
469 assert_eq!(
470 cli.command,
471 Commands::Image(ImageCommands::Inspect(RequiredPassthroughArgs {
472 args: vec!["alpine:latest".into()],
473 }))
474 );
475
476 let cli = Cli::parse_from(["orodruin", "image", "load", "-i", "image.tar"]);
477 assert_eq!(
478 cli.command,
479 Commands::Image(ImageCommands::Load(OptionalPassthroughArgs {
480 args: vec!["-i".into(), "image.tar".into()],
481 }))
482 );
483 }
484
485 #[test]
486 fn parses_passthrough_aliases() {
487 let cli = Cli::parse_from(["orodruin", "cp", "src", "dst"]);
488 assert_eq!(
489 cli.command,
490 Commands::Copy(RequiredPassthroughArgs {
491 args: vec!["src".into(), "dst".into()],
492 })
493 );
494
495 let cli = Cli::parse_from(["orodruin", "registry", "ls"]);
496 assert_eq!(
497 cli.command,
498 Commands::Registry(RegistryCommands::List(OptionalPassthroughArgs {
499 args: vec![]
500 }))
501 );
502
503 let cli = Cli::parse_from(["orodruin", "volume", "c", "cache"]);
504 assert_eq!(
505 cli.command,
506 Commands::Volume(ResourceCommands::Create(RequiredPassthroughArgs {
507 args: vec!["cache".into()],
508 }))
509 );
510
511 let cli = Cli::parse_from(["orodruin", "network", "i", "bridge"]);
512 assert_eq!(
513 cli.command,
514 Commands::Network(ResourceCommands::Inspect(RequiredPassthroughArgs {
515 args: vec!["bridge".into()],
516 }))
517 );
518
519 let cli = Cli::parse_from(["orodruin", "container", "i", "demo"]);
520 assert_eq!(
521 cli.command,
522 Commands::Container(ContainerCommands::Inspect(RequiredPassthroughArgs {
523 args: vec!["demo".into()],
524 }))
525 );
526
527 let cli = Cli::parse_from(["orodruin", "container", "prune", "--all"]);
528 assert_eq!(
529 cli.command,
530 Commands::Container(ContainerCommands::Prune(OptionalPassthroughArgs {
531 args: vec!["--all".into()],
532 }))
533 );
534
535 let cli = Cli::parse_from(["orodruin", "machine", "ls"]);
536 assert_eq!(
537 cli.command,
538 Commands::Machine(MachineCommands::List(OptionalPassthroughArgs {
539 args: vec![]
540 }))
541 );
542
543 let cli = Cli::parse_from(["orodruin", "builder", "rm"]);
544 assert_eq!(
545 cli.command,
546 Commands::Builder(BuilderCommands::Remove(OptionalPassthroughArgs {
547 args: vec![]
548 }))
549 );
550
551 let cli = Cli::parse_from(["orodruin", "system", "version"]);
552 assert_eq!(
553 cli.command,
554 Commands::System(SystemCommands::Version(OptionalPassthroughArgs {
555 args: vec![]
556 }))
557 );
558 }
559
560 #[test]
561 fn parses_completions_subcommand() {
562 let cli = Cli::parse_from(["orodruin", "completions", "zsh"]);
563 assert_eq!(
564 cli.command,
565 Commands::Completions(CompletionsCommand { shell: Shell::Zsh })
566 );
567 }
568
569 #[test]
570 fn parses_global_yes_and_list_json() {
571 let cli = Cli::parse_from(["orodruin", "--yes", "list", "--json"]);
572 assert!(cli.yes);
573 assert_eq!(cli.command, Commands::List(ListCommand { json: true }));
574 }
575
576 #[test]
577 fn parses_inspect_json() {
578 let cli = Cli::parse_from(["orodruin", "inspect", "dev", "--json"]);
579 assert_eq!(
580 cli.command,
581 Commands::Inspect(InspectCommand {
582 env: "dev".into(),
583 json: true,
584 })
585 );
586 }
587
588 #[test]
589 fn parses_run_without_environment() {
590 let cli = Cli::parse_from(["orodruin", "run"]);
591 assert_eq!(
592 cli.command,
593 Commands::Run(RunCommand {
594 env: None,
595 command: vec![],
596 })
597 );
598
599 let cli = Cli::parse_from(["orodruin", "run", "dev"]);
600 assert_eq!(
601 cli.command,
602 Commands::Run(RunCommand {
603 env: Some("dev".into()),
604 command: vec![],
605 })
606 );
607
608 let cli = Cli::parse_from(["orodruin", "run", "dev", "--", "date"]);
609 assert_eq!(
610 cli.command,
611 Commands::Run(RunCommand {
612 env: Some("dev".into()),
613 command: vec!["date".into()],
614 })
615 );
616 }
617
618 #[test]
619 fn parses_enter_without_environment() {
620 let cli = Cli::parse_from(["orodruin", "enter"]);
621 assert_eq!(cli.command, Commands::Enter(EnterCommand { env: None }));
622
623 let cli = Cli::parse_from(["orodruin", "enter", "dev"]);
624 assert_eq!(
625 cli.command,
626 Commands::Enter(EnterCommand {
627 env: Some("dev".into()),
628 })
629 );
630 }
631
632 #[test]
633 fn podman_runtime_prunes_unsupported_subcommands() {
634 let command = Cli::command_for_runtime(ContainerRuntime::Podman);
635 let registry = command.find_subcommand("registry").unwrap();
636 let builder = command.find_subcommand("builder").unwrap();
637 let system = command.find_subcommand("system").unwrap();
638 let machine = command.find_subcommand("machine").unwrap();
639
640 assert!(registry.find_subcommand("list").is_none());
641 assert!(registry.find_subcommand("login").is_some());
642 assert!(builder.find_subcommand("start").is_none());
643 assert!(builder.find_subcommand("status").is_some());
644 assert!(system.find_subcommand("dns").is_none());
645 assert!(system.find_subcommand("version").is_some());
646 assert!(machine.find_subcommand("set-default").is_none());
647 assert!(machine.find_subcommand("set").is_some());
648 }
649
650 #[test]
651 fn command_for_runtime_named_uses_the_requested_binary_name() {
652 let command = Cli::command_for_runtime_named(ContainerRuntime::Podman, "rui");
653
654 assert_eq!(command.get_bin_name(), Some("rui"));
655 }
656
657 #[test]
658 fn podman_runtime_parses_hidden_unsupported_subcommands() {
659 let cli = Cli::parse_for_runtime(["orodruin", "system", "dns"], ContainerRuntime::Podman)
660 .unwrap();
661 assert_eq!(
662 cli.command,
663 Commands::System(SystemCommands::Dns(OptionalPassthroughArgs {
664 args: vec![]
665 }))
666 );
667
668 let cli =
669 Cli::parse_for_runtime(["orodruin", "registry", "list"], ContainerRuntime::Podman)
670 .unwrap();
671 assert_eq!(
672 cli.command,
673 Commands::Registry(RegistryCommands::List(OptionalPassthroughArgs {
674 args: vec![]
675 }))
676 );
677 }
678}