1use std::fs;
22use std::path::{Path, PathBuf};
23use std::time::{SystemTime, UNIX_EPOCH};
24
25use chrono::{Local, NaiveDateTime};
26use serde_json::Value;
27
28use crate::error::{Error, Result};
29use crate::library::Library;
30use crate::model::LibraryMetadata;
31
32pub const MAX_BACKUP_COUNT: usize = 100;
34const BACKUP_DIR: &str = "backup";
35const METADATA: &str = "metadata.json";
36const MARKER: &str = "backup-";
38
39pub fn backup_name(time: NaiveDateTime) -> String {
42 format!("{MARKER}{}.json", time.format("%Y-%m-%d %H.%M.%S%.3f"))
43}
44
45impl Library {
46 pub fn backup_metadata(&self) -> Result<Option<PathBuf>> {
52 backup_library(self.root())
53 }
54
55 pub fn backups(&self) -> Result<Vec<PathBuf>> {
57 list(&self.root().join(BACKUP_DIR))
58 }
59
60 pub fn restore_metadata_from_backup(&mut self, path: impl AsRef<Path>) -> Result<()> {
65 let path = path.as_ref();
66 let metadata = parse_backup(path).ok_or_else(|| Error::Import { path: path.to_path_buf(), reason: "not a usable metadata.json backup".into() })?;
67 replace_metadata(self.root(), path)?;
68 self.metadata = metadata;
69 self.committed.metadata = self.metadata.clone();
72 self.dirty.metadata = false;
73 self.disk_stamp = self.metadata.modification_time;
74 Ok(())
75 }
76
77 pub fn open_or_restore(path: impl AsRef<Path>) -> Result<(Library, Option<PathBuf>)> {
81 Library::open_or_restore_with_progress(path, &|_, _| {})
82 }
83
84 pub fn open_or_restore_with_progress(path: impl AsRef<Path>, progress: &dyn Fn(usize, usize)) -> Result<(Library, Option<PathBuf>)> {
87 let root = path.as_ref();
88 let metadata = root.join(METADATA);
89 match Library::open_with_progress(root, progress) {
90 Ok(lib) => Ok((lib, None)),
91 Err(Error::Json { path, .. } | Error::Io { path, .. }) if path == metadata => match restore_newest(root)? {
92 Some(used) => Ok((Library::open_with_progress(root, progress)?, Some(used))),
93 None => Library::open_with_progress(root, progress).map(|lib| (lib, None)),
94 },
95 Err(e) => Err(e),
96 }
97 }
98}
99
100pub fn app_data_backup_name(root: &Path) -> String {
103 let path = root.to_string_lossy();
104 let path = path.trim_end_matches(['/', '\\']);
105 format!("{}.json", fnv1a_hex(path))
106}
107
108fn fnv1a_hex(s: &str) -> String {
111 let mut h: u32 = 0x811c_9dc5;
112 for unit in s.encode_utf16() {
113 h ^= u32::from(unit);
114 h = h.wrapping_mul(0x0100_0193);
115 }
116 format!("{h:08x}")
117}
118
119pub fn backup_library_with(root: &Path, app_backups: Option<&Path>) -> Result<Option<PathBuf>> {
123 let made = backup_library(root)?;
124 if let (Some(made), Some(dir)) = (&made, app_backups) {
125 let _ = fs::create_dir_all(dir).and_then(|()| write_atomic(&dir.join(app_data_backup_name(root)), &fs::read(made)?));
126 }
127 Ok(made)
128}
129
130fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
133 let dir = path.parent().unwrap_or(Path::new("."));
134 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
135 std::io::Write::write_all(&mut tmp, bytes)?;
136 tmp.persist(path).map_err(|e| e.error)?;
137 Ok(())
138}
139
140pub fn backup_library(root: &Path) -> Result<Option<PathBuf>> {
145 backup_at(root, Local::now().naive_local())
146}
147
148pub(crate) fn backup_at(root: &Path, now: NaiveDateTime) -> Result<Option<PathBuf>> {
150 let current = root.join(METADATA);
151 let dir = root.join(BACKUP_DIR);
152 if !current.is_file() {
153 return Ok(None);
155 }
156 fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
157
158 let text = fs::read(¤t).map_err(|e| Error::io(¤t, e))?;
159 let healthy = serde_json::from_slice::<Value>(&text).ok().is_some_and(|v| truthy(v.get("applicationVersion")));
160 if !healthy {
161 return Ok(None);
162 }
163
164 let existing = list(&dir)?;
165 let target = dir.join(backup_name(now));
166 let changed = match existing.first() {
167 None => true,
168 Some(newest) => fs::metadata(newest).map(|m| m.len()).ok() != Some(text.len() as u64),
169 };
170 if changed {
171 write_atomic(&target, &text).map_err(|e| Error::io(&target, e))?;
172 }
173 for old in existing.iter().skip(MAX_BACKUP_COUNT - 1) {
176 let _ = fs::remove_file(old);
177 }
178 Ok(changed.then_some(target))
179}
180
181fn list(dir: &Path) -> Result<Vec<PathBuf>> {
182 if !dir.is_dir() {
183 return Ok(Vec::new());
184 }
185 let mut out = Vec::new();
186 for entry in fs::read_dir(dir).map_err(|e| Error::io(dir, e))? {
187 let entry = entry.map_err(|e| Error::io(dir, e))?;
188 if entry.file_name().to_string_lossy().contains(MARKER) && entry.path().is_file() {
189 out.push(entry.path());
190 }
191 }
192 out.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
193 Ok(out)
194}
195
196fn restore_newest(root: &Path) -> Result<Option<PathBuf>> {
198 for candidate in list(&root.join(BACKUP_DIR))? {
199 if fs::metadata(&candidate).is_ok_and(|m| m.len() > 0) && parse_backup(&candidate).is_some() {
200 replace_metadata(root, &candidate)?;
201 return Ok(Some(candidate));
202 }
203 }
204 Ok(None)
205}
206
207fn parse_backup(path: &Path) -> Option<LibraryMetadata> {
210 let bytes = fs::read(path).ok()?;
211 let value: Value = serde_json::from_slice(&bytes).ok()?;
212 if !truthy(value.get("modificationTime")) {
213 return None;
214 }
215 serde_json::from_value(value).ok()
216}
217
218fn replace_metadata(root: &Path, backup: &Path) -> Result<()> {
219 let current = root.join(METADATA);
220 if current.exists() {
221 let dir = root.join(BACKUP_DIR);
222 fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
223 let ms = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis());
224 let damaged = dir.join(format!("{METADATA}.damaged-{ms}"));
225 let _ = fs::copy(¤t, damaged);
226 }
227 fs::copy(backup, ¤t).map_err(|e| Error::io(¤t, e))?;
228 Ok(())
229}
230
231fn truthy(v: Option<&Value>) -> bool {
233 match v {
234 None | Some(Value::Null) => false,
235 Some(Value::Bool(b)) => *b,
236 Some(Value::Number(n)) => n.as_f64().is_some_and(|f| f != 0.0),
237 Some(Value::String(s)) => !s.is_empty(),
238 Some(_) => true,
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use chrono::NaiveDate;
246
247 fn at(h: u32, m: u32, s: u32, ms: u32) -> NaiveDateTime {
248 NaiveDate::from_ymd_opt(2026, 10, 26).unwrap().and_hms_milli_opt(h, m, s, ms).unwrap()
249 }
250
251 fn temp_library() -> (tempfile::TempDir, Library) {
252 let dir = tempfile::tempdir().unwrap();
253 let lib = Library::create(dir.path().join("Test.library")).unwrap();
254 (dir, lib)
255 }
256
257 #[test]
258 fn app_data_name_is_eagles_fnv_hash() {
259 assert_eq!(fnv1a_hex(""), "811c9dc5");
261 assert_eq!(fnv1a_hex("a"), "e40c292c");
262 assert_eq!(fnv1a_hex("foobar"), "bf9cf968");
263 assert_ne!(fnv1a_hex("é"), fnv1a_hex("\u{c3}\u{a9}"));
265 let p = Path::new("/Users/me/Pictures/Books.library");
266 assert_eq!(app_data_backup_name(p), format!("{}.json", fnv1a_hex("/Users/me/Pictures/Books.library")));
267 assert_eq!(app_data_backup_name(Path::new("/Users/me/Pictures/Books.library/")), app_data_backup_name(p));
268 }
269
270 #[test]
271 fn app_data_copy_follows_each_new_backup() {
272 let (d, mut lib) = temp_library();
273 let app = d.path().join("app/eagle-backup");
274 let copy = app.join(app_data_backup_name(lib.root()));
275 backup_library_with(lib.root(), Some(&app)).unwrap().unwrap();
276 assert_eq!(fs::read(©).unwrap(), fs::read(lib.root().join(METADATA)).unwrap());
277 lib.create_folder(None, "Changed").unwrap();
278 backup_library_with(lib.root(), Some(&app)).unwrap().unwrap();
279 assert_eq!(fs::read(©).unwrap(), fs::read(lib.root().join(METADATA)).unwrap());
280 assert_eq!(fs::read_dir(&app).unwrap().count(), 1, "one copy per library, no temp files left");
281 }
282
283 #[test]
284 fn never_recreates_a_missing_library() {
285 let (d, lib) = temp_library();
286 let root = lib.root().to_path_buf();
287 drop(lib);
288 fs::remove_dir_all(&root).unwrap();
289 assert_eq!(backup_library_with(&root, Some(&d.path().join("app"))).unwrap(), None);
290 assert!(!root.exists());
291 }
292
293 #[test]
294 fn name_matches_eagle() {
295 assert_eq!(backup_name(at(2, 15, 25, 310)), "backup-2026-10-26 02.15.25.310.json");
296 assert_eq!(backup_name(at(14, 5, 7, 9)), "backup-2026-10-26 14.05.07.009.json");
297 }
298
299 #[test]
300 fn skips_when_newest_has_same_size() {
301 let (_d, mut lib) = temp_library();
302 let first = backup_at(lib.root(), at(1, 0, 0, 0)).unwrap().unwrap();
303 assert_eq!(fs::read(&first).unwrap(), fs::read(lib.root().join(METADATA)).unwrap());
304 assert_eq!(backup_at(lib.root(), at(1, 0, 1, 0)).unwrap(), None);
305 lib.create_folder(None, "Longer metadata now").unwrap();
306 let second = backup_at(lib.root(), at(1, 0, 2, 0)).unwrap().unwrap();
307 assert_eq!(lib.backups().unwrap(), vec![second, first]);
308 }
309
310 #[test]
311 fn refuses_damaged_metadata() {
312 let (_d, lib) = temp_library();
313 fs::write(lib.root().join(METADATA), b"{\"folders\":[]}").unwrap();
314 assert_eq!(backup_at(lib.root(), at(1, 0, 0, 0)).unwrap(), None);
315 fs::write(lib.root().join(METADATA), b"{not json").unwrap();
316 assert_eq!(backup_at(lib.root(), at(1, 0, 0, 0)).unwrap(), None);
317 assert!(lib.backups().unwrap().is_empty());
318 }
319
320 #[test]
321 fn keeps_at_most_max_count() {
322 let (_d, lib) = temp_library();
323 let dir = lib.root().join(BACKUP_DIR);
324 fs::create_dir_all(&dir).unwrap();
325 for i in 0..120 {
326 fs::write(dir.join(backup_name(at(0, i / 60, i % 60, 0))), b"x").unwrap();
327 }
328 fs::write(dir.join("unrelated.txt"), b"keep").unwrap();
329 let new = backup_at(lib.root(), at(5, 0, 0, 0)).unwrap().unwrap();
330 let left = lib.backups().unwrap();
331 assert_eq!(left.len(), MAX_BACKUP_COUNT);
332 assert_eq!(left[0], new);
333 assert_eq!(left[1], dir.join(backup_name(at(0, 1, 59, 0))));
335 assert_eq!(left[99], dir.join(backup_name(at(0, 0, 21, 0))));
336 assert!(dir.join("unrelated.txt").exists());
337 }
338
339 #[test]
340 fn restores_from_chosen_backup() {
341 let (_d, mut lib) = temp_library();
342 let before = backup_at(lib.root(), at(1, 0, 0, 0)).unwrap().unwrap();
343 lib.create_folder(None, "Later").unwrap();
344 assert_eq!(lib.metadata.folders.len(), 1);
345 lib.restore_metadata_from_backup(&before).unwrap();
346 assert!(lib.metadata.folders.is_empty());
347 assert_eq!(fs::read(lib.root().join(METADATA)).unwrap(), fs::read(&before).unwrap());
348 let damaged = fs::read_dir(lib.root().join(BACKUP_DIR))
349 .unwrap()
350 .filter_map(|e| e.ok())
351 .filter(|e| e.file_name().to_string_lossy().starts_with("metadata.json.damaged-"))
352 .count();
353 assert_eq!(damaged, 1);
354 let bad = lib.root().join(BACKUP_DIR).join("backup-bad.json");
355 fs::write(&bad, b"[]").unwrap();
356 assert!(lib.restore_metadata_from_backup(&bad).is_err());
357 }
358
359 #[test]
360 fn open_or_restore_recovers_corrupt_metadata() {
361 let (_d, mut lib) = temp_library();
362 lib.create_folder(None, "Kept").unwrap();
363 let good = backup_at(lib.root(), at(1, 0, 0, 0)).unwrap().unwrap();
364 let dir = lib.root().join(BACKUP_DIR);
366 fs::write(dir.join(backup_name(at(2, 0, 0, 0))), b"{\"folders\":").unwrap();
367 fs::write(dir.join(backup_name(at(3, 0, 0, 0))), b"").unwrap();
368 fs::write(lib.root().join(METADATA), b"{\"folders\":[").unwrap();
369 assert!(Library::open(lib.root()).is_err());
370
371 let (reopened, used) = Library::open_or_restore(lib.root()).unwrap();
372 assert_eq!(used, Some(good));
373 assert_eq!(reopened.metadata.folders[0].name, "Kept");
374
375 let (_, used) = Library::open_or_restore(lib.root()).unwrap();
377 assert_eq!(used, None);
378 }
379}