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