Skip to main content

alf/config/
mod.rs

1//! Configuration management for alf.
2
3use anyhow::Result;
4use fs4::FileExt;
5use serde::{Deserialize, Serialize};
6use std::env::var_os;
7use std::fs::{self, File, OpenOptions};
8use std::path::PathBuf;
9use std::process::id;
10
11/// Main configuration structure
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Config {
14   pub display: DisplayConfig,
15   pub general: GeneralConfig,
16   pub search: SearchConfig,
17   pub ui: UiConfig,
18}
19
20/// Controls what gets populated when Tab is pressed on an alias
21#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
22#[serde(rename_all = "lowercase")]
23pub enum AliasExpansion {
24   #[default]
25   Name,
26   Script,
27}
28
29/// General configuration options
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31#[serde(default)]
32pub struct GeneralConfig {
33   /// List of shell files to parse (supports glob patterns)
34   pub shell_files: Vec<String>,
35   pub alias_expansion: AliasExpansion,
36}
37
38/// Search behavior configuration
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SearchConfig {
41   /// Case matching strategy
42   pub case_matching: CaseMatching,
43   /// Enable Unicode normalization
44   pub normalize: bool,
45   /// Enable regex support
46   pub enable_regex: bool,
47   /// Enable substring matching
48   pub substring_matching: bool,
49}
50
51/// Case matching options for search
52#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum CaseMatching {
55   /// Ignore case entirely
56   Ignore,
57   /// Smart case (case-insensitive unless query has uppercase)
58   Smart,
59   /// Respect case exactly
60   Respect,
61}
62
63/// UI configuration
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct UiConfig {
66   /// Selected theme name
67   pub theme: String,
68   /// Keybinding mode (currently only "vim" is supported)
69   pub keybind_mode: String,
70}
71
72/// Display preferences
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct DisplayConfig {
75   /// Show type badges (Alias/Function)
76   pub show_type_badges: bool,
77   /// Enable syntax highlighting
78   pub syntax_highlighting: bool,
79   /// Parse and display comments
80   pub parse_comments: bool,
81}
82
83impl Default for Config {
84   fn default() -> Self {
85      Self {
86         display: DisplayConfig {
87            parse_comments: true,
88            show_type_badges: true,
89            syntax_highlighting: true,
90         },
91         general: GeneralConfig::default(),
92         search: SearchConfig {
93            case_matching: CaseMatching::Smart,
94            enable_regex: true,
95            normalize: true,
96            substring_matching: true,
97         },
98         ui: UiConfig {
99            keybind_mode: "vim".to_string(),
100            theme: "default".to_string(),
101         },
102      }
103   }
104}
105
106/// Get the platform-specific configuration file path
107///
108/// - Linux/macOS: `$HOME/.config/alf/config.toml`
109/// - Windows: `%USERPROFILE%\.config\alf\config.toml`
110///
111/// Home is resolved by [`resolve_home_dir`], the same way `~` is expanded, so the two can never
112/// disagree about where home is.
113pub fn get_config_path() -> Result<PathBuf> {
114   let home = resolve_home_dir()
115      .ok_or_else(|| anyhow::anyhow!("Could not determine the home directory: set HOME or USERPROFILE"))?;
116
117   let config_dir = home.join(".config").join("alf");
118   Ok(config_dir.join("config.toml"))
119}
120
121/// Get the path of the lock file guarding the configuration file
122///
123/// Sits beside the configuration file as `config.toml.lock` so the lock survives the config file
124/// being replaced by a rename.
125pub fn get_config_lock_path() -> Result<PathBuf> {
126   Ok(get_config_path()?.with_extension("toml.lock"))
127}
128
129/// An exclusive, process-safe lock over the configuration file
130///
131/// Hold this across a read-modify-write cycle — from before [`load_config`] until after
132/// [`save_config`] — so two concurrent `alf` processes cannot both load the same configuration and
133/// have the later save silently discard the earlier one's changes.
134///
135/// The lock is advisory: it excludes other holders of this same lock, not an unrelated process or
136/// a hand edit of the file. It is released when the guard is dropped, which covers early returns,
137/// errors and panics alike, and the operating system releases it if the process dies while holding
138/// it, so a crash cannot leave the lock stuck.
139pub struct ConfigLock {
140   file: File,
141}
142
143impl ConfigLock {
144   /// Acquire the lock, blocking until any other holder releases it
145   ///
146   /// # Errors
147   /// Returns an error if the configuration directory cannot be created, the lock file cannot be
148   /// opened, or the underlying lock cannot be acquired.
149   pub fn acquire() -> Result<Self> {
150      let path = get_config_lock_path()?;
151
152      if let Some(parent) = path.parent() {
153         fs::create_dir_all(parent)?;
154      }
155
156      let file = OpenOptions::new().create(true).read(true).write(true).truncate(false).open(&path)?;
157      FileExt::lock_exclusive(&file)?;
158
159      Ok(Self {
160         file,
161      })
162   }
163}
164
165impl Drop for ConfigLock {
166   fn drop(&mut self) {
167      let _ = FileExt::unlock(&self.file);
168   }
169}
170
171/// Resolve the home directory used for the config path and for expanding `~` and `$HOME`
172///
173/// `HOME` and `USERPROFILE` are consulted first, in that order, so a caller that overrides the
174/// environment is honoured on every platform. An empty value is skipped rather than accepted, which
175/// would otherwise yield a relative config path, and `var_os` is used so a home path that is not
176/// valid Unicode — legal on Unix — still resolves instead of reading as unset.
177///
178/// `dirs::home_dir` is only the fallback. On Windows it reads the profile known folder and ignores
179/// both variables, so relying on it alone would expand `~` to the real user profile even when the
180/// environment points somewhere else — which silently defeats an isolated test home.
181fn resolve_home_dir() -> Option<PathBuf> {
182   for key in ["HOME", "USERPROFILE"] {
183      match var_os(key) {
184         Some(value) if !value.is_empty() => return Some(PathBuf::from(value)),
185         _ => {},
186      }
187   }
188
189   dirs::home_dir()
190}
191
192/// Expand a leading `~` or `$HOME` in a configured file path into an absolute path
193///
194/// Paths without a home prefix are returned unchanged.
195pub fn expand_path(file_path_str: &str) -> PathBuf {
196   let expanded = if let Some(home_dir) = resolve_home_dir() {
197      let path = if let Some(rest) = file_path_str.strip_prefix("~/") {
198         home_dir.join(rest)
199      } else if file_path_str == "~" {
200         home_dir.clone()
201      } else if let Some(rest) = file_path_str.strip_prefix("$HOME/") {
202         home_dir.join(rest)
203      } else if file_path_str == "$HOME" {
204         home_dir.clone()
205      } else {
206         PathBuf::from(file_path_str)
207      };
208      path
209   } else {
210      PathBuf::from(file_path_str)
211   };
212
213   expanded
214}
215
216/// Load configuration from disk
217pub fn load_config() -> Result<Config> {
218   let path = get_config_path()?;
219   let content = fs::read_to_string(&path)?;
220   let config: Config = toml::from_str(&content)?;
221   Ok(config)
222}
223
224/// Save configuration to disk
225///
226/// The new contents are written to a sibling temporary file and then renamed over the target, so a
227/// failure part-way through leaves the previous configuration intact rather than a truncated file.
228/// The temporary name carries the process id to keep concurrent writers from sharing it.
229pub fn save_config(config: &Config) -> Result<()> {
230   let path = get_config_path()?;
231
232   // `parent()` yields the config file's directory, or `None` for a path with no parent at all.
233   // `create_dir_all` builds every missing ancestor and succeeds when they already exist, so it
234   // acts as the existence check itself
235   if let Some(parent) = path.parent() {
236      fs::create_dir_all(parent)?;
237   }
238
239   let content = toml::to_string_pretty(config)?;
240   let temp_path = path.with_extension(format!("toml.{}.tmp", id()));
241
242   if let Err(error) = fs::write(&temp_path, content) {
243      let _ = fs::remove_file(&temp_path);
244      return Err(error.into());
245   }
246
247   if let Err(error) = fs::rename(&temp_path, &path) {
248      let _ = fs::remove_file(&temp_path);
249      return Err(error.into());
250   }
251
252   Ok(())
253}
254
255/// Check if this is the first run (config doesn't exist)
256pub fn is_first_run() -> Result<bool> {
257   let path = get_config_path()?;
258   Ok(!path.exists())
259}
260
261#[cfg(test)]
262mod config_tests;