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 load_config, resolve_config_template_output, write_config_schemas, 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, default_value = "schemas/config.schema.json")]
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 #[command(name = "config-validate")]
50 ConfigValidate,
51
52 Completions {
54 #[arg(value_enum)]
56 shell: Shell,
57 },
58
59 InstallCompletions {
61 #[arg(value_enum)]
63 shell: Shell,
64 },
65}
66
67pub 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
116pub 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
138pub 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
185fn 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
196struct ShellInstallTarget {
202 shell: Shell,
203 completion_dir: PathBuf,
204 rc_path: Option<PathBuf>,
205}
206
207impl ShellInstallTarget {
208 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 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
306pub 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;