use std::path::{Path, PathBuf}; use tracing::warn; use super::{model::LoadedConfig, Config, CONFIG_PATH_ENV_VAR}; pub fn app_dir_name() -> &'static str { if cfg!(debug_assertions) { "herdr-dev" } else { "herdr" } } pub fn config_dir() -> PathBuf { if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") { PathBuf::from(dir).join(app_dir_name()) } else if let Ok(home) = std::env::var("HOME") { PathBuf::from(home).join(format!(".config/{}", app_dir_name())) } else { PathBuf::from(format!("/tmp/{}", app_dir_name())) } } impl Config { pub fn load() -> LoadedConfig { let path = config_path(); if path.exists() { match std::fs::read_to_string(&path) { Ok(content) => match toml::from_str::(&content) { Ok(config) => { let diagnostics = config.collect_diagnostics(); return LoadedConfig { config, diagnostics, invalid_sections: Vec::new(), }; } Err(err) => { warn!(err = %err, "config parse error, using defaults"); return LoadedConfig { config: Self::default(), diagnostics: vec![format!("config parse error: {err}; using defaults")], invalid_sections: Vec::new(), }; } }, Err(err) => { warn!(err = %err, "config read error, using defaults"); return LoadedConfig { config: Self::default(), diagnostics: vec![format!("config read error: {err}; using defaults")], invalid_sections: Vec::new(), }; } } } LoadedConfig { config: Self::default(), diagnostics: Vec::new(), invalid_sections: Vec::new(), } } } pub(super) fn resolve_config_relative_path(path: &Path) -> PathBuf { if path.is_absolute() { return path.to_path_buf(); } config_path() .parent() .unwrap_or_else(|| Path::new(".")) .join(path) } pub fn config_path() -> PathBuf { if let Ok(path) = std::env::var(CONFIG_PATH_ENV_VAR) { return PathBuf::from(path); } config_dir().join("config.toml") } pub fn config_diagnostic_summary(diagnostics: &[String]) -> Option { if diagnostics.is_empty() { None } else if diagnostics.len() == 1 { Some(diagnostics[0].clone()) } else { Some(format!( "{} (and {} more)", diagnostics[0], diagnostics.len() - 1 )) } } pub fn load_live_config() -> Result> { let path = config_path(); if !path.exists() { return Ok(LoadedConfig { config: Config::default(), diagnostics: Vec::new(), invalid_sections: Vec::new(), }); } let content = std::fs::read_to_string(&path) .map_err(|err| vec![format!("config read error: {err}; keeping current config")])?; load_live_config_from_str(&content) } fn load_live_config_from_str(content: &str) -> Result> { let value = content .parse::() .map_err(|err| vec![format!("config parse error: {err}; keeping current config")])?; let table = value.as_table().ok_or_else(|| { vec![ "config parse error: top-level config must be a table; keeping current config" .to_string(), ] })?; let mut config = Config::default(); let mut diagnostics = Vec::new(); let mut invalid_sections = Vec::new(); if let Some(value) = table.get("onboarding") { match value.clone().try_into::>() { Ok(onboarding) => config.onboarding = onboarding, Err(err) => diagnostics.push(format!( "invalid onboarding setting: {err}; keeping current onboarding state" )), } } load_live_section( table, "theme", "theme config", &mut diagnostics, &mut invalid_sections, |section| config.theme = section, ); load_live_section( table, "keys", "keybinding config", &mut diagnostics, &mut invalid_sections, |section| config.keys = section, ); load_live_section( table, "ui", "ui config", &mut diagnostics, &mut invalid_sections, |section| config.ui = section, ); load_live_section( table, "advanced", "advanced config", &mut diagnostics, &mut invalid_sections, |section| config.advanced = section, ); Ok(LoadedConfig { config, diagnostics, invalid_sections, }) } fn load_live_section( table: &toml::map::Map, section: &'static str, label: &str, diagnostics: &mut Vec, invalid_sections: &mut Vec, apply: impl FnOnce(T), ) where T: serde::de::DeserializeOwned, { let Some(value) = table.get(section) else { return; }; match value.clone().try_into::() { Ok(section_config) => apply(section_config), Err(err) => { diagnostics.push(format!( "invalid {label}: {err}; keeping current {section} settings" )); invalid_sections.push(section.to_string()); } } } pub(crate) fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String { let replacement = format!("{key} = {value}"); let mut lines: Vec = content.lines().map(|line| line.to_string()).collect(); let mut in_section = false; for line in &mut lines { let trimmed = line.trim(); if trimmed.starts_with('[') && trimmed.ends_with(']') { in_section = true; continue; } if in_section { continue; } if trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}=")) { *line = replacement.clone(); return lines.join("\n") + "\n"; } } if lines.is_empty() { format!("{replacement}\n") } else { format!("{replacement}\n{}\n", lines.join("\n").trim_end()) } } /// Write a key = value pair in a TOML section (creates section if missing). pub fn upsert_section_value(content: &str, section: &str, key: &str, value: &str) -> String { upsert_section_raw(content, section, key, value) } pub fn upsert_section_bool(content: &str, section: &str, key: &str, value: bool) -> String { upsert_section_raw(content, section, key, &value.to_string()) } pub fn remove_section_key(content: &str, section: &str, key: &str) -> String { let header = format!("[{section}]"); let lines: Vec<&str> = content.lines().collect(); let mut result = Vec::new(); let mut i = 0; let mut in_section = false; while i < lines.len() { let line = lines[i]; let trimmed = line.trim(); if trimmed.starts_with('[') && trimmed.ends_with(']') { in_section = trimmed == header; result.push(line.to_string()); i += 1; continue; } if in_section && (trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}="))) { i += 1; continue; } result.push(line.to_string()); i += 1; } result.join("\n") + "\n" } fn upsert_section_raw(content: &str, section: &str, key: &str, value: &str) -> String { let header = format!("[{section}]"); let assignment = format!("{key} = {value}"); let lines: Vec<&str> = content.lines().collect(); let mut result = Vec::new(); let mut i = 0; let mut found_section = false; let mut inserted = false; while i < lines.len() { let line = lines[i]; let trimmed = line.trim(); if trimmed == header { found_section = true; result.push(line.to_string()); i += 1; while i < lines.len() { let current = lines[i]; let current_trimmed = current.trim(); if current_trimmed.starts_with('[') && current_trimmed.ends_with(']') { if !inserted { result.push(assignment.clone()); inserted = true; } break; } if current_trimmed.starts_with(&format!("{key} ")) || current_trimmed.starts_with(&format!("{key}=")) { result.push(assignment.clone()); inserted = true; } else { result.push(current.to_string()); } i += 1; } continue; } result.push(line.to_string()); i += 1; } if !found_section { if !result.is_empty() && !result.last().is_some_and(|line| line.trim().is_empty()) { result.push(String::new()); } result.push(header); result.push(assignment); } else if !inserted { result.push(assignment); } result.join("\n") + "\n" } #[cfg(test)] mod tests { use super::*; #[test] fn upsert_top_level_bool_replaces_existing_value() { let content = "onboarding = true\n[keys]\nprefix = \"ctrl+b\"\n"; let updated = upsert_top_level_bool(content, "onboarding", false); assert!(updated.contains("onboarding = false")); assert!(!updated.contains("onboarding = true")); } #[test] fn upsert_section_bool_adds_missing_section() { let updated = upsert_section_bool("", "ui.toast", "enabled", true); assert!(updated.contains("[ui.toast]")); assert!(updated.contains("enabled = true")); } #[test] fn remove_section_key_removes_matching_key_from_section() { let content = "[ui.toast]\nenabled = true\ndelivery = \"herdr\"\n[ui.sound]\nenabled = true\n"; let updated = remove_section_key(content, "ui.toast", "enabled"); assert!(!updated.contains("[ui.toast]\nenabled = true")); assert!(updated.contains("delivery = \"herdr\"")); assert!(updated.contains("[ui.sound]\nenabled = true")); } }