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