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