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