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