things3_core/database/
path_discovery.rs1use std::path::PathBuf;
10
11const THINGS_GROUP_CONTAINER: &str =
13 "Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac";
14
15const THINGS_DB_RELATIVE: &str = "Things Database.thingsdatabase/main.sqlite";
17
18#[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
51fn 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 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"), )
149 .unwrap();
150 assert!(discover_things_database(group_container.path()).is_none());
151 }
152}