use std::path::{Path, PathBuf}; use tracing::warn; use super::{model::LoadedConfig, Config, LiveKeybindConfig, 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, }; } Err(err) => { warn!(err = %err, "config parse error, using defaults"); return LoadedConfig { config: Self::default(), diagnostics: vec![format!("config parse error: {err}; using defaults")], }; } }, Err(err) => { warn!(err = %err, "config read error, using defaults"); return LoadedConfig { config: Self::default(), diagnostics: vec![format!("config read error: {err}; using defaults")], }; } } } LoadedConfig { config: Self::default(), diagnostics: 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 load_live_keybinds() -> Result> { let path = config_path(); if !path.exists() { return Config::default().live_keybinds(); } let content = std::fs::read_to_string(&path).map_err(|err| { vec![format!( "config read error: {err}; keeping current keybinds" )] })?; let config = toml::from_str::(&content).map_err(|err| { vec![format!( "config parse error: {err}; keeping current keybinds" )] })?; config.live_keybinds() } 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")); } }