1use std::{
8 fs, io,
9 path::{Path, PathBuf},
10 time::{SystemTime, UNIX_EPOCH},
11};
12
13use clap::{CommandFactory, Subcommand};
14use clap_complete::{
15 Generator,
16 aot::{Shell, generate, generate_to},
17};
18use schemars::JsonSchema;
19
20use crate::{
21 ConfigResult, ConfigSchema,
22 config::{
23 default_config_schema_output, load_config, write_config_schemas,
24 write_config_templates_with_schema,
25 },
26 config_output,
27};
28
29#[derive(Debug, Subcommand)]
31pub enum ConfigCommand {
32 GenerateTemplate {
36 #[arg(long)]
38 output: Option<PathBuf>,
39
40 #[arg(long)]
42 schema: Option<PathBuf>,
43 },
44
45 #[command(name = "generate-schema")]
47 GenerateSchema {
48 #[arg(long)]
50 output: Option<PathBuf>,
51 },
52
53 #[command(name = "validate-config")]
55 ValidateConfig,
56
57 Completions {
59 #[arg(value_enum)]
61 shell: Shell,
62 },
63
64 InstallCompletions {
66 #[arg(value_enum)]
68 shell: Shell,
69 },
70
71 UninstallCompletions {
73 #[arg(value_enum)]
75 shell: Shell,
76 },
77}
78
79pub fn handle_config_command<C, S>(command: ConfigCommand, config_path: &Path) -> ConfigResult<()>
139where
140 C: CommandFactory,
141 S: ConfigSchema + JsonSchema,
142{
143 match command {
144 ConfigCommand::GenerateTemplate { output, schema } => {
145 let output = config_output::resolve_config_template_output::<S>(output)?;
146 let schema = schema.unwrap_or_else(default_config_schema_output::<S>);
147 write_config_schemas::<S>(&schema)?;
148 write_config_templates_with_schema::<S>(config_path, output, schema)
149 }
150 ConfigCommand::GenerateSchema { output } => {
151 write_config_schemas::<S>(output.unwrap_or_else(default_config_schema_output::<S>))
152 }
153 ConfigCommand::ValidateConfig => {
154 load_config::<S>(config_path)?;
155 println!("Configuration is ok");
156 Ok(())
157 }
158 ConfigCommand::Completions { shell } => {
159 print_shell_completion::<C>(shell);
160 Ok(())
161 }
162 ConfigCommand::InstallCompletions { shell } => install_shell_completion::<C>(shell),
163 ConfigCommand::UninstallCompletions { shell } => uninstall_shell_completion::<C>(shell),
164 }
165}
166
167pub fn print_shell_completion<C>(shell: Shell)
195where
196 C: CommandFactory,
197{
198 let mut cmd = C::command();
199 let bin_name = cmd.get_name().to_string();
200 generate(shell, &mut cmd, bin_name, &mut io::stdout());
201}
202
203pub fn install_shell_completion<C>(shell: Shell) -> ConfigResult<()>
233where
234 C: CommandFactory,
235{
236 let home_dir = home_dir()
237 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "cannot find home directory"))?;
238 let target = ShellInstallTarget::new(shell, &home_dir)?;
239
240 fs::create_dir_all(&target.completion_dir)?;
241
242 let mut cmd = C::command();
243 let bin_name = cmd.get_name().to_string();
244 let generated_path = generate_to(shell, &mut cmd, bin_name.clone(), &target.completion_dir)?;
245
246 if let Some(ref rc_path) = target.rc_path {
247 let block_body = target
248 .rc_block_body(&generated_path, &target.completion_dir)
249 .ok_or_else(|| {
250 io::Error::new(
251 io::ErrorKind::InvalidData,
252 "completion install path is not valid UTF-8",
253 )
254 })?;
255 upsert_managed_block_with_backup_name(
256 &target.managed_block_name(&bin_name),
257 shell,
258 rc_path,
259 &block_body,
260 &bin_name,
261 )?;
262 println!("{shell} rc configured: {}", rc_path.display());
263 }
264
265 println!("{shell} completion generated: {}", generated_path.display());
266 println!("restart {shell} or open a new shell session");
267
268 Ok(())
269}
270
271pub fn uninstall_shell_completion<C>(shell: Shell) -> ConfigResult<()>
302where
303 C: CommandFactory,
304{
305 let home_dir = home_dir()
306 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "cannot find home directory"))?;
307 let target = ShellInstallTarget::new(shell, &home_dir)?;
308
309 let cmd = C::command();
310 let bin_name = cmd.get_name().to_string();
311 let completion_path = target.completion_file_path(&bin_name);
312
313 remove_completion_file(&completion_path)?;
314
315 if let Some(ref rc_path) = target.rc_path {
316 let removed_rc = if shell == Shell::Zsh {
317 if completion_dir_is_empty(&target.completion_dir)? {
318 remove_managed_block_with_backup_name(
319 &target.managed_block_name(&bin_name),
320 shell,
321 rc_path,
322 &bin_name,
323 )?
324 } else {
325 false
326 }
327 } else {
328 remove_managed_block_with_backup_name(
329 &target.managed_block_name(&bin_name),
330 shell,
331 rc_path,
332 &bin_name,
333 )?
334 };
335
336 if removed_rc {
337 println!("{shell} rc unconfigured: {}", rc_path.display());
338 }
339 }
340
341 println!("{shell} completion removed: {}", completion_path.display());
342 println!("restart {shell} or open a new shell session");
343
344 Ok(())
345}
346
347fn home_dir() -> Option<PathBuf> {
363 std::env::var_os("HOME")
364 .map(PathBuf::from)
365 .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
366}
367
368struct ShellInstallTarget {
374 shell: Shell,
375 completion_dir: PathBuf,
376 rc_path: Option<PathBuf>,
377}
378
379impl ShellInstallTarget {
381 fn new(shell: Shell, home_dir: &Path) -> ConfigResult<Self> {
399 let target = match shell {
400 Shell::Bash => Self {
401 shell,
402 completion_dir: home_dir.join(".bash_completion.d"),
403 rc_path: Some(home_dir.join(".bashrc")),
404 },
405 Shell::Elvish => Self {
406 shell,
407 completion_dir: home_dir.join(".config").join("elvish").join("lib"),
408 rc_path: Some(home_dir.join(".config").join("elvish").join("rc.elv")),
409 },
410 Shell::Fish => Self {
411 shell,
412 completion_dir: home_dir.join(".config").join("fish").join("completions"),
413 rc_path: None,
414 },
415 Shell::PowerShell => Self {
416 shell,
417 completion_dir: home_dir
418 .join("Documents")
419 .join("PowerShell")
420 .join("Completions"),
421 rc_path: Some(
422 home_dir
423 .join("Documents")
424 .join("PowerShell")
425 .join("Microsoft.PowerShell_profile.ps1"),
426 ),
427 },
428 Shell::Zsh => Self {
429 shell,
430 completion_dir: home_dir.join(".zsh").join("completions"),
431 rc_path: Some(home_dir.join(".zshrc")),
432 },
433 _ => {
434 return Err(io::Error::new(
435 io::ErrorKind::Unsupported,
436 format!("unsupported shell: {shell}"),
437 )
438 .into());
439 }
440 };
441
442 Ok(target)
443 }
444
445 fn rc_block_body(&self, generated_path: &Path, completion_dir: &Path) -> Option<String> {
463 let generated_path = generated_path.to_str()?;
464 let completion_dir = completion_dir.to_str()?;
465
466 let body = match self.shell {
467 Shell::Bash => {
468 format!("[[ -r \"{generated_path}\" ]] && source \"{generated_path}\"\n")
469 }
470 Shell::Elvish => format!("use {generated_path}\n"),
471 Shell::PowerShell => {
472 format!("if (Test-Path \"{generated_path}\") {{ . \"{generated_path}\" }}\n")
473 }
474 Shell::Zsh => format!(
475 concat!(
476 "typeset -U fpath\n",
477 "fpath=(\"{}\" $fpath)\n",
478 "\n",
479 "autoload -Uz compinit\n",
480 "compinit\n",
481 ),
482 completion_dir,
483 ),
484 Shell::Fish => return None,
485 _ => return None,
486 };
487
488 Some(body)
489 }
490
491 fn completion_file_path(&self, bin_name: &str) -> PathBuf {
492 self.completion_dir.join(self.shell.file_name(bin_name))
493 }
494
495 fn managed_block_name(&self, bin_name: &str) -> String {
496 match self.shell {
497 Shell::Zsh => "rust-config-tree".to_owned(),
498 _ => bin_name.to_owned(),
499 }
500 }
501}
502
503pub fn upsert_managed_block(
537 bin_name: &str,
538 shell: Shell,
539 file_path: &Path,
540 block_body: &str,
541) -> io::Result<()> {
542 upsert_managed_block_with_backup_name(bin_name, shell, file_path, block_body, bin_name)
543}
544
545fn upsert_managed_block_with_backup_name(
546 block_name: &str,
547 shell: Shell,
548 file_path: &Path,
549 block_body: &str,
550 backup_name: &str,
551) -> io::Result<()> {
552 let (begin_marker, end_marker) = managed_block_markers(block_name, shell);
553
554 let existing = match fs::read_to_string(file_path) {
555 Ok(content) => content,
556 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
557 Err(err) => return Err(err),
558 };
559
560 if let Some(parent) = file_path.parent() {
561 fs::create_dir_all(parent)?;
562 }
563
564 let managed_block = format!("{begin_marker}\n{block_body}\n{end_marker}\n");
565
566 let next_content = if let Some(begin_pos) = existing.find(&begin_marker) {
567 if let Some(relative_end_pos) = existing[begin_pos..].find(&end_marker) {
568 let end_pos = begin_pos + relative_end_pos + end_marker.len();
569
570 let before = existing[..begin_pos].trim_end();
571 let after = existing[end_pos..].trim_start();
572
573 match (before.is_empty(), after.is_empty()) {
574 (true, true) => managed_block,
575 (true, false) => format!("{managed_block}\n{after}"),
576 (false, true) => format!("{before}\n\n{managed_block}"),
577 (false, false) => format!("{before}\n\n{managed_block}\n{after}"),
578 }
579 } else {
580 return Err(io::Error::new(
581 io::ErrorKind::InvalidData,
582 format!("found `{begin_marker}` but missing `{end_marker}`"),
583 ));
584 }
585 } else {
586 let existing = existing.trim_end();
587
588 if existing.is_empty() {
589 managed_block
590 } else {
591 format!("{existing}\n\n{managed_block}")
592 }
593 };
594
595 write_startup_file_if_changed(file_path, &existing, next_content, backup_name)
596}
597
598#[cfg(test)]
599fn remove_managed_block(bin_name: &str, shell: Shell, file_path: &Path) -> io::Result<bool> {
600 remove_managed_block_with_backup_name(bin_name, shell, file_path, bin_name)
601}
602
603fn remove_managed_block_with_backup_name(
604 block_name: &str,
605 shell: Shell,
606 file_path: &Path,
607 backup_name: &str,
608) -> io::Result<bool> {
609 let (begin_marker, end_marker) = managed_block_markers(block_name, shell);
610
611 let existing = match fs::read_to_string(file_path) {
612 Ok(content) => content,
613 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false),
614 Err(err) => return Err(err),
615 };
616
617 let Some(begin_pos) = existing.find(&begin_marker) else {
618 return Ok(false);
619 };
620
621 let Some(relative_end_pos) = existing[begin_pos..].find(&end_marker) else {
622 return Err(io::Error::new(
623 io::ErrorKind::InvalidData,
624 format!("found `{begin_marker}` but missing `{end_marker}`"),
625 ));
626 };
627
628 let end_pos = begin_pos + relative_end_pos + end_marker.len();
629 let before = existing[..begin_pos].trim_end();
630 let after = existing[end_pos..].trim_start();
631
632 let next_content = match (before.is_empty(), after.is_empty()) {
633 (true, true) => String::new(),
634 (true, false) => after.to_owned(),
635 (false, true) => format!("{before}\n"),
636 (false, false) => format!("{before}\n\n{after}"),
637 };
638
639 write_startup_file_if_changed(file_path, &existing, next_content, backup_name)?;
640 Ok(true)
641}
642
643fn remove_completion_file(path: &Path) -> io::Result<()> {
644 match fs::remove_file(path) {
645 Ok(()) => Ok(()),
646 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
647 Err(err) => Err(err),
648 }
649}
650
651fn completion_dir_is_empty(path: &Path) -> io::Result<bool> {
652 match fs::read_dir(path) {
653 Ok(mut entries) => Ok(entries.next().is_none()),
654 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(true),
655 Err(err) => Err(err),
656 }
657}
658
659fn managed_block_markers(block_name: &str, shell: Shell) -> (String, String) {
660 (
661 format!("# >>> {block_name} {shell} completions >>>"),
662 format!("# <<< {block_name} {shell} completions <<<"),
663 )
664}
665
666fn write_startup_file_if_changed(
667 file_path: &Path,
668 existing: &str,
669 next_content: String,
670 backup_name: &str,
671) -> io::Result<()> {
672 if existing == next_content {
673 return Ok(());
674 }
675
676 if !existing.is_empty() || file_path.exists() {
677 backup_startup_file(file_path, backup_name)?;
678 }
679
680 fs::write(file_path, next_content)
681}
682
683fn backup_startup_file(file_path: &Path, backup_name: &str) -> io::Result<PathBuf> {
684 let file_name = file_path
685 .file_name()
686 .and_then(|value| value.to_str())
687 .ok_or_else(|| {
688 io::Error::new(
689 io::ErrorKind::InvalidInput,
690 "startup file path does not have a valid UTF-8 file name",
691 )
692 })?;
693 let backup_name = backup_file_name_part(backup_name);
694 let timestamp = SystemTime::now()
695 .duration_since(UNIX_EPOCH)
696 .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?
697 .as_nanos();
698 let backup_file_name = format!("{file_name}.backup.by.{backup_name}.{timestamp}");
699 let backup_path = file_path.with_file_name(backup_file_name);
700
701 fs::copy(file_path, &backup_path)?;
702 Ok(backup_path)
703}
704
705fn backup_file_name_part(value: &str) -> String {
706 value
707 .chars()
708 .map(|ch| match ch {
709 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => ch,
710 _ => '_',
711 })
712 .collect()
713}
714
715#[cfg(test)]
716#[path = "unit_tests/cli.rs"]
717mod unit_tests;