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