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