aboutsummaryrefslogtreecommitdiff
path: root/src/config/io.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/config/io.rs')
-rw-r--r--src/config/io.rs226
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"));
+ }
+}