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