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;