use anyhow::Result;
use fs4::FileExt;
use serde::{Deserialize, Serialize};
use std::env::var_os;
use std::fs::{self, File, OpenOptions};
use std::path::PathBuf;
use std::process::id;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub display: DisplayConfig,
pub general: GeneralConfig,
pub search: SearchConfig,
pub ui: UiConfig,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum AliasExpansion {
#[default]
Name,
Script,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct GeneralConfig {
pub shell_files: Vec<String>,
pub alias_expansion: AliasExpansion,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchConfig {
pub case_matching: CaseMatching,
pub normalize: bool,
pub enable_regex: bool,
pub substring_matching: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CaseMatching {
Ignore,
Smart,
Respect,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiConfig {
pub theme: String,
pub keybind_mode: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DisplayConfig {
pub show_type_badges: bool,
pub syntax_highlighting: bool,
pub parse_comments: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
display: DisplayConfig {
parse_comments: true,
show_type_badges: true,
syntax_highlighting: true,
},
general: GeneralConfig::default(),
search: SearchConfig {
case_matching: CaseMatching::Smart,
enable_regex: true,
normalize: true,
substring_matching: true,
},
ui: UiConfig {
keybind_mode: "vim".to_string(),
theme: "default".to_string(),
},
}
}
}
pub fn get_config_path() -> Result<PathBuf> {
let home = resolve_home_dir()
.ok_or_else(|| anyhow::anyhow!("Could not determine the home directory: set HOME or USERPROFILE"))?;
let config_dir = home.join(".config").join("alf");
Ok(config_dir.join("config.toml"))
}
pub fn get_config_lock_path() -> Result<PathBuf> {
Ok(get_config_path()?.with_extension("toml.lock"))
}
pub struct ConfigLock {
file: File,
}
impl ConfigLock {
pub fn acquire() -> Result<Self> {
let path = get_config_lock_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let file = OpenOptions::new().create(true).read(true).write(true).truncate(false).open(&path)?;
FileExt::lock_exclusive(&file)?;
Ok(Self {
file,
})
}
}
impl Drop for ConfigLock {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.file);
}
}
fn resolve_home_dir() -> Option<PathBuf> {
for key in ["HOME", "USERPROFILE"] {
match var_os(key) {
Some(value) if !value.is_empty() => return Some(PathBuf::from(value)),
_ => {},
}
}
dirs::home_dir()
}
pub fn expand_path(file_path_str: &str) -> PathBuf {
let expanded = if let Some(home_dir) = resolve_home_dir() {
let path = if let Some(rest) = file_path_str.strip_prefix("~/") {
home_dir.join(rest)
} else if file_path_str == "~" {
home_dir.clone()
} else if let Some(rest) = file_path_str.strip_prefix("$HOME/") {
home_dir.join(rest)
} else if file_path_str == "$HOME" {
home_dir.clone()
} else {
PathBuf::from(file_path_str)
};
path
} else {
PathBuf::from(file_path_str)
};
expanded
}
pub fn load_config() -> Result<Config> {
let path = get_config_path()?;
let content = fs::read_to_string(&path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
pub fn save_config(config: &Config) -> Result<()> {
let path = get_config_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(config)?;
let temp_path = path.with_extension(format!("toml.{}.tmp", id()));
if let Err(error) = fs::write(&temp_path, content) {
let _ = fs::remove_file(&temp_path);
return Err(error.into());
}
if let Err(error) = fs::rename(&temp_path, &path) {
let _ = fs::remove_file(&temp_path);
return Err(error.into());
}
Ok(())
}
pub fn is_first_run() -> Result<bool> {
let path = get_config_path()?;
Ok(!path.exists())
}
#[cfg(test)]
mod config_tests;