Skip to main content

rust_config_tree/
cli.rs

1//! Clap subcommand integration and shell completion installation helpers.
2//!
3//! This module exposes reusable commands for generating config templates,
4//! printing shell completions, and installing or uninstalling completions in
5//! common shell startup locations.
6
7use std::{
8    fs, io,
9    path::{Path, PathBuf},
10    time::{SystemTime, UNIX_EPOCH},
11};
12
13use clap::{CommandFactory, Subcommand};
14use clap_complete::{
15    Generator,
16    aot::{Shell, generate, generate_to},
17};
18use schemars::JsonSchema;
19
20use crate::{
21    config::{
22        ConfigResult, ConfigSchema, default_config_schema_output, load_config,
23        write_config_schemas, write_config_templates_with_schema,
24    },
25    config_output,
26};
27
28/// Built-in clap subcommands for config templates and shell completions.
29#[derive(Debug, Subcommand)]
30pub enum ConfigCommand {
31    /// Generate an example config template.
32    ///
33    /// The output format is inferred from the output path extension; unknown or missing extensions use YAML.
34    GenerateTemplate {
35        /// Template output path. Defaults to `config/<root-config-name>/<root-config-name>.example.yaml`.
36        #[arg(long)]
37        output: Option<PathBuf>,
38
39        /// Root JSON Schema path to write and bind from generated templates.
40        #[arg(long)]
41        schema: Option<PathBuf>,
42    },
43
44    /// Generate JSON Schema files for editor completion and validation.
45    #[command(name = "generate-schema")]
46    GenerateSchema {
47        /// Root schema output path. Defaults to `config/<root-config-name>/<root-config-name>.schema.json`.
48        #[arg(long)]
49        output: Option<PathBuf>,
50    },
51
52    /// Validate the full runtime config tree.
53    #[command(name = "validate-config")]
54    ValidateConfig {
55        /// Root config file to validate. Falls back to the default when omitted.
56        #[arg(long)]
57        config: Option<PathBuf>,
58    },
59
60    /// Generate shell completions.
61    Completions {
62        /// Shell to generate completions for.
63        #[arg(value_enum)]
64        shell: Shell,
65    },
66
67    /// Install shell completions and configure the shell startup file when needed.
68    InstallCompletions {
69        /// Shell to install completions for.
70        #[arg(value_enum)]
71        shell: Shell,
72    },
73
74    /// Uninstall shell completions and remove managed startup-file blocks.
75    UninstallCompletions {
76        /// Shell to uninstall completions for.
77        #[arg(value_enum)]
78        shell: Shell,
79    },
80}
81
82/// Handles a built-in config subcommand for a consumer CLI.
83///
84/// `C` is the clap parser type used to generate completion metadata. `S` is the
85/// application config schema used for template and JSON Schema generation.
86///
87/// # Type Parameters
88///
89/// - `C`: The consumer CLI parser type that implements [`CommandFactory`].
90/// - `S`: The consumer config schema used when rendering config templates and
91///   JSON Schema files.
92///
93/// # Arguments
94///
95/// - `command`: Built-in subcommand selected by the consumer CLI.
96/// - `config_path`: Root config path used as the template source when handling
97///   `generate-template`.
98///
99/// # Returns
100///
101/// Returns `Ok(())` after the selected subcommand completes.
102///
103/// # Examples
104///
105/// ```no_run
106/// use clap::{Parser, Subcommand};
107/// use confique::Config;
108/// use rust_config_tree::cli::{ConfigCommand, handle_config_command};
109/// use rust_config_tree::config::ConfigSchema;
110/// use schemars::JsonSchema;
111///
112/// #[derive(Parser)]
113/// struct Cli {
114///     #[command(subcommand)]
115///     command: Command,
116/// }
117///
118/// #[derive(Subcommand)]
119/// enum Command {
120///     #[command(flatten)]
121///     Config(ConfigCommand),
122/// }
123///
124/// #[derive(Config, JsonSchema)]
125/// struct AppConfig {
126///     #[config(default = [])]
127///     include: Vec<std::path::PathBuf>,
128/// }
129///
130/// impl ConfigSchema for AppConfig {
131///     fn include_paths(layer: &<Self as Config>::Layer) -> Vec<std::path::PathBuf> {
132///         layer.include.clone().unwrap_or_default()
133///     }
134/// }
135///
136/// handle_config_command::<Cli, AppConfig>(
137///     ConfigCommand::ValidateConfig { config: None },
138///     std::path::Path::new("config.yaml"),
139/// )?;
140/// # Ok::<(), rust_config_tree::error::ConfigError>(())
141/// ```
142pub fn handle_config_command<C, S>(command: ConfigCommand, config_path: &Path) -> ConfigResult<()>
143where
144    C: CommandFactory,
145    S: ConfigSchema + JsonSchema,
146{
147    match command {
148        ConfigCommand::GenerateTemplate { output, schema } => {
149            let output = config_output::resolve_config_template_output::<S>(output)?;
150            let schema = schema.unwrap_or_else(default_config_schema_output::<S>);
151            write_config_schemas::<S>(&schema)?;
152            write_config_templates_with_schema::<S>(config_path, output, schema)
153        }
154        ConfigCommand::GenerateSchema { output } => {
155            write_config_schemas::<S>(output.unwrap_or_else(default_config_schema_output::<S>))
156        }
157        ConfigCommand::ValidateConfig { config } => {
158            let path = config.as_deref().unwrap_or(config_path);
159            load_config::<S>(path)?;
160            println!("Configuration is ok");
161            Ok(())
162        }
163        ConfigCommand::Completions { shell } => {
164            print_shell_completion::<C>(shell);
165            Ok(())
166        }
167        ConfigCommand::InstallCompletions { shell } => install_shell_completion::<C>(shell),
168        ConfigCommand::UninstallCompletions { shell } => uninstall_shell_completion::<C>(shell),
169    }
170}
171
172/// Writes shell completion output to stdout.
173///
174/// # Type Parameters
175///
176/// - `C`: The consumer CLI parser type used to build the clap command.
177///
178/// # Arguments
179///
180/// - `shell`: Shell whose completion script should be generated.
181///
182/// # Returns
183///
184/// This function writes to stdout and returns no value.
185///
186/// # Examples
187///
188/// ```no_run
189/// use clap::Parser;
190/// use clap_complete::aot::Shell;
191/// use rust_config_tree::cli::print_shell_completion;
192///
193/// #[derive(Parser)]
194/// #[command(name = "myapp")]
195/// struct Cli {}
196///
197/// print_shell_completion::<Cli>(Shell::Bash);
198/// ```
199pub fn print_shell_completion<C>(shell: Shell)
200where
201    C: CommandFactory,
202{
203    let mut cmd = C::command();
204    let bin_name = cmd.get_name().to_string();
205    generate(shell, &mut cmd, bin_name, &mut io::stdout());
206}
207
208/// Generates shell completion files and updates shell startup files when needed.
209///
210/// # Type Parameters
211///
212/// - `C`: The consumer CLI parser type used to build the clap command.
213///
214/// # Arguments
215///
216/// - `shell`: Shell whose completion file should be installed.
217///
218/// # Returns
219///
220/// Returns `Ok(())` after the completion file is generated and any required
221/// startup file has been updated.
222///
223/// # Examples
224///
225/// ```no_run
226/// use clap::Parser;
227/// use clap_complete::aot::Shell;
228/// use rust_config_tree::cli::install_shell_completion;
229///
230/// #[derive(Parser)]
231/// #[command(name = "myapp")]
232/// struct Cli {}
233///
234/// install_shell_completion::<Cli>(Shell::Zsh)?;
235/// # Ok::<(), rust_config_tree::error::ConfigError>(())
236/// ```
237pub fn install_shell_completion<C>(shell: Shell) -> ConfigResult<()>
238where
239    C: CommandFactory,
240{
241    let home_dir = home_dir()
242        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "cannot find home directory"))?;
243    let target = ShellInstallTarget::new(shell, &home_dir)?;
244
245    fs::create_dir_all(&target.completion_dir)?;
246
247    let mut cmd = C::command();
248    let bin_name = cmd.get_name().to_string();
249    let generated_path = generate_to(shell, &mut cmd, bin_name.clone(), &target.completion_dir)?;
250
251    if let Some(ref rc_path) = target.rc_path {
252        let block_body = target
253            .rc_block_body(&generated_path, &target.completion_dir)
254            .ok_or_else(|| {
255                io::Error::new(
256                    io::ErrorKind::InvalidData,
257                    "completion install path is not valid UTF-8",
258                )
259            })?;
260        upsert_managed_block_with_backup_name(
261            &target.managed_block_name(&bin_name),
262            shell,
263            rc_path,
264            &block_body,
265            &bin_name,
266        )?;
267        println!("{shell} rc configured: {}", rc_path.display());
268    }
269
270    println!("{shell} completion generated: {}", generated_path.display());
271    println!("restart {shell} or open a new shell session");
272
273    Ok(())
274}
275
276/// Removes shell completion files and managed shell startup-file blocks.
277///
278/// # Type Parameters
279///
280/// - `C`: The consumer CLI parser type used to build the clap command.
281///
282/// # Arguments
283///
284/// - `shell`: Shell whose completion file should be removed.
285///
286/// # Returns
287///
288/// Returns `Ok(())` after the completion file is removed and any managed
289/// startup-file block has been removed. Existing startup files are backed up
290/// before being modified.
291///
292/// # Examples
293///
294/// ```no_run
295/// use clap::Parser;
296/// use clap_complete::aot::Shell;
297/// use rust_config_tree::cli::uninstall_shell_completion;
298///
299/// #[derive(Parser)]
300/// #[command(name = "myapp")]
301/// struct Cli {}
302///
303/// uninstall_shell_completion::<Cli>(Shell::Zsh)?;
304/// # Ok::<(), rust_config_tree::error::ConfigError>(())
305/// ```
306pub fn uninstall_shell_completion<C>(shell: Shell) -> ConfigResult<()>
307where
308    C: CommandFactory,
309{
310    let home_dir = home_dir()
311        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "cannot find home directory"))?;
312    let target = ShellInstallTarget::new(shell, &home_dir)?;
313
314    let cmd = C::command();
315    let bin_name = cmd.get_name().to_string();
316    let completion_path = target.completion_file_path(&bin_name);
317
318    remove_completion_file(&completion_path)?;
319
320    if let Some(ref rc_path) = target.rc_path {
321        let removed_rc = if shell == Shell::Zsh {
322            if completion_dir_is_empty(&target.completion_dir)? {
323                remove_managed_block_with_backup_name(
324                    &target.managed_block_name(&bin_name),
325                    shell,
326                    rc_path,
327                    &bin_name,
328                )?
329            } else {
330                false
331            }
332        } else {
333            remove_managed_block_with_backup_name(
334                &target.managed_block_name(&bin_name),
335                shell,
336                rc_path,
337                &bin_name,
338            )?
339        };
340
341        if removed_rc {
342            println!("{shell} rc unconfigured: {}", rc_path.display());
343        }
344    }
345
346    println!("{shell} completion removed: {}", completion_path.display());
347    println!("restart {shell} or open a new shell session");
348
349    Ok(())
350}
351
352/// Resolves the current user's home directory from environment variables.
353///
354/// # Arguments
355///
356/// This function has no arguments.
357///
358/// # Returns
359///
360/// Returns the home directory when `HOME` or `USERPROFILE` is set.
361///
362/// # Examples
363///
364/// ```no_run
365/// // Internal helper; use `install_shell_completion` to resolve install paths.
366/// ```
367fn home_dir() -> Option<PathBuf> {
368    std::env::var_os("HOME")
369        .map(PathBuf::from)
370        .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
371}
372
373/// Completion and startup-file paths for one shell.
374///
375/// The completion directory receives the generated completion file. The
376/// optional startup path is updated only for shells that require explicit
377/// startup configuration.
378struct ShellInstallTarget {
379    shell: Shell,
380    completion_dir: PathBuf,
381    rc_path: Option<PathBuf>,
382}
383
384/// Shell-specific completion install path construction.
385impl ShellInstallTarget {
386    /// Creates an install target rooted under `home_dir`.
387    ///
388    /// # Arguments
389    ///
390    /// - `shell`: Shell whose completion target should be created.
391    /// - `home_dir`: Home directory used as the base for completion and startup
392    ///   file paths.
393    ///
394    /// # Returns
395    ///
396    /// Returns the shell-specific install target.
397    ///
398    /// # Examples
399    ///
400    /// ```no_run
401    /// // Internal helper; use `install_shell_completion` to construct targets.
402    /// ```
403    fn new(shell: Shell, home_dir: &Path) -> ConfigResult<Self> {
404        let target = match shell {
405            Shell::Bash => Self {
406                shell,
407                completion_dir: home_dir.join(".bash_completion.d"),
408                rc_path: Some(home_dir.join(".bashrc")),
409            },
410            Shell::Elvish => Self {
411                shell,
412                completion_dir: home_dir.join(".config").join("elvish").join("lib"),
413                rc_path: Some(home_dir.join(".config").join("elvish").join("rc.elv")),
414            },
415            Shell::Fish => Self {
416                shell,
417                completion_dir: home_dir.join(".config").join("fish").join("completions"),
418                rc_path: None,
419            },
420            Shell::PowerShell => Self {
421                shell,
422                completion_dir: home_dir
423                    .join("Documents")
424                    .join("PowerShell")
425                    .join("Completions"),
426                rc_path: Some(
427                    home_dir
428                        .join("Documents")
429                        .join("PowerShell")
430                        .join("Microsoft.PowerShell_profile.ps1"),
431                ),
432            },
433            Shell::Zsh => Self {
434                shell,
435                completion_dir: home_dir.join(".zsh").join("completions"),
436                rc_path: Some(home_dir.join(".zshrc")),
437            },
438            _ => {
439                return Err(io::Error::new(
440                    io::ErrorKind::Unsupported,
441                    format!("unsupported shell: {shell}"),
442                )
443                .into());
444            }
445        };
446
447        Ok(target)
448    }
449
450    /// Builds the shell-specific startup block for a generated completion file.
451    ///
452    /// # Arguments
453    ///
454    /// - `generated_path`: Path to the generated completion file.
455    /// - `completion_dir`: Directory containing generated completion files.
456    ///
457    /// # Returns
458    ///
459    /// Returns the startup-file block body, or `None` when the shell does not
460    /// need startup-file changes.
461    ///
462    /// # Examples
463    ///
464    /// ```no_run
465    /// // Internal helper; use `install_shell_completion` to generate rc blocks.
466    /// ```
467    fn rc_block_body(&self, generated_path: &Path, completion_dir: &Path) -> Option<String> {
468        let generated_path = generated_path.to_str()?;
469        let completion_dir = completion_dir.to_str()?;
470
471        let body = match self.shell {
472            Shell::Bash => {
473                format!("[[ -r \"{generated_path}\" ]] && source \"{generated_path}\"\n")
474            }
475            Shell::Elvish => format!("use {generated_path}\n"),
476            Shell::PowerShell => {
477                format!("if (Test-Path \"{generated_path}\") {{ . \"{generated_path}\" }}\n")
478            }
479            Shell::Zsh => format!(
480                concat!(
481                    "typeset -U fpath\n",
482                    "fpath=(\"{}\" $fpath)\n",
483                    "\n",
484                    "autoload -Uz compinit\n",
485                    "compinit\n",
486                ),
487                completion_dir,
488            ),
489            Shell::Fish => return None,
490            _ => return None,
491        };
492
493        Some(body)
494    }
495
496    fn completion_file_path(&self, bin_name: &str) -> PathBuf {
497        self.completion_dir.join(self.shell.file_name(bin_name))
498    }
499
500    fn managed_block_name(&self, bin_name: &str) -> String {
501        match self.shell {
502            Shell::Zsh => "rust-config-tree".to_owned(),
503            _ => bin_name.to_owned(),
504        }
505    }
506}
507
508/// Inserts or replaces a managed shell configuration block in a startup file.
509///
510/// The managed block is identified by the binary name and shell, allowing repeat
511/// installs to update the same block instead of appending duplicates.
512/// Existing startup files are backed up before being modified.
513///
514/// # Arguments
515///
516/// - `bin_name`: Binary name used in the managed block markers.
517/// - `shell`: Shell whose startup block is being inserted or replaced.
518/// - `file_path`: Startup file to update.
519/// - `block_body`: Shell-specific content placed between the managed markers.
520///
521/// # Returns
522///
523/// Returns `Ok(())` after the startup file has been written.
524///
525/// # Examples
526///
527/// ```
528/// use std::fs;
529/// use clap_complete::aot::Shell;
530/// use rust_config_tree::cli::upsert_managed_block;
531///
532/// let path = std::env::temp_dir().join("rust-config-tree-upsert-doctest.rc");
533/// upsert_managed_block("myapp", Shell::Bash, &path, "body\n")?;
534///
535/// let content = fs::read_to_string(&path)?;
536/// assert!(content.contains("# >>> myapp bash completions >>>"));
537/// assert!(content.contains("body"));
538/// # let _ = fs::remove_file(path);
539/// # Ok::<(), std::io::Error>(())
540/// ```
541pub fn upsert_managed_block(
542    bin_name: &str,
543    shell: Shell,
544    file_path: &Path,
545    block_body: &str,
546) -> io::Result<()> {
547    upsert_managed_block_with_backup_name(bin_name, shell, file_path, block_body, bin_name)
548}
549
550fn upsert_managed_block_with_backup_name(
551    block_name: &str,
552    shell: Shell,
553    file_path: &Path,
554    block_body: &str,
555    backup_name: &str,
556) -> io::Result<()> {
557    let (begin_marker, end_marker) = managed_block_markers(block_name, shell);
558
559    let existing = match fs::read_to_string(file_path) {
560        Ok(content) => content,
561        Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
562        Err(err) => return Err(err),
563    };
564
565    if let Some(parent) = file_path.parent() {
566        fs::create_dir_all(parent)?;
567    }
568
569    let managed_block = format!("{begin_marker}\n{block_body}\n{end_marker}\n");
570
571    let next_content = if let Some(begin_pos) = existing.find(&begin_marker) {
572        if let Some(relative_end_pos) = existing[begin_pos..].find(&end_marker) {
573            let end_pos = begin_pos + relative_end_pos + end_marker.len();
574
575            let before = existing[..begin_pos].trim_end();
576            let after = existing[end_pos..].trim_start();
577
578            match (before.is_empty(), after.is_empty()) {
579                (true, true) => managed_block,
580                (true, false) => format!("{managed_block}\n{after}"),
581                (false, true) => format!("{before}\n\n{managed_block}"),
582                (false, false) => format!("{before}\n\n{managed_block}\n{after}"),
583            }
584        } else {
585            return Err(io::Error::new(
586                io::ErrorKind::InvalidData,
587                format!("found `{begin_marker}` but missing `{end_marker}`"),
588            ));
589        }
590    } else {
591        let existing = existing.trim_end();
592
593        if existing.is_empty() {
594            managed_block
595        } else {
596            format!("{existing}\n\n{managed_block}")
597        }
598    };
599
600    write_startup_file_if_changed(file_path, &existing, next_content, backup_name)
601}
602
603#[cfg(test)]
604fn remove_managed_block(bin_name: &str, shell: Shell, file_path: &Path) -> io::Result<bool> {
605    remove_managed_block_with_backup_name(bin_name, shell, file_path, bin_name)
606}
607
608fn remove_managed_block_with_backup_name(
609    block_name: &str,
610    shell: Shell,
611    file_path: &Path,
612    backup_name: &str,
613) -> io::Result<bool> {
614    let (begin_marker, end_marker) = managed_block_markers(block_name, shell);
615
616    let existing = match fs::read_to_string(file_path) {
617        Ok(content) => content,
618        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false),
619        Err(err) => return Err(err),
620    };
621
622    let Some(begin_pos) = existing.find(&begin_marker) else {
623        return Ok(false);
624    };
625
626    let Some(relative_end_pos) = existing[begin_pos..].find(&end_marker) else {
627        return Err(io::Error::new(
628            io::ErrorKind::InvalidData,
629            format!("found `{begin_marker}` but missing `{end_marker}`"),
630        ));
631    };
632
633    let end_pos = begin_pos + relative_end_pos + end_marker.len();
634    let before = existing[..begin_pos].trim_end();
635    let after = existing[end_pos..].trim_start();
636
637    let next_content = match (before.is_empty(), after.is_empty()) {
638        (true, true) => String::new(),
639        (true, false) => after.to_owned(),
640        (false, true) => format!("{before}\n"),
641        (false, false) => format!("{before}\n\n{after}"),
642    };
643
644    write_startup_file_if_changed(file_path, &existing, next_content, backup_name)?;
645    Ok(true)
646}
647
648fn remove_completion_file(path: &Path) -> io::Result<()> {
649    match fs::remove_file(path) {
650        Ok(()) => Ok(()),
651        Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
652        Err(err) => Err(err),
653    }
654}
655
656fn completion_dir_is_empty(path: &Path) -> io::Result<bool> {
657    match fs::read_dir(path) {
658        Ok(mut entries) => Ok(entries.next().is_none()),
659        Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(true),
660        Err(err) => Err(err),
661    }
662}
663
664fn managed_block_markers(block_name: &str, shell: Shell) -> (String, String) {
665    (
666        format!("# >>> {block_name} {shell} completions >>>"),
667        format!("# <<< {block_name} {shell} completions <<<"),
668    )
669}
670
671fn write_startup_file_if_changed(
672    file_path: &Path,
673    existing: &str,
674    next_content: String,
675    backup_name: &str,
676) -> io::Result<()> {
677    if existing == next_content {
678        return Ok(());
679    }
680
681    if !existing.is_empty() || file_path.exists() {
682        backup_startup_file(file_path, backup_name)?;
683    }
684
685    fs::write(file_path, next_content)
686}
687
688fn backup_startup_file(file_path: &Path, backup_name: &str) -> io::Result<PathBuf> {
689    let file_name = file_path
690        .file_name()
691        .and_then(|value| value.to_str())
692        .ok_or_else(|| {
693            io::Error::new(
694                io::ErrorKind::InvalidInput,
695                "startup file path does not have a valid UTF-8 file name",
696            )
697        })?;
698    let backup_name = backup_file_name_part(backup_name);
699    let timestamp = SystemTime::now()
700        .duration_since(UNIX_EPOCH)
701        .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?
702        .as_nanos();
703    let backup_file_name = format!("{file_name}.backup.by.{backup_name}.{timestamp}");
704    let backup_path = file_path.with_file_name(backup_file_name);
705
706    fs::copy(file_path, &backup_path)?;
707    Ok(backup_path)
708}
709
710fn backup_file_name_part(value: &str) -> String {
711    value
712        .chars()
713        .map(|ch| match ch {
714            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => ch,
715            _ => '_',
716        })
717        .collect()
718}
719
720#[cfg(test)]
721#[path = "unit_tests/cli.rs"]
722mod unit_tests;