|
1 | 1 | use std::process; |
2 | 2 |
|
3 | 3 | use anyhow::{Context, Result}; |
4 | | -use clap::{Parser, Subcommand}; |
| 4 | +use clap::{CommandFactory, Parser, Subcommand}; |
5 | 5 | use cmd::config::{handle_config_get, handle_config_list, handle_config_set}; |
6 | 6 | use cmd::deps::build_deps; |
7 | 7 | use cmd::execute::execute; |
@@ -30,16 +30,31 @@ mod service; |
30 | 30 | mod util; |
31 | 31 |
|
32 | 32 | use crate::constants::cmd::{ |
33 | | - CLEAN_ABOUT, CLEAN_ALIAS, CLEAN_NAME, CONFIG_ABOUT, CONFIG_ALIAS, CONFIG_NAME, DEPS_ABOUT, |
34 | | - DEPS_ALIAS, DEPS_NAME, EXECUTE_ABOUT, EXECUTE_ALIAS, EXECUTE_NAME, INIT_ABOUT, INIT_ALIAS, |
35 | | - INIT_NAME, INSTALL_ABOUT, INSTALL_ALIAS, INSTALL_NAME, LINK_ABOUT, LINK_ALIAS, LINK_NAME, |
36 | | - LIST_ALIAS, LIST_NAME, REBUILD_ABOUT, REBUILD_ALIAS, REBUILD_NAME, RUN_ALIAS, RUN_NAME, |
37 | | - UNINSTALL_ABOUT, UNINSTALL_ALIAS, UNINSTALL_NAME, UPDATE_ABOUT, UPDATE_ALIAS, UPDATE_NAME, |
38 | | - VIEW_ABOUT, VIEW_ALIAS, VIEW_ALIAS_INFO, VIEW_ALIAS_SHOW, VIEW_NAME, |
| 33 | + CLEAN_ABOUT, CLEAN_ALIAS, CLEAN_NAME, COMPLETIONS_ABOUT, COMPLETIONS_NAME, CONFIG_ABOUT, |
| 34 | + CONFIG_ALIAS, CONFIG_NAME, DEPS_ABOUT, DEPS_ALIAS, DEPS_NAME, EXECUTE_ABOUT, EXECUTE_ALIAS, |
| 35 | + EXECUTE_NAME, INIT_ABOUT, INIT_ALIAS, INIT_NAME, INSTALL_ABOUT, INSTALL_ALIAS, INSTALL_NAME, |
| 36 | + LINK_ABOUT, LINK_ALIAS, LINK_NAME, LIST_ALIAS, LIST_NAME, REBUILD_ABOUT, REBUILD_ALIAS, |
| 37 | + REBUILD_NAME, RUN_ALIAS, RUN_NAME, UNINSTALL_ABOUT, UNINSTALL_ALIAS, UNINSTALL_NAME, |
| 38 | + UPDATE_ABOUT, UPDATE_ALIAS, UPDATE_NAME, VIEW_ABOUT, VIEW_ALIAS, VIEW_ALIAS_INFO, |
| 39 | + VIEW_ALIAS_SHOW, VIEW_NAME, |
39 | 40 | }; |
40 | 41 | use crate::constants::{APP_ABOUT, APP_NAME, APP_VERSION}; |
41 | 42 | use crate::helper::workspace::update_cwd_to_root; |
42 | 43 |
|
| 44 | +fn detect_shell_from_env() -> Option<clap_complete::Shell> { |
| 45 | + // Most common on Unix-like systems. |
| 46 | + let shell_path = std::env::var("SHELL").ok()?; |
| 47 | + let name = shell_path.rsplit('/').next().unwrap_or(shell_path.as_str()); |
| 48 | + |
| 49 | + match name { |
| 50 | + "bash" => Some(clap_complete::Shell::Bash), |
| 51 | + "zsh" => Some(clap_complete::Shell::Zsh), |
| 52 | + "fish" => Some(clap_complete::Shell::Fish), |
| 53 | + // Leave PowerShell + Elvish to explicit flags; auto-detect tends to be unreliable. |
| 54 | + _ => None, |
| 55 | + } |
| 56 | +} |
| 57 | + |
43 | 58 | #[derive(Parser)] |
44 | 59 | #[command(name = APP_NAME)] |
45 | 60 | #[command(version = APP_VERSION)] |
@@ -257,6 +272,14 @@ enum Commands { |
257 | 272 | #[arg(long, short)] |
258 | 273 | yes: bool, |
259 | 274 | }, |
| 275 | + |
| 276 | + /// Generate shell completion scripts |
| 277 | + #[command(name = COMPLETIONS_NAME, about = COMPLETIONS_ABOUT)] |
| 278 | + Completions { |
| 279 | + /// Shell to generate completions for (auto-detected if omitted) |
| 280 | + #[arg(value_enum)] |
| 281 | + shell: Option<clap_complete::Shell>, |
| 282 | + }, |
260 | 283 | } |
261 | 284 |
|
262 | 285 | fn main() { |
@@ -307,6 +330,27 @@ async fn async_main() -> Result<()> { |
307 | 330 | return Ok(()); |
308 | 331 | } |
309 | 332 |
|
| 333 | + // Handle completions early to avoid unnecessary initialization (tracing, registry, auto-update) |
| 334 | + if let Some(Commands::Completions { shell }) = cli.command { |
| 335 | + let shell = shell.or_else(detect_shell_from_env); |
| 336 | + |
| 337 | + let Some(shell) = shell else { |
| 338 | + eprintln!( |
| 339 | + "Could not detect shell. Usage: utoo completions <bash|zsh|fish|powershell|elvish>" |
| 340 | + ); |
| 341 | + process::exit(2); |
| 342 | + }; |
| 343 | + |
| 344 | + tokio::task::spawn_blocking(move || { |
| 345 | + let mut cmd = Cli::command(); |
| 346 | + clap_complete::generate(shell, &mut cmd, APP_NAME, &mut std::io::stdout()); |
| 347 | + }) |
| 348 | + .await |
| 349 | + .context("Failed to generate shell completions")?; |
| 350 | + |
| 351 | + return Ok(()); |
| 352 | + } |
| 353 | + |
310 | 354 | // Initialize tracing (replaces set_verbose) |
311 | 355 | let (log_file, _guard) = init_tracing(cli.verbose).context("Failed to initialize logging")?; |
312 | 356 |
|
@@ -541,7 +585,44 @@ async fn async_main() -> Result<()> { |
541 | 585 | log_time_end("All packages installed"); |
542 | 586 | } |
543 | 587 | } |
| 588 | + // Completions is handled early before initialization |
| 589 | + Some(Commands::Completions { .. }) => unreachable!(), |
544 | 590 | } |
545 | 591 |
|
546 | 592 | Ok(()) |
547 | 593 | } |
| 594 | + |
| 595 | +#[cfg(test)] |
| 596 | +mod tests { |
| 597 | + use super::*; |
| 598 | + use clap::CommandFactory; |
| 599 | + |
| 600 | + #[test] |
| 601 | + fn test_cli_debug_assert() { |
| 602 | + // Validates that the clap command definition has no conflicts or issues |
| 603 | + Cli::command().debug_assert(); |
| 604 | + } |
| 605 | + |
| 606 | + #[test] |
| 607 | + fn test_completions_generates_output() { |
| 608 | + for shell in [ |
| 609 | + clap_complete::Shell::Bash, |
| 610 | + clap_complete::Shell::Zsh, |
| 611 | + clap_complete::Shell::Fish, |
| 612 | + clap_complete::Shell::PowerShell, |
| 613 | + clap_complete::Shell::Elvish, |
| 614 | + ] { |
| 615 | + let mut buf = Vec::new(); |
| 616 | + clap_complete::generate(shell, &mut Cli::command(), APP_NAME, &mut buf); |
| 617 | + let output = String::from_utf8(buf).expect("completion output should be valid UTF-8"); |
| 618 | + assert!( |
| 619 | + !output.is_empty(), |
| 620 | + "{shell} completion should produce output" |
| 621 | + ); |
| 622 | + assert!( |
| 623 | + output.contains("install"), |
| 624 | + "{shell} completion should contain subcommands" |
| 625 | + ); |
| 626 | + } |
| 627 | + } |
| 628 | +} |
0 commit comments