Skip to main content

things3_core/database/
path_discovery.rs

1//! Discover the active Things 3 database path on disk.
2//!
3//! Things 3 stores its SQLite file under `Library/Group Containers/.../ThingsData-XXXXX/...`,
4//! where the 4-character suffix varies per install (App Store vs. direct purchase,
5//! possibly tied to iCloud account). This module scans the group container at
6//! runtime and picks the most-recently-modified candidate, falling back to the
7//! historical literal `ThingsData-0Z0Z2` path when nothing is found.
8
9use std::path::PathBuf;
10
11/// Things 3 group container directory under the user's `Library`.
12const THINGS_GROUP_CONTAINER: &str =
13    "Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac";
14
15/// Path inside a `ThingsData-XXXXX` directory that points at the SQLite file.
16const THINGS_DB_RELATIVE: &str = "Things Database.thingsdatabase/main.sqlite";
17
18/// Get the default Things 3 database path.
19///
20/// The 4-character suffix on `ThingsData-XXXXX` varies per install (App Store
21/// vs. direct purchase, possibly tied to iCloud account), so this function
22/// scans the group container for any `ThingsData-*` directory containing a
23/// real database file. If multiple candidates exist, the one whose
24/// `main.sqlite` was modified most recently wins. When no candidate is found
25/// the function falls back to the historical literal `ThingsData-0Z0Z2` path
26/// — callers downstream surface a clean "file not found" error in that case.
27///
28/// # Examples
29///
30/// ```
31/// use things3_core::get_default_database_path;
32///
33/// let path = get_default_database_path();
34/// assert!(!path.to_string_lossy().is_empty());
35/// assert!(path.to_string_lossy().contains("Library"));
36/// ```
37#[must_use]
38pub fn get_default_database_path() -> PathBuf {
39    let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
40    let group_container = PathBuf::from(&home).join(THINGS_GROUP_CONTAINER);
41
42    if let Some(found) = discover_things_database(&group_container) {
43        return found;
44    }
45
46    group_container
47        .join("ThingsData-0Z0Z2")
48        .join(THINGS_DB_RELATIVE)
49}
50
51/// Scan `group_container` for `ThingsData-*/Things Database.thingsdatabase/main.sqlite`
52/// and return the most-recently-modified candidate, if any.
53fn discover_things_database(group_container: &std::path::Path) -> Option<PathBuf> {
54    let entries = std::fs::read_dir(group_container).ok()?;
55
56    let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
57    for entry in entries.flatten() {
58        let name = entry.file_name();
59        let Some(name_str) = name.to_str() else {
60            continue;
61        };
62        if !name_str.starts_with("ThingsData-") {
63            continue;
64        }
65
66        let candidate = entry.path().join(THINGS_DB_RELATIVE);
67        let Ok(meta) = std::fs::metadata(&candidate) else {
68            continue;
69        };
70        if !meta.is_file() {
71            continue;
72        }
73        let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
74
75        match &best {
76            Some((_, best_mtime)) if mtime <= *best_mtime => {}
77            _ => best = Some((candidate, mtime)),
78        }
79    }
80
81    best.map(|(path, _)| path)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use tempfile::TempDir;
88
89    #[test]
90    fn test_get_default_database_path_format() {
91        let path = get_default_database_path();
92        let path_str = path.to_string_lossy();
93        assert!(path_str.contains("Things Database.thingsdatabase"));
94        assert!(path_str.contains("main.sqlite"));
95        assert!(path_str.contains("Library/Group Containers"));
96    }
97
98    #[test]
99    fn test_discover_things_database_picks_non_default_suffix() {
100        let group_container = TempDir::new().unwrap();
101        let things_dir = group_container.path().join("ThingsData-01AEF");
102        let db_dir = things_dir.join("Things Database.thingsdatabase");
103        std::fs::create_dir_all(&db_dir).unwrap();
104        let db_path = db_dir.join("main.sqlite");
105        std::fs::write(&db_path, b"").unwrap();
106
107        let found = discover_things_database(group_container.path()).unwrap();
108        assert_eq!(found, db_path);
109    }
110
111    #[test]
112    fn test_discover_things_database_prefers_most_recent() {
113        let group_container = TempDir::new().unwrap();
114
115        let make = |suffix: &str| {
116            let dir = group_container
117                .path()
118                .join(format!("ThingsData-{suffix}"))
119                .join("Things Database.thingsdatabase");
120            std::fs::create_dir_all(&dir).unwrap();
121            let db = dir.join("main.sqlite");
122            std::fs::write(&db, b"").unwrap();
123            db
124        };
125
126        let _older = make("OLDER");
127        // 10ms is well above any reasonable filesystem mtime resolution, so
128        // the second file is guaranteed to have a strictly later mtime.
129        std::thread::sleep(std::time::Duration::from_millis(10));
130        let newer = make("NEWER");
131
132        let found = discover_things_database(group_container.path()).unwrap();
133        assert_eq!(found, newer);
134    }
135
136    #[test]
137    fn test_discover_things_database_returns_none_when_empty() {
138        let group_container = TempDir::new().unwrap();
139        assert!(discover_things_database(group_container.path()).is_none());
140    }
141
142    #[test]
143    fn test_discover_things_database_skips_non_matching_dirs() {
144        let group_container = TempDir::new().unwrap();
145        std::fs::create_dir_all(group_container.path().join("SomethingElse")).unwrap();
146        std::fs::create_dir_all(
147            group_container.path().join("ThingsData-EMPTY"), // no main.sqlite inside
148        )
149        .unwrap();
150        assert!(discover_things_database(group_container.path()).is_none());
151    }
152}