things_mcp/core/
config.rs1use 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 #[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
115const GROUP_CONTAINER_GLOB: &str =
117 "Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/ThingsData-*/Things Database.thingsdatabase/main.sqlite";
118
119pub 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 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 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}