1use 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#[derive(Debug, Subcommand)]
26pub enum ConfigCommand {
27 ConfigTemplate {
31 #[arg(long)]
33 output: Option<PathBuf>,
34
35 #[arg(long)]
37 schema: Option<PathBuf>,
38 },
39
40 #[command(name = "config-schema")]
42 JsonSchema {
43 #[arg(long, default_value = "schemas/config.schema.json")]
45 output: PathBuf,
46 },
47
48 Completions {
50 #[arg(value_enum)]
52 shell: Shell,
53 },
54
55 InstallCompletions {
57 #[arg(value_enum)]
59 shell: Shell,
60 },
61}
62
63pub 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
107pub 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
129pub 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
176fn 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
187struct ShellInstallTarget {
193 shell: Shell,
194 completion_dir: PathBuf,
195 rc_path: Option<PathBuf>,
196}
197
198impl ShellInstallTarget {
199 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 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
297pub 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;