Skip to main content

alf/cli/
config_cmd.rs

1//! Configuration management commands (add, show, edit, reset).
2
3use crate::cli::init;
4use crate::cli::ConfigAction;
5use crate::config::{
6   expand_path, get_config_path, is_first_run, load_config, save_config, Config, ConfigLock, GeneralConfig,
7};
8use anyhow::Result;
9use std::fs;
10use std::io::{self, Write};
11use std::path::PathBuf;
12use std::process::Command;
13
14/// Run a configuration management action
15pub fn run_config_action(action: ConfigAction) -> Result<()> {
16   match action {
17      ConfigAction::Add {
18         paths,
19      } => add_source_files(&paths),
20      ConfigAction::Edit => edit_config(),
21      ConfigAction::Reset => reset_config(),
22      ConfigAction::Show => show_config(),
23   }
24}
25
26/// Add one or more shell source files to the configured `shell_files` list
27///
28/// The load-modify-save cycle runs under an exclusive [`ConfigLock`], so a concurrent `alf config
29/// add` cannot read the same starting configuration and overwrite the entries this run appends.
30/// The guard releases the lock on every exit path, including the early returns and the `?` bails.
31fn add_source_files(raw_paths: &[String]) -> Result<()> {
32   let config_path = get_config_path()?;
33
34   if is_first_run()? {
35      anyhow::bail!("No config found at {}. Run `alf init` to create one.", config_path.display());
36   }
37
38   let _lock = ConfigLock::acquire()?;
39   let mut config = load_config()?;
40   let (to_add, duplicates) = resolve_new_source_files(&config, raw_paths)?;
41
42   for duplicate in &duplicates {
43      println!("Already configured: {}", duplicate);
44   }
45
46   if to_add.is_empty() {
47      println!("No new source files added.");
48      return Ok(());
49   }
50
51   config.general.shell_files.extend(to_add.iter().cloned());
52   save_config(&config)?;
53
54   for added in &to_add {
55      println!("Added: {}", added);
56   }
57
58   println!("Config saved to {}", config_path.display());
59   Ok(())
60}
61
62/// Split the given paths into new source files and ones the config already tracks
63///
64/// Paths are compared in canonical form, so entries that reach the same file through a symlink,
65/// a `..` segment, or a different `~`/`$HOME` spelling count as duplicates. The caller's original
66/// spelling is what gets returned, and therefore what gets stored.
67///
68/// # Errors
69/// Returns an error if any path is relative, does not exist on disk, or is not a regular file,
70/// before classifying any of them, so a single bad path leaves the configuration untouched.
71/// Relative paths are rejected first, so their message is never masked by a missing-file error.
72///
73/// Directories, sockets and FIFOs are rejected because the parser reads each configured entry as a
74/// file: a directory would warn on every launch, and a FIFO would block the read. Both checks
75/// follow symlinks, so a symlink to a regular file is still accepted.
76fn resolve_new_source_files(
77   config: &Config,
78   raw_paths: &[String],
79) -> Result<(Vec<String>, Vec<String>)> {
80   for raw_path in raw_paths {
81      if !expand_path(raw_path).is_absolute() {
82         anyhow::bail!(
83            "Relative paths are not allowed: {}. Use an absolute path or one starting with `~` or `$HOME`.",
84            raw_path
85         );
86      }
87   }
88
89   for raw_path in raw_paths {
90      let expanded = expand_path(raw_path);
91
92      if !expanded.exists() {
93         anyhow::bail!("Shell file not found: {}", expanded.display());
94      }
95
96      if !expanded.is_file() {
97         anyhow::bail!("Shell source path is not a regular file: {}", expanded.display());
98      }
99   }
100
101   let mut configured: Vec<PathBuf> =
102      config.general.shell_files.iter().map(String::as_str).map(canonical_key).collect();
103
104   let mut to_add = Vec::new();
105   let mut duplicates = Vec::new();
106
107   for raw_path in raw_paths {
108      let key = canonical_key(raw_path);
109
110      if configured.contains(&key) {
111         duplicates.push(raw_path.clone());
112      } else {
113         configured.push(key);
114         to_add.push(raw_path.clone());
115      }
116   }
117
118   Ok((to_add, duplicates))
119}
120
121/// Resolve a configured path to the key used for duplicate comparison
122///
123/// Falls back to the merely expanded path when the file cannot be canonicalized, which happens for
124/// stale `shell_files` entries pointing at files that no longer exist.
125fn canonical_key(raw_path: &str) -> PathBuf {
126   let expanded = expand_path(raw_path);
127   fs::canonicalize(&expanded).unwrap_or(expanded)
128}
129
130/// Show the current configuration
131fn show_config() -> Result<()> {
132   let config = load_config()?;
133   let config_path = get_config_path()?;
134
135   println!("Location: {}\n", config_path.display());
136   println!("{}", toml::to_string_pretty(&config)?);
137
138   Ok(())
139}
140
141/// Edit the configuration file in the user's preferred editor
142fn edit_config() -> Result<()> {
143   let config_path = get_config_path()?;
144
145   // Try $EDITOR first, then fall back to common editors
146   let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
147
148   let status = Command::new(&editor).arg(config_path.to_string_lossy().to_string()).status()?;
149
150   if !status.success() {
151      anyhow::bail!("Editor exited with non-zero status");
152   }
153
154   Ok(())
155}
156
157/// Reset configuration to defaults
158fn reset_config() -> Result<()> {
159   print!("Are you sure you want to reset configuration? (y/N) ");
160   io::stdout().flush()?;
161
162   let mut response = String::new();
163   io::stdin().read_line(&mut response)?;
164
165   if !response.trim().eq_ignore_ascii_case("y") {
166      println!("Cancelled.");
167      return Ok(());
168   }
169
170   // Auto-detect standard shell files
171   let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("HOME environment variable is not set"))?;
172   let detected_files = init::detect_shell_files(&home);
173
174   let config = Config {
175      general: GeneralConfig {
176         shell_files: detected_files,
177         ..Default::default()
178      },
179      ..Default::default()
180   };
181
182   save_config(&config)?;
183
184   let config_path = get_config_path()?;
185   println!("Config reset to defaults and saved to {}", config_path.display());
186
187   Ok(())
188}
189
190#[cfg(test)]
191#[path = "config_cmd_tests.rs"]
192mod config_cmd_tests;