diff options
Diffstat (limited to 'src/config/io.rs')
| -rw-r--r-- | src/config/io.rs | 226 |
1 files changed, 226 insertions, 0 deletions
diff --git a/src/config/io.rs b/src/config/io.rs new file mode 100644 index 00000000..3bc18c4b --- /dev/null +++ b/src/config/io.rs @@ -0,0 +1,226 @@ +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::<Config>(&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 save_onboarding_choices(sound_enabled: bool, toast_enabled: bool) -> std::io::Result<()> { + let path = config_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let content = std::fs::read_to_string(&path).unwrap_or_default(); + let content = upsert_top_level_bool(&content, "onboarding", false); + let content = upsert_section_bool(&content, "ui.sound", "enabled", sound_enabled); + let content = upsert_section_bool(&content, "ui.toast", "enabled", toast_enabled); + std::fs::write(path, content) +} + +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<LiveKeybindConfig, Vec<String>> { + 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::<Config>(&content).map_err(|err| { + vec![format!( + "config parse error: {err}; keeping current keybinds" + )] + })?; + config.live_keybinds() +} + +pub(super) fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String { + let replacement = format!("{key} = {value}"); + let mut lines: Vec<String> = 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()) +} + +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")); + } +} |
