Skip to main content

things_mcp/core/
config.rs

1//! Persistent configuration.
2//!
3//! Loaded from `<config_dir>/config.toml` if present; missing file yields
4//! a `Config::default()`. `config_dir()` resolves
5//! `~/Library/Application Support/dev.things-mcp.things-mcp/` via the
6//! `directories` crate on macOS.
7
8use std::path::{Path, PathBuf};
9
10use directories::ProjectDirs;
11use serde::{Deserialize, Serialize};
12
13const QUALIFIER: &str = "dev";
14const ORG: &str = "things-mcp";
15const APP: &str = "things-mcp";
16
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18pub struct Config {
19    #[serde(default)]
20    pub things: ThingsConfig,
21    #[serde(default)]
22    pub backup: BackupConfig,
23    #[serde(default)]
24    pub writer: WriterConfig,
25    #[serde(default)]
26    pub logging: LoggingConfig,
27}
28
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30pub struct ThingsConfig {
31    #[serde(default)]
32    pub db_path: Option<PathBuf>,
33    #[serde(default)]
34    pub auth_token: Option<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct BackupConfig {
39    pub retain: u32,
40    pub directory: Option<PathBuf>,
41}
42impl Default for BackupConfig {
43    fn default() -> Self {
44        Self {
45            retain: 10,
46            directory: None,
47        }
48    }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct WriterConfig {
53    pub poll_timeout_ms: u64,
54    pub poll_interval_ms: u64,
55}
56impl Default for WriterConfig {
57    fn default() -> Self {
58        Self {
59            poll_timeout_ms: 3000,
60            poll_interval_ms: 100,
61        }
62    }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct LoggingConfig {
67    pub level: String,
68}
69impl Default for LoggingConfig {
70    fn default() -> Self {
71        Self {
72            level: "info".into(),
73        }
74    }
75}
76
77pub fn config_dir() -> anyhow::Result<PathBuf> {
78    let dirs = ProjectDirs::from(QUALIFIER, ORG, APP)
79        .ok_or_else(|| anyhow::anyhow!("could not resolve config dir"))?;
80    Ok(dirs.config_dir().to_path_buf())
81}
82
83pub fn config_path() -> anyhow::Result<PathBuf> {
84    Ok(config_dir()?.join("config.toml"))
85}
86
87impl Config {
88    pub fn load_from(path: &Path) -> anyhow::Result<Self> {
89        if !path.exists() {
90            return Ok(Self::default());
91        }
92        let raw = std::fs::read_to_string(path)?;
93        let cfg: Self = toml::from_str(&raw)?;
94        Ok(cfg)
95    }
96
97    pub fn save_to(&self, path: &Path) -> anyhow::Result<()> {
98        if let Some(parent) = path.parent() {
99            std::fs::create_dir_all(parent)?;
100        }
101        let raw = toml::to_string_pretty(self)?;
102        std::fs::write(path, raw)?;
103        // 0600 on unix
104        #[cfg(unix)]
105        {
106            use std::os::unix::fs::PermissionsExt;
107            let mut p = std::fs::metadata(path)?.permissions();
108            p.set_mode(0o600);
109            std::fs::set_permissions(path, p)?;
110        }
111        Ok(())
112    }
113}
114
115/// Where Things keeps its SQLite under the macOS Group Container.
116const GROUP_CONTAINER_GLOB: &str =
117    "Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/ThingsData-*/Things Database.thingsdatabase/main.sqlite";
118
119/// Resolve the live Things DB path using the three-tier precedence from the spec:
120/// 1. `THINGS_DB_PATH` env var (or explicit override)
121/// 2. cached path in `config.toml [things].db_path` if it still exists on disk
122/// 3. glob over `~/Library/Group Containers/.../ThingsData-*/...`
123///
124/// On a successful glob fallback the resolved path is written back to `config`
125/// so subsequent starts skip the glob. Returns `Ok((path, was_cache_hit))`.
126pub fn resolve_db_path(
127    cfg: &mut Config,
128    env_override: Option<&Path>,
129    home_dir: &Path,
130) -> anyhow::Result<(PathBuf, bool)> {
131    if let Some(path) = env_override {
132        return Ok((path.to_path_buf(), false));
133    }
134    if let Some(cached) = cfg.things.db_path.as_ref() {
135        if cached.exists() {
136            return Ok((cached.clone(), true));
137        }
138        tracing::warn!("cached Things DB path {:?} missing; re-globbing", cached);
139    }
140    let pattern = home_dir.join(GROUP_CONTAINER_GLOB);
141    let resolved = glob_first_match(&pattern)?
142        .ok_or_else(|| anyhow::anyhow!("Things SQLite not found under {}", pattern.display()))?;
143    cfg.things.db_path = Some(resolved.clone());
144    Ok((resolved, false))
145}
146
147fn glob_first_match(pattern: &Path) -> anyhow::Result<Option<PathBuf>> {
148    // Hand-rolled single-level glob: split on the only `*` segment, readdir
149    // the parent, return the first match that satisfies the trailing suffix.
150    let s = pattern.to_string_lossy().to_string();
151    let star_idx = s
152        .find('*')
153        .ok_or_else(|| anyhow::anyhow!("pattern has no '*'"))?;
154    let last_sep_before_star = s[..star_idx]
155        .rfind('/')
156        .ok_or_else(|| anyhow::anyhow!("glob pattern has no '/' before '*'"))?;
157    let next_sep_after_star = star_idx + s[star_idx..].find('/').unwrap_or(s.len() - star_idx);
158    let parent = PathBuf::from(&s[..last_sep_before_star]);
159    let prefix = &s[last_sep_before_star + 1..star_idx];
160    let suffix_in_segment = &s[star_idx + 1..next_sep_after_star];
161    let trailing = &s[next_sep_after_star..];
162
163    if !parent.exists() {
164        return Ok(None);
165    }
166    for entry in std::fs::read_dir(&parent)? {
167        let entry = entry?;
168        let name = entry.file_name();
169        let name = name.to_string_lossy();
170        if name.starts_with(prefix) && name.ends_with(suffix_in_segment) {
171            let candidate = parent
172                .join(name.as_ref())
173                .join(trailing.trim_start_matches('/'));
174            if candidate.exists() {
175                return Ok(Some(candidate));
176            }
177        }
178    }
179    Ok(None)
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use tempfile::tempdir;
186
187    #[test]
188    fn missing_file_yields_default() {
189        let tmp = tempdir().unwrap();
190        let path = tmp.path().join("config.toml");
191        let cfg = Config::load_from(&path).unwrap();
192        assert_eq!(cfg.backup.retain, 10);
193        assert_eq!(cfg.writer.poll_timeout_ms, 3000);
194        assert_eq!(cfg.logging.level, "info");
195    }
196
197    #[test]
198    fn round_trip_preserves_fields() {
199        let tmp = tempdir().unwrap();
200        let path = tmp.path().join("config.toml");
201        let mut cfg = Config::default();
202        cfg.things.db_path = Some(PathBuf::from("/tmp/foo.sqlite"));
203        cfg.things.auth_token = Some("abc123".into());
204        cfg.backup.retain = 5;
205        cfg.save_to(&path).unwrap();
206        let loaded = Config::load_from(&path).unwrap();
207        assert_eq!(
208            loaded.things.db_path,
209            Some(PathBuf::from("/tmp/foo.sqlite"))
210        );
211        assert_eq!(loaded.things.auth_token.as_deref(), Some("abc123"));
212        assert_eq!(loaded.backup.retain, 5);
213    }
214
215    #[test]
216    fn env_override_wins() {
217        let mut cfg = Config::default();
218        let tmp = tempdir().unwrap();
219        let override_path = tmp.path().join("custom.sqlite");
220        std::fs::write(&override_path, b"").unwrap();
221        let (p, hit) = resolve_db_path(&mut cfg, Some(&override_path), tmp.path()).unwrap();
222        assert_eq!(p, override_path);
223        assert!(!hit);
224        // env override never populates the cache
225        assert!(cfg.things.db_path.is_none());
226    }
227
228    #[test]
229    fn cached_path_hit_when_file_exists() {
230        let tmp = tempdir().unwrap();
231        let real = tmp.path().join("real.sqlite");
232        std::fs::write(&real, b"").unwrap();
233        let mut cfg = Config::default();
234        cfg.things.db_path = Some(real.clone());
235        let (p, hit) = resolve_db_path(&mut cfg, None, tmp.path()).unwrap();
236        assert_eq!(p, real);
237        assert!(hit);
238    }
239
240    #[test]
241    fn glob_fallback_populates_cache() {
242        let tmp = tempdir().unwrap();
243        let group = tmp.path().join("Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/ThingsData-deadbeef/Things Database.thingsdatabase");
244        std::fs::create_dir_all(&group).unwrap();
245        let db = group.join("main.sqlite");
246        std::fs::write(&db, b"").unwrap();
247        let mut cfg = Config::default();
248        let (p, hit) = resolve_db_path(&mut cfg, None, tmp.path()).unwrap();
249        assert_eq!(p, db);
250        assert!(!hit);
251        assert_eq!(cfg.things.db_path.as_deref(), Some(db.as_path()));
252    }
253
254    #[test]
255    fn stale_cache_triggers_reglob() {
256        let tmp = tempdir().unwrap();
257        let group = tmp.path().join("Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/ThingsData-feedface/Things Database.thingsdatabase");
258        std::fs::create_dir_all(&group).unwrap();
259        let real = group.join("main.sqlite");
260        std::fs::write(&real, b"").unwrap();
261        let mut cfg = Config::default();
262        cfg.things.db_path = Some(PathBuf::from("/does/not/exist.sqlite"));
263        let (p, hit) = resolve_db_path(&mut cfg, None, tmp.path()).unwrap();
264        assert_eq!(p, real);
265        assert!(!hit);
266        assert_eq!(cfg.things.db_path.as_deref(), Some(real.as_path()));
267    }
268}