use std::path::{Path, PathBuf}; use tracing::warn; use super::{model::LoadedConfig, Config, CONFIG_PATH_ENV_VAR}; const KNOWN_TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ "advanced", "experimental", "keys", "onboarding", "remote", "session", "terminal", "theme", "ui", "update", "worktrees", ]; 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") { return PathBuf::from(dir).join(app_dir_name()); } platform_config_dir() } pub fn state_dir() -> PathBuf { if let Ok(dir) = std::env::var("XDG_STATE_HOME") { return PathBuf::from(dir).join(app_dir_name()); } platform_state_dir() } #[cfg(windows)] fn platform_config_dir() -> PathBuf { if let Ok(dir) = std::env::var("APPDATA") { return PathBuf::from(dir).join(app_dir_name()); } if let Ok(profile) = std::env::var("USERPROFILE") { return PathBuf::from(profile) .join("AppData") .join("Roaming") .join(app_dir_name()); } if let Ok(home) = std::env::var("HOME") { return PathBuf::from(home).join(format!(".config/{}", app_dir_name())); } std::env::temp_dir().join(app_dir_name()) } #[cfg(not(windows))] fn platform_config_dir() -> PathBuf { if let Ok(home) = std::env::var("HOME") { PathBuf::from(home).join(format!(".config/{}", app_dir_name())) } else { std::env::temp_dir().join(app_dir_name()) } } #[cfg(windows)] fn platform_state_dir() -> PathBuf { if let Ok(dir) = std::env::var("LOCALAPPDATA") { return PathBuf::from(dir).join(app_dir_name()); } if let Ok(profile) = std::env::var("USERPROFILE") { return PathBuf::from(profile) .join("AppData") .join("Local") .join(app_dir_name()); } if let Ok(home) = std::env::var("HOME") { return PathBuf::from(home).join(format!(".local/state/{}", app_dir_name())); } std::env::temp_dir().join(format!("{}-state", app_dir_name())) } #[cfg(not(windows))] fn platform_state_dir() -> PathBuf { if let Ok(home) = std::env::var("HOME") { PathBuf::from(home).join(format!(".local/state/{}", app_dir_name())) } else { std::env::temp_dir().join(format!("{}-state", 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 mut diagnostics = unknown_top_level_section_diagnostics_from_str(&content); diagnostics.extend(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 { const MAX_VISIBLE_DIAGNOSTICS: usize = 4; if diagnostics.is_empty() { return None; } let mut lines: Vec = diagnostics .iter() .take(MAX_VISIBLE_DIAGNOSTICS) .map(|diagnostic| diagnostic.split_whitespace().collect::>().join(" ")) .collect(); let hidden = diagnostics.len().saturating_sub(MAX_VISIBLE_DIAGNOSTICS); if hidden > 0 { lines.push(format!("and {hidden} more config warnings")); } Some(lines.join("\n")) } 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 = unknown_top_level_section_diagnostics(table); 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, "terminal", "terminal config", &mut diagnostics, &mut invalid_sections, |section| config.terminal = section, ); load_live_section( table, "session", "session config", &mut diagnostics, &mut invalid_sections, |section| config.session = section, ); load_live_section( table, "update", "update config", &mut diagnostics, &mut invalid_sections, |section| config.update = 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, ); load_live_section( table, "worktrees", "worktree config", &mut diagnostics, &mut invalid_sections, |section| config.worktrees = section, ); load_live_section( table, "experimental", "experimental config", &mut diagnostics, &mut invalid_sections, |section| config.experimental = section, ); load_live_section( table, "remote", "remote config", &mut diagnostics, &mut invalid_sections, |section| config.remote = section, ); Ok(LoadedConfig { config, diagnostics, invalid_sections, }) } fn unknown_top_level_section_diagnostics_from_str(content: &str) -> Vec { content .parse::() .ok() .and_then(|value| value.as_table().map(unknown_top_level_section_diagnostics)) .unwrap_or_default() } fn unknown_top_level_section_diagnostics( table: &toml::map::Map, ) -> Vec { table .iter() .filter_map(|(key, value)| unknown_top_level_section_diagnostic(key, value)) .collect() } fn unknown_top_level_section_diagnostic(key: &str, value: &toml::Value) -> Option { if KNOWN_TOP_LEVEL_CONFIG_KEYS.contains(&key) { return None; } let header = if value.is_table() { format!("[{key}]") } else if value .as_array() .is_some_and(|items| !items.is_empty() && items.iter().all(toml::Value::is_table)) { format!("[[{key}]]") } else { return None; }; if key == "toast" { Some(format!( "unknown config section {header}; did you mean [ui.toast]? ignoring section" )) } else { Some(format!("unknown config section {header}; ignoring section")) } } 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" } pub fn remove_keybinding_config_sections(content: &str) -> (String, bool) { let mut result = Vec::new(); let mut removed = false; let mut skipping_key_section = false; let mut in_table = false; for line in content.lines() { let trimmed = line.trim(); if let Some(table_name) = toml_table_header_name(trimmed) { in_table = true; skipping_key_section = is_keys_table_name(table_name); if skipping_key_section { removed = true; continue; } } else if skipping_key_section || (!in_table && is_top_level_keys_assignment(trimmed)) { removed = true; continue; } result.push(line.to_string()); } let mut updated = result.join("\n"); if content.ends_with('\n') || !updated.is_empty() { updated.push('\n'); } (updated, removed) } fn toml_table_header_name(trimmed: &str) -> Option<&str> { if let Some(name) = trimmed .strip_prefix("[[") .and_then(|value| value.strip_suffix("]]")) { return Some(name.trim()); } trimmed .strip_prefix('[') .and_then(|value| value.strip_suffix(']')) .map(str::trim) } fn is_keys_table_name(name: &str) -> bool { name == "keys" || name.starts_with("keys.") } fn is_top_level_keys_assignment(trimmed: &str) -> bool { trimmed.starts_with("keys ") || trimmed.starts_with("keys=") || trimmed.starts_with("keys.") } 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")); } #[test] fn config_diagnostic_summary_keeps_multiple_warnings_visible() { let diagnostics = vec![ "one".to_string(), "two".to_string(), "three".to_string(), "four".to_string(), "five".to_string(), ]; assert_eq!( config_diagnostic_summary(&diagnostics).as_deref(), Some("one\ntwo\nthree\nfour\nand 1 more config warnings") ); } #[test] fn load_live_config_parses_session_section() { let loaded = load_live_config_from_str( r#" [session] resume_agents_on_restore = true "#, ) .unwrap(); assert!(loaded.config.session.resume_agents_on_restore); assert!(loaded.diagnostics.is_empty()); assert!(loaded.invalid_sections.is_empty()); } #[test] fn load_live_config_warns_about_unknown_top_level_sections() { let loaded = load_live_config_from_str( r#" [toast] delivery = "system" [ui.toast] delivery = "herdr" "#, ) .unwrap(); assert_eq!( loaded.diagnostics, vec!["unknown config section [toast]; did you mean [ui.toast]? ignoring section"] ); assert!(loaded.invalid_sections.is_empty()); assert_eq!( loaded.config.ui.toast.delivery, super::super::ToastDelivery::Herdr ); } #[test] fn load_live_config_does_not_warn_about_unknown_top_level_scalar_values() { let loaded = load_live_config_from_str( r#" plugin = [] [ui.toast] delivery = "herdr" "#, ) .unwrap(); assert!(loaded.diagnostics.is_empty()); assert_eq!( loaded.config.ui.toast.delivery, super::super::ToastDelivery::Herdr ); } #[test] fn startup_config_load_warns_about_unknown_top_level_sections() { let _guard = crate::config::test_config_env_lock().lock().unwrap(); let path = std::env::temp_dir().join(format!( "herdr-config-unknown-section-{}.toml", std::process::id() )); std::fs::write( &path, r#" [[plugin]] id = "example" [ui.toast] delivery = "system" "#, ) .unwrap(); std::env::set_var(CONFIG_PATH_ENV_VAR, &path); let loaded = Config::load(); assert_eq!( loaded.diagnostics, vec!["unknown config section [[plugin]]; ignoring section"] ); assert_eq!( loaded.config.ui.toast.delivery, super::super::ToastDelivery::System ); std::env::remove_var(CONFIG_PATH_ENV_VAR); let _ = std::fs::remove_file(path); } #[test] fn remove_keybinding_config_sections_removes_keys_tables_only() { let content = r#"onboarding = false [theme] name = "catppuccin" [keys] prefix = "ctrl+a" new_tab = "c" [[keys.command]] key = "g" command = "lazygit" [keys.indexed] tabs = "ctrl" [ui] mouse_capture = false "#; let (updated, removed) = remove_keybinding_config_sections(content); assert!(removed); assert!(updated.contains("onboarding = false")); assert!(updated.contains("[theme]\nname = \"catppuccin\"")); assert!(updated.contains("[ui]\nmouse_capture = false")); assert!(!updated.contains("[keys]")); assert!(!updated.contains("[[keys.command]]")); assert!(!updated.contains("[keys.indexed]")); assert!(toml::from_str::(&updated).is_ok()); } #[test] fn remove_keybinding_config_sections_reports_noop_without_keys() { let content = "[ui]\nmouse_capture = true\n"; let (updated, removed) = remove_keybinding_config_sections(content); assert!(!removed); assert_eq!(updated, content); } }