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 completions into common shell
5//! startup locations.
6
7use std::{
8    fs, io,
9    path::{Path, PathBuf},
10};
11
12use clap::{CommandFactory, Subcommand};
13use clap_complete::aot::{Shell, generate, generate_to};
14use schemars::JsonSchema;
15
16use crate::{
17    ConfigResult, ConfigSchema,
18    config::{
19        load_config, resolve_config_template_output, write_config_schemas, write_config_templates,
20        write_config_templates_with_schema,
21    },
22};
23
24/// Built-in clap subcommands for config templates and shell completions.
25#[derive(Debug, Subcommand)]
26pub enum ConfigCommand {
27    /// Generate an example config template.
28    ///
29    /// The output format is inferred from the extension; unknown or missing extensions use YAML.
30    ConfigTemplate {
31        /// Template output path. Defaults to `config.example.yaml`.
32        #[arg(long)]
33        output: Option<PathBuf>,
34
35        /// Root JSON Schema path to write and bind from TOML/YAML templates.
36        #[arg(long, default_value = "schemas/config.schema.json")]
37        schema: Option<PathBuf>,
38    },
39
40    /// Generate JSON Schema files for editor completion and validation.
41    #[command(name = "config-schema")]
42    JsonSchema {
43        /// Root schema output path. Defaults to `schemas/config.schema.json`.
44        #[arg(long, default_value = "schemas/config.schema.json")]
45        output: PathBuf,
46    },
47
48    /// Validate the full runtime config tree.
49    #[command(name = "config-validate")]
50    ConfigValidate,
51
52    /// Generate shell completions.
53    Completions {
54        /// Shell to generate completions for.
55        #[arg(value_enum)]
56        shell: Shell,
57    },
58
59    /// Install shell completions and configure the shell startup file when needed.
60    InstallCompletions {
61        /// Shell to install completions for.
62        #[arg(value_enum)]
63        shell: Shell,
64    },
65}
66
67/// Handles a built-in config subcommand for a consumer CLI.
68///
69/// `C` is the clap parser type used to generate completion metadata. `S` is the
70/// application config schema used for template and JSON Schema generation.
71///
72/// # Type Parameters
73///
74/// - `C`: The consumer CLI parser type that implements [`CommandFactory`].
75/// - `S`: The consumer config schema used when rendering config templates and
76///   JSON Schema files.
77///
78/// # Arguments
79///
80/// - `command`: Built-in subcommand selected by the consumer CLI.
81/// - `config_path`: Root config path used as the template source when handling
82///   `config-template`.
83///
84/// # Returns
85///
86/// Returns `Ok(())` after the selected subcommand completes.
87///
88/// # Examples
89///
90/// ```no_run
91/// use clap::{Parser, Subcommand};
92/// use confique::Config;
93/// use rust_config_tree::{ConfigCommand, ConfigSchema, handle_config_command};
94/// use schemars::JsonSchema;
95///
96/// #[derive(Parser)]
97/// struct Cli {
98///     #[command(subcommand)]
99///     command: Command,
100/// }
101///
102/// #[derive(Subcommand)]
103/// enum Command {
104///     #[command(flatten)]
105///     Config(ConfigCommand),
106/// }
107///
108/// #[derive(Config, JsonSchema)]
109/// struct AppConfig {
110///     #[config(default = [])]
111///     include: Vec<std::path::PathBuf>,
112/// }
113///
114/// impl ConfigSchema for AppConfig {
115///     fn include_paths(layer: &<Self as Config>::Layer) -> Vec<std::path::PathBuf> {
116///         layer.include.clone().unwrap_or_default()
117///     }
118/// }
119///
120/// handle_config_command::<Cli, AppConfig>(
121///     ConfigCommand::ConfigValidate,
122///     std::path::Path::new("config.yaml"),
123/// )?;
124/// # Ok::<(), rust_config_tree::ConfigError>(())
125/// ```
126pub fn handle_config_command<C, S>(command: ConfigCommand, config_path: &Path) -> ConfigResult<()>
127where
128    C: CommandFactory,
129    S: ConfigSchema + JsonSchema,
130{
131    match command {
132        ConfigCommand::ConfigTemplate { output, schema } => {
133            let output = resolve_config_template_output(output)?;
134            match schema {
135                Some(schema) => {
136                    write_config_schemas::<S>(&schema)?;
137                    write_config_templates_with_schema::<S>(config_path, output, schema)
138                }
139                None => write_config_templates::<S>(config_path, output),
140            }
141        }
142        ConfigCommand::JsonSchema { output } => write_config_schemas::<S>(output),
143        ConfigCommand::ConfigValidate => {
144            load_config::<S>(config_path)?;
145            println!("Configuration is ok");
146            Ok(())
147        }
148        ConfigCommand::Completions { shell } => {
149            print_shell_completion::<C>(shell);
150            Ok(())
151        }
152        ConfigCommand::InstallCompletions { shell } => install_shell_completion::<C>(shell),
153    }
154}
155
156/// Writes shell completion output to stdout.
157///
158/// # Type Parameters
159///
160/// - `C`: The consumer CLI parser type used to build the clap command.
161///
162/// # Arguments
163///
164/// - `shell`: Shell whose completion script should be generated.
165///
166/// # Returns
167///
168/// This function writes to stdout and returns no value.
169///
170/// # Examples
171///
172/// ```no_run
173/// use clap::Parser;
174/// use clap_complete::aot::Shell;
175/// use rust_config_tree::print_shell_completion;
176///
177/// #[derive(Parser)]
178/// #[command(name = "myapp")]
179/// struct Cli {}
180///
181/// print_shell_completion::<Cli>(Shell::Bash);
182/// ```
183pub fn print_shell_completion<C>(shell: Shell)
184where
185    C: CommandFactory,
186{
187    let mut cmd = C::command();
188    let bin_name = cmd.get_name().to_string();
189    generate(shell, &mut cmd, bin_name, &mut io::stdout());
190}
191
192/// Generates shell completion files and updates shell startup files when needed.
193///
194/// # Type Parameters
195///
196/// - `C`: The consumer CLI parser type used to build the clap command.
197///
198/// # Arguments
199///
200/// - `shell`: Shell whose completion file should be installed.
201///
202/// # Returns
203///
204/// Returns `Ok(())` after the completion file is generated and any required
205/// startup file has been updated.
206///
207/// # Examples
208///
209/// ```no_run
210/// use clap::Parser;
211/// use clap_complete::aot::Shell;
212/// use rust_config_tree::install_shell_completion;
213///
214/// #[derive(Parser)]
215/// #[command(name = "myapp")]
216/// struct Cli {}
217///
218/// install_shell_completion::<Cli>(Shell::Zsh)?;
219/// # Ok::<(), rust_config_tree::ConfigError>(())
220/// ```
221pub fn install_shell_completion<C>(shell: Shell) -> ConfigResult<()>
222where
223    C: CommandFactory,
224{
225    let home_dir = home_dir()
226        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "cannot find home directory"))?;
227    let target = ShellInstallTarget::new(shell, &home_dir)?;
228
229    fs::create_dir_all(&target.completion_dir)?;
230
231    let mut cmd = C::command();
232    let bin_name = cmd.get_name().to_string();
233    let generated_path = generate_to(shell, &mut cmd, bin_name.clone(), &target.completion_dir)?;
234
235    if let Some(ref rc_path) = target.rc_path {
236        let block_body = target
237            .rc_block_body(&generated_path, &target.completion_dir)
238            .ok_or_else(|| {
239                io::Error::new(
240                    io::ErrorKind::InvalidData,
241                    "completion install path is not valid UTF-8",
242                )
243            })?;
244        upsert_managed_block(&bin_name, shell, rc_path, &block_body)?;
245        println!("{shell} rc configured: {}", rc_path.display());
246    }
247
248    println!("{shell} completion generated: {}", generated_path.display());
249    println!("restart {shell} or open a new shell session");
250
251    Ok(())
252}
253
254/// Resolves the current user's home directory from environment variables.
255///
256/// # Arguments
257///
258/// This function has no arguments.
259///
260/// # Returns
261///
262/// Returns the home directory when `HOME` or `USERPROFILE` is set.
263///
264/// # Examples
265///
266/// ```no_run
267/// // Internal helper; use `install_shell_completion` to resolve install paths.
268/// ```
269fn home_dir() -> Option<PathBuf> {
270    std::env::var_os("HOME")
271        .map(PathBuf::from)
272        .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
273}
274
275/// Completion and startup-file paths for one shell.
276///
277/// The completion directory receives the generated completion file. The
278/// optional startup path is updated only for shells that require explicit
279/// startup configuration.
280struct ShellInstallTarget {
281    shell: Shell,
282    completion_dir: PathBuf,
283    rc_path: Option<PathBuf>,
284}
285
286/// Shell-specific completion install path construction.
287impl ShellInstallTarget {
288    /// Creates an install target rooted under `home_dir`.
289    ///
290    /// # Arguments
291    ///
292    /// - `shell`: Shell whose completion target should be created.
293    /// - `home_dir`: Home directory used as the base for completion and startup
294    ///   file paths.
295    ///
296    /// # Returns
297    ///
298    /// Returns the shell-specific install target.
299    ///
300    /// # Examples
301    ///
302    /// ```no_run
303    /// // Internal helper; use `install_shell_completion` to construct targets.
304    /// ```
305    fn new(shell: Shell, home_dir: &Path) -> ConfigResult<Self> {
306        let target = match shell {
307            Shell::Bash => Self {
308                shell,
309                completion_dir: home_dir.join(".bash_completion.d"),
310                rc_path: Some(home_dir.join(".bashrc")),
311            },
312            Shell::Elvish => Self {
313                shell,
314                completion_dir: home_dir.join(".config").join("elvish").join("lib"),
315                rc_path: Some(home_dir.join(".config").join("elvish").join("rc.elv")),
316            },
317            Shell::Fish => Self {
318                shell,
319                completion_dir: home_dir.join(".config").join("fish").join("completions"),
320                rc_path: None,
321            },
322            Shell::PowerShell => Self {
323                shell,
324                completion_dir: home_dir
325                    .join("Documents")
326                    .join("PowerShell")
327                    .join("Completions"),
328                rc_path: Some(
329                    home_dir
330                        .join("Documents")
331                        .join("PowerShell")
332                        .join("Microsoft.PowerShell_profile.ps1"),
333                ),
334            },
335            Shell::Zsh => Self {
336                shell,
337                completion_dir: home_dir.join(".zsh").join("completions"),
338                rc_path: Some(home_dir.join(".zshrc")),
339            },
340            _ => {
341                return Err(io::Error::new(
342                    io::ErrorKind::Unsupported,
343                    format!("unsupported shell: {shell}"),
344                )
345                .into());
346            }
347        };
348
349        Ok(target)
350    }
351
352    /// Builds the shell-specific startup block for a generated completion file.
353    ///
354    /// # Arguments
355    ///
356    /// - `generated_path`: Path to the generated completion file.
357    /// - `completion_dir`: Directory containing generated completion files.
358    ///
359    /// # Returns
360    ///
361    /// Returns the startup-file block body, or `None` when the shell does not
362    /// need startup-file changes.
363    ///
364    /// # Examples
365    ///
366    /// ```no_run
367    /// // Internal helper; use `install_shell_completion` to generate rc blocks.
368    /// ```
369    fn rc_block_body(&self, generated_path: &Path, completion_dir: &Path) -> Option<String> {
370        let generated_path = generated_path.to_str()?;
371        let completion_dir = completion_dir.to_str()?;
372
373        let body = match self.shell {
374            Shell::Bash => {
375                format!("[[ -r \"{generated_path}\" ]] && source \"{generated_path}\"\n")
376            }
377            Shell::Elvish => format!("use {generated_path}\n"),
378            Shell::PowerShell => {
379                format!("if (Test-Path \"{generated_path}\") {{ . \"{generated_path}\" }}\n")
380            }
381            Shell::Zsh => format!(
382                concat!(
383                    "fpath=(\"{}\" $fpath)\n",
384                    "\n",
385                    "autoload -Uz compinit\n",
386                    "compinit\n",
387                ),
388                completion_dir,
389            ),
390            Shell::Fish => return None,
391            _ => return None,
392        };
393
394        Some(body)
395    }
396}
397
398/// Inserts or replaces a managed shell configuration block in a startup file.
399///
400/// The managed block is identified by the binary name and shell, allowing repeat
401/// installs to update the same block instead of appending duplicates.
402///
403/// # Arguments
404///
405/// - `bin_name`: Binary name used in the managed block markers.
406/// - `shell`: Shell whose startup block is being inserted or replaced.
407/// - `file_path`: Startup file to update.
408/// - `block_body`: Shell-specific content placed between the managed markers.
409///
410/// # Returns
411///
412/// Returns `Ok(())` after the startup file has been written.
413///
414/// # Examples
415///
416/// ```
417/// use std::fs;
418/// use clap_complete::aot::Shell;
419/// use rust_config_tree::upsert_managed_block;
420///
421/// let path = std::env::temp_dir().join("rust-config-tree-upsert-doctest.rc");
422/// upsert_managed_block("myapp", Shell::Bash, &path, "body\n")?;
423///
424/// let content = fs::read_to_string(&path)?;
425/// assert!(content.contains("# >>> myapp bash completions >>>"));
426/// assert!(content.contains("body"));
427/// # let _ = fs::remove_file(path);
428/// # Ok::<(), std::io::Error>(())
429/// ```
430pub fn upsert_managed_block(
431    bin_name: &str,
432    shell: Shell,
433    file_path: &Path,
434    block_body: &str,
435) -> io::Result<()> {
436    let begin_marker = format!("# >>> {bin_name} {shell} completions >>>");
437    let end_marker = format!("# <<< {bin_name} {shell} completions <<<");
438
439    let existing = match fs::read_to_string(file_path) {
440        Ok(content) => content,
441        Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
442        Err(err) => return Err(err),
443    };
444
445    if let Some(parent) = file_path.parent() {
446        fs::create_dir_all(parent)?;
447    }
448
449    let managed_block = format!("{begin_marker}\n{block_body}\n{end_marker}\n");
450
451    let next_content = if let Some(begin_pos) = existing.find(&begin_marker) {
452        if let Some(relative_end_pos) = existing[begin_pos..].find(&end_marker) {
453            let end_pos = begin_pos + relative_end_pos + end_marker.len();
454
455            let before = existing[..begin_pos].trim_end();
456            let after = existing[end_pos..].trim_start();
457
458            match (before.is_empty(), after.is_empty()) {
459                (true, true) => managed_block,
460                (true, false) => format!("{managed_block}\n{after}"),
461                (false, true) => format!("{before}\n\n{managed_block}"),
462                (false, false) => format!("{before}\n\n{managed_block}\n{after}"),
463            }
464        } else {
465            return Err(io::Error::new(
466                io::ErrorKind::InvalidData,
467                format!("found `{begin_marker}` but missing `{end_marker}`"),
468            ));
469        }
470    } else {
471        let existing = existing.trim_end();
472
473        if existing.is_empty() {
474            managed_block
475        } else {
476            format!("{existing}\n\n{managed_block}")
477        }
478    };
479
480    fs::write(file_path, next_content)
481}
482
483#[cfg(test)]
484#[path = "unit_tests/cli.rs"]
485mod unit_tests;