Skip to main content

oximemo_core/
config.rs

1//! Vault configuration parsed from `config.toml` (§5.8).
2//!
3//! All fields have defaults so a vault with no config file behaves identically
4//! to one with every value spelled out.
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::Result;
9use crate::paths::Paths;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(default)]
13pub struct VaultConfig {
14    pub general: GeneralConfig,
15    pub capture: CaptureConfig,
16    pub appearance: AppearanceConfig,
17    pub categories: CategoriesConfig,
18    pub index: IndexConfig,
19    /// Forward-compatible schema marker. Unknown fields are ignored.
20    pub schema_version: u32,
21}
22
23impl Default for VaultConfig {
24    fn default() -> Self {
25        Self {
26            general: GeneralConfig::default(),
27            capture: CaptureConfig::default(),
28            appearance: AppearanceConfig::default(),
29            categories: CategoriesConfig::default(),
30            index: IndexConfig::default(),
31            schema_version: 2,
32        }
33    }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(default)]
38pub struct GeneralConfig {
39    pub trash_retention_days: u32,
40}
41
42impl Default for GeneralConfig {
43    fn default() -> Self {
44        Self {
45            trash_retention_days: 30,
46        }
47    }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(default)]
52pub struct CaptureConfig {
53    pub double_tap_threshold_ms: u32,
54    pub overlay_max_height: u32,
55}
56
57impl Default for CaptureConfig {
58    fn default() -> Self {
59        Self {
60            double_tap_threshold_ms: 350,
61            overlay_max_height: 400,
62        }
63    }
64}
65
66#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
67#[serde(rename_all = "lowercase")]
68pub enum Theme {
69    #[default]
70    System,
71    Light,
72    Dark,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(default)]
77pub struct AppearanceConfig {
78    pub theme: Theme,
79    pub show_dock_icon: bool,
80}
81
82impl Default for AppearanceConfig {
83    fn default() -> Self {
84        Self {
85            theme: Theme::System,
86            show_dock_icon: true,
87        }
88    }
89}
90/// Five default category color stops (OKLCH). The order and ids are the
91/// canonical built-in palette; `CategoriesConfig::default` ships them as the
92/// initial `items` so a fresh vault inherits a usable sidebar. The previous
93/// sixth entry, the `note` (blue) category, was retired: orphan refs (memos
94/// with `category = "note"`) fall back to the default card surface via
95/// [`resolve_category_color`] because the id is no longer in `items`.
96pub const AUTO_COLORS: &[&str] = &[
97    "",                     // inbox — transparent (renders default card surface)
98    "oklch(0.78 0.15 75)",  // todo — amber
99    "oklch(0.72 0.15 310)", // idea — purple
100    "oklch(0.75 0.12 195)", // bookmark — teal
101    "oklch(0.75 0.13 145)", // snippet — green
102];
103
104/// Resolve a category id to its OKLCH color string. Returns the inbox color
105/// (empty/transparent) when the id is empty or not in `items`, so an unknown
106/// / legacy category never crashes rendering — it falls back to the default
107/// card surface (no tint).
108pub fn resolve_category_color(id: &str, items: &[CategoryDef]) -> String {
109    if let Some(def) = items.iter().find(|c| c.id == id) {
110        return def.color.clone();
111    }
112    AUTO_COLORS[0].to_string()
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct CategoryDef {
117    pub id: String,
118    pub color: String,
119    #[serde(default)]
120    pub builtin: bool,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124#[serde(default)]
125pub struct CategoriesConfig {
126    pub items: Vec<CategoryDef>,
127}
128
129impl Default for CategoriesConfig {
130    fn default() -> Self {
131        let ids = ["inbox", "todo", "idea", "bookmark", "snippet"];
132        let items = ids
133            .iter()
134            .zip(AUTO_COLORS.iter())
135            .map(|(id, color)| CategoryDef {
136                id: (*id).to_string(),
137                color: (*color).to_string(),
138                builtin: true,
139            })
140            .collect();
141        Self { items }
142    }
143}
144#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(default)]
146pub struct IndexConfig {
147    pub watcher_debounce_ms: u32,
148    pub watcher_retry_count: u32,
149    pub watcher_retry_interval_ms: u32,
150}
151
152impl Default for IndexConfig {
153    fn default() -> Self {
154        Self {
155            watcher_debounce_ms: 300,
156            watcher_retry_count: 2,
157            watcher_retry_interval_ms: 200,
158        }
159    }
160}
161
162impl VaultConfig {
163    /// Load config for a vault, falling back to defaults if the file is absent
164    /// or unreadable (a corrupt file is logged but never fatal).
165    pub fn load(paths: &Paths) -> Self {
166        let path = paths.config_path();
167        match std::fs::read_to_string(&path) {
168            Ok(text) => match toml::from_str::<Self>(&text) {
169                Ok(c) => c,
170                Err(e) => {
171                    tracing::warn!(path = %path.display(), error = %e, "config.toml parse failed; using defaults");
172                    Self::default()
173                }
174            },
175            Err(_) => Self::default(),
176        }
177    }
178
179    /// Serialize back to TOML text (used by `oximemo` config init / doctor).
180    pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
181        toml::to_string_pretty(self)
182    }
183
184    /// Persist this config to `<vault>/config.toml`. Used after category CRUD
185    /// to write user-defined categories back to disk so they survive restarts.
186    pub fn save(&self, paths: &Paths) -> Result<()> {
187        let text = self.to_toml()?;
188        let path = paths.config_path();
189        // Crash-safe: write to a temp sibling then atomically rename (APFS).
190        // A torn write would otherwise silently revert the user's category
191        // setup to built-ins on next load (load() degrades to defaults).
192        let tmp = path.with_extension("toml.tmp");
193        std::fs::write(&tmp, text)?;
194        std::fs::rename(&tmp, path)?;
195        Ok(())
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn defaults_roundtrip() {
205        let c = VaultConfig::default();
206        let s = c.to_toml().unwrap();
207        let back: VaultConfig = toml::from_str(&s).unwrap();
208        assert_eq!(back.general.trash_retention_days, 30);
209        assert_eq!(back.schema_version, 2);
210    }
211
212    #[test]
213    fn unknown_fields_ignored() {
214        let t = r#"schema_version = 1
215[general]
216trash_retention_days = 7
217unknown_future_field = true
218"#;
219        let c: VaultConfig = toml::from_str(t).unwrap();
220        assert_eq!(c.general.trash_retention_days, 7);
221    }
222    #[test]
223    fn save_roundtrips_categories() {
224        let dir = tempfile::tempdir().unwrap();
225        let paths = Paths::resolve(Some(dir.path()));
226        let mut cfg = VaultConfig::default();
227        cfg.categories.items.push(CategoryDef {
228            id: "custom".into(),
229            color: "oklch(0.7 0.1 200)".into(),
230            builtin: false,
231        });
232        cfg.save(&paths).unwrap();
233        let reloaded = VaultConfig::load(&paths);
234        assert!(reloaded.categories.items.iter().any(|c| c.id == "custom"));
235    }
236}