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.
87pub fn handle_config_command<C, S>(command: ConfigCommand, config_path: &Path) -> ConfigResult<()>
88where
89    C: CommandFactory,
90    S: ConfigSchema + JsonSchema,
91{
92    match command {
93        ConfigCommand::ConfigTemplate { output, schema } => {
94            let output = resolve_config_template_output(output)?;
95            match schema {
96                Some(schema) => {
97                    write_config_schemas::<S>(&schema)?;
98                    write_config_templates_with_schema::<S>(config_path, output, schema)
99                }
100                None => write_config_templates::<S>(config_path, output),
101            }
102        }
103        ConfigCommand::JsonSchema { output } => write_config_schemas::<S>(output),
104        ConfigCommand::ConfigValidate => {
105            load_config::<S>(config_path)?;
106            Ok(())
107        }
108        ConfigCommand::Completions { shell } => {
109            print_shell_completion::<C>(shell);
110            Ok(())
111        }
112        ConfigCommand::InstallCompletions { shell } => install_shell_completion::<C>(shell),
113    }
114}
115
116/// Writes shell completion output to stdout.
117///
118/// # Type Parameters
119///
120/// - `C`: The consumer CLI parser type used to build the clap command.
121///
122/// # Arguments
123///
124/// - `shell`: Shell whose completion script should be generated.
125///
126/// # Returns
127///
128/// This function writes to stdout and returns no value.
129pub fn print_shell_completion<C>(shell: Shell)
130where
131    C: CommandFactory,
132{
133    let mut cmd = C::command();
134    let bin_name = cmd.get_name().to_string();
135    generate(shell, &mut cmd, bin_name, &mut io::stdout());
136}
137
138/// Generates shell completion files and updates shell startup files when needed.
139///
140/// # Type Parameters
141///
142/// - `C`: The consumer CLI parser type used to build the clap command.
143///
144/// # Arguments
145///
146/// - `shell`: Shell whose completion file should be installed.
147///
148/// # Returns
149///
150/// Returns `Ok(())` after the completion file is generated and any required
151/// startup file has been updated.
152pub fn install_shell_completion<C>(shell: Shell) -> ConfigResult<()>
153where
154    C: CommandFactory,
155{
156    let home_dir = home_dir()
157        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "cannot find home directory"))?;
158    let target = ShellInstallTarget::new(shell, &home_dir)?;
159
160    fs::create_dir_all(&target.completion_dir)?;
161
162    let mut cmd = C::command();
163    let bin_name = cmd.get_name().to_string();
164    let generated_path = generate_to(shell, &mut cmd, bin_name.clone(), &target.completion_dir)?;
165
166    if let Some(ref rc_path) = target.rc_path {
167        let block_body = target
168            .rc_block_body(&generated_path, &target.completion_dir)
169            .ok_or_else(|| {
170                io::Error::new(
171                    io::ErrorKind::InvalidData,
172                    "completion install path is not valid UTF-8",
173                )
174            })?;
175        upsert_managed_block(&bin_name, shell, rc_path, &block_body)?;
176        println!("{shell} rc configured: {}", rc_path.display());
177    }
178
179    println!("{shell} completion generated: {}", generated_path.display());
180    println!("restart {shell} or open a new shell session");
181
182    Ok(())
183}
184
185/// Resolves the current user's home directory from environment variables.
186///
187/// # Returns
188///
189/// Returns the home directory when `HOME` or `USERPROFILE` is set.
190fn home_dir() -> Option<PathBuf> {
191    std::env::var_os("HOME")
192        .map(PathBuf::from)
193        .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
194}
195
196/// Completion and startup-file paths for one shell.
197///
198/// The completion directory receives the generated completion file. The
199/// optional startup path is updated only for shells that require explicit
200/// startup configuration.
201struct ShellInstallTarget {
202    shell: Shell,
203    completion_dir: PathBuf,
204    rc_path: Option<PathBuf>,
205}
206
207impl ShellInstallTarget {
208    /// Creates an install target rooted under `home_dir`.
209    ///
210    /// # Arguments
211    ///
212    /// - `shell`: Shell whose completion target should be created.
213    /// - `home_dir`: Home directory used as the base for completion and startup
214    ///   file paths.
215    ///
216    /// # Returns
217    ///
218    /// Returns the shell-specific install target.
219    fn new(shell: Shell, home_dir: &Path) -> ConfigResult<Self> {
220        let target = match shell {
221            Shell::Bash => Self {
222                shell,
223                completion_dir: home_dir.join(".bash_completion.d"),
224                rc_path: Some(home_dir.join(".bashrc")),
225            },
226            Shell::Elvish => Self {
227                shell,
228                completion_dir: home_dir.join(".config").join("elvish").join("lib"),
229                rc_path: Some(home_dir.join(".config").join("elvish").join("rc.elv")),
230            },
231            Shell::Fish => Self {
232                shell,
233                completion_dir: home_dir.join(".config").join("fish").join("completions"),
234                rc_path: None,
235            },
236            Shell::PowerShell => Self {
237                shell,
238                completion_dir: home_dir
239                    .join("Documents")
240                    .join("PowerShell")
241                    .join("Completions"),
242                rc_path: Some(
243                    home_dir
244                        .join("Documents")
245                        .join("PowerShell")
246                        .join("Microsoft.PowerShell_profile.ps1"),
247                ),
248            },
249            Shell::Zsh => Self {
250                shell,
251                completion_dir: home_dir.join(".zsh").join("completions"),
252                rc_path: Some(home_dir.join(".zshrc")),
253            },
254            _ => {
255                return Err(io::Error::new(
256                    io::ErrorKind::Unsupported,
257                    format!("unsupported shell: {shell}"),
258                )
259                .into());
260            }
261        };
262
263        Ok(target)
264    }
265
266    /// Builds the shell-specific startup block for a generated completion file.
267    ///
268    /// # Arguments
269    ///
270    /// - `generated_path`: Path to the generated completion file.
271    /// - `completion_dir`: Directory containing generated completion files.
272    ///
273    /// # Returns
274    ///
275    /// Returns the startup-file block body, or `None` when the shell does not
276    /// need startup-file changes.
277    fn rc_block_body(&self, generated_path: &Path, completion_dir: &Path) -> Option<String> {
278        let generated_path = generated_path.to_str()?;
279        let completion_dir = completion_dir.to_str()?;
280
281        let body = match self.shell {
282            Shell::Bash => {
283                format!("[[ -r \"{generated_path}\" ]] && source \"{generated_path}\"\n")
284            }
285            Shell::Elvish => format!("use {generated_path}\n"),
286            Shell::PowerShell => {
287                format!("if (Test-Path \"{generated_path}\") {{ . \"{generated_path}\" }}\n")
288            }
289            Shell::Zsh => format!(
290                concat!(
291                    "fpath=(\"{}\" $fpath)\n",
292                    "\n",
293                    "autoload -Uz compinit\n",
294                    "compinit\n",
295                ),
296                completion_dir,
297            ),
298            Shell::Fish => return None,
299            _ => return None,
300        };
301
302        Some(body)
303    }
304}
305
306/// Inserts or replaces a managed shell configuration block in a startup file.
307///
308/// The managed block is identified by the binary name and shell, allowing repeat
309/// installs to update the same block instead of appending duplicates.
310///
311/// # Arguments
312///
313/// - `bin_name`: Binary name used in the managed block markers.
314/// - `shell`: Shell whose startup block is being inserted or replaced.
315/// - `file_path`: Startup file to update.
316/// - `block_body`: Shell-specific content placed between the managed markers.
317///
318/// # Returns
319///
320/// Returns `Ok(())` after the startup file has been written.
321pub fn upsert_managed_block(
322    bin_name: &str,
323    shell: Shell,
324    file_path: &Path,
325    block_body: &str,
326) -> io::Result<()> {
327    let begin_marker = format!("# >>> {bin_name} {shell} completions >>>");
328    let end_marker = format!("# <<< {bin_name} {shell} completions <<<");
329
330    let existing = match fs::read_to_string(file_path) {
331        Ok(content) => content,
332        Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
333        Err(err) => return Err(err),
334    };
335
336    if let Some(parent) = file_path.parent() {
337        fs::create_dir_all(parent)?;
338    }
339
340    let managed_block = format!("{begin_marker}\n{block_body}\n{end_marker}\n");
341
342    let next_content = if let Some(begin_pos) = existing.find(&begin_marker) {
343        if let Some(relative_end_pos) = existing[begin_pos..].find(&end_marker) {
344            let end_pos = begin_pos + relative_end_pos + end_marker.len();
345
346            let before = existing[..begin_pos].trim_end();
347            let after = existing[end_pos..].trim_start();
348
349            match (before.is_empty(), after.is_empty()) {
350                (true, true) => managed_block,
351                (true, false) => format!("{managed_block}\n{after}"),
352                (false, true) => format!("{before}\n\n{managed_block}"),
353                (false, false) => format!("{before}\n\n{managed_block}\n{after}"),
354            }
355        } else {
356            return Err(io::Error::new(
357                io::ErrorKind::InvalidData,
358                format!("found `{begin_marker}` but missing `{end_marker}`"),
359            ));
360        }
361    } else {
362        let existing = existing.trim_end();
363
364        if existing.is_empty() {
365            managed_block
366        } else {
367            format!("{existing}\n\n{managed_block}")
368        }
369    };
370
371    fs::write(file_path, next_content)
372}
373
374#[cfg(test)]
375#[path = "unit_tests/cli.rs"]
376mod unit_tests;