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