1use 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#[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#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
22#[serde(rename_all = "lowercase")]
23pub enum AliasExpansion {
24 #[default]
25 Name,
26 Script,
27}
28
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31#[serde(default)]
32pub struct GeneralConfig {
33 pub shell_files: Vec<String>,
35 pub alias_expansion: AliasExpansion,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SearchConfig {
41 pub case_matching: CaseMatching,
43 pub normalize: bool,
45 pub enable_regex: bool,
47 pub substring_matching: bool,
49}
50
51#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum CaseMatching {
55 Ignore,
57 Smart,
59 Respect,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct UiConfig {
66 pub theme: String,
68 pub keybind_mode: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct DisplayConfig {
75 pub show_type_badges: bool,
77 pub syntax_highlighting: bool,
79 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
106pub 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
121pub fn get_config_lock_path() -> Result<PathBuf> {
126 Ok(get_config_path()?.with_extension("toml.lock"))
127}
128
129pub struct ConfigLock {
140 file: File,
141}
142
143impl ConfigLock {
144 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
171fn 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
192pub 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
216pub 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
224pub fn save_config(config: &Config) -> Result<()> {
230 let path = get_config_path()?;
231
232 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
255pub fn is_first_run() -> Result<bool> {
257 let path = get_config_path()?;
258 Ok(!path.exists())
259}
260
261#[cfg(test)]
262mod config_tests;