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 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
117pub 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
139pub 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
186fn 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
197struct ShellInstallTarget {
203 shell: Shell,
204 completion_dir: PathBuf,
205 rc_path: Option<PathBuf>,
206}
207
208impl ShellInstallTarget {
209 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 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
307pub 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;