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