Skip to main content

falcon_library/
repair.rs

1//! Clear Invalid Content: finding item folders that are empty or broken,
2//! after Eagle's repair tool of that name.
3//!
4//! Eagle walks `images/` and counts as invalid:
5//! - an `<id>.info` folder that isn't a loaded item and has no
6//!   `metadata.json`: the folder is removed;
7//! - a loaded item whose original file is gone (whether or not its folder
8//!   holds anything else): the item is moved to the Trash (if it isn't
9//!   there already). An item whose original exists is never invalid, however
10//!   few files its folder holds;
11//! - a loaded item whose folder has gone altogether: it drops out when the
12//!   library is reloaded.
13//!
14//! It asks first, warning that a sync tool may not have finished, then
15//! cleans up and reloads the library. falcon follows this, with one
16//! difference: an orphaned folder is handed to the caller's `discard` (the
17//! app moves it to the Trash) instead of being deleted. A folder with a
18//! `metadata.json` that can't be read is left alone, as Eagle leaves it
19//! (falcon reports such items when the library opens).
20//!
21//! [`scan`] needs only a copy of the items, so it runs without the library
22//! lock; [`Library::clear_invalid`] applies a scan.
23
24use std::collections::HashSet;
25use std::fs;
26use std::path::{Path, PathBuf};
27
28use crate::error::Result;
29use crate::library::Library;
30use crate::model::Item;
31
32/// What a scan found.
33#[derive(Debug, Clone, Default, PartialEq)]
34pub struct InvalidScan {
35    /// `*.info` folders looked at.
36    pub scanned: usize,
37    /// Folders that are no item and have no `metadata.json`.
38    pub orphans: Vec<PathBuf>,
39    /// Items whose original is gone, not yet in
40    /// the Trash.
41    pub broken: Vec<String>,
42    /// Items whose folder has gone.
43    pub gone: Vec<String>,
44}
45
46impl InvalidScan {
47    /// How many things the cleanup would deal with.
48    pub fn total(&self) -> usize {
49        self.orphans.len() + self.broken.len() + self.gone.len()
50    }
51}
52
53/// Scans the library at `root`, whose loaded items are `items`.
54pub fn scan(root: &Path, items: &[Item]) -> InvalidScan {
55    let mut out = InvalidScan::default();
56    let known: HashSet<&str> = items.iter().map(|i| i.id.as_str()).collect();
57    let images = root.join("images");
58    if let Ok(entries) = fs::read_dir(&images) {
59        let mut dirs: Vec<PathBuf> = entries
60            .filter_map(|e| e.ok())
61            .filter(|e| e.file_name().to_string_lossy().ends_with(".info") && e.path().is_dir())
62            .map(|e| e.path())
63            .collect();
64        dirs.sort();
65        for dir in dirs {
66            out.scanned += 1;
67            let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
68            let id = name.trim_end_matches(".info");
69            if !known.contains(id) && !dir.join("metadata.json").exists() {
70                out.orphans.push(dir);
71            }
72        }
73    }
74    for item in items {
75        let dir = item.info_dir(root);
76        if !dir.is_dir() {
77            out.gone.push(item.id.clone());
78        } else if !item.is_deleted && !item.original_path(root).exists() {
79            out.broken.push(item.id.clone());
80        }
81    }
82    out
83}
84
85/// What [`Library::clear_invalid`] did.
86#[derive(Debug, Clone, Default, PartialEq)]
87pub struct Cleared {
88    pub trashed: usize,
89    pub removed: usize,
90    pub forgotten: usize,
91    /// Folders `discard` refused, with the reason.
92    pub failed: Vec<(PathBuf, String)>,
93}
94
95impl Library {
96    /// Applies `scan`: moves its broken items to the Trash (one undoable
97    /// step when a transaction is open), forgets items whose folder has
98    /// gone, and hands each orphaned folder still there to `discard`.
99    pub fn clear_invalid(&mut self, scan: &InvalidScan, mut discard: impl FnMut(&Path) -> std::io::Result<()>) -> Result<Cleared> {
100        let mut out = Cleared::default();
101        let broken: Vec<String> = scan.broken.iter().filter(|id| self.item(id).is_some_and(|i| !i.is_deleted)).cloned().collect();
102        if !broken.is_empty() {
103            self.set_trashed(&broken, true)?;
104            out.trashed = broken.len();
105        }
106        for id in &scan.gone {
107            if let Some(item) = self.item(id)
108                && !item.info_dir(self.root()).exists()
109            {
110                self.forget_item(id)?;
111                out.forgotten += 1;
112            }
113        }
114        for dir in &scan.orphans {
115            if !dir.is_dir() || dir.join("metadata.json").exists() || !dir.starts_with(self.root().join("images")) {
116                continue;
117            }
118            match discard(dir) {
119                Ok(()) => out.removed += 1,
120                Err(e) => out.failed.push((dir.clone(), e.to_string())),
121            }
122        }
123        Ok(out)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::import::ImportOptions;
131
132    fn setup() -> (tempfile::TempDir, Library, Vec<String>) {
133        let d = tempfile::tempdir().unwrap();
134        let mut lib = Library::create(d.path().join("L.library")).unwrap();
135        let mut ids = Vec::new();
136        for n in ["a.txt", "b.txt", "c.txt", "d.txt"] {
137            let p = d.path().join(n);
138            fs::write(&p, n).unwrap();
139            ids.push(lib.import_file(&p, &ImportOptions::default()).unwrap());
140        }
141        (d, lib, ids)
142    }
143
144    #[test]
145    fn a_healthy_library_has_nothing_invalid() {
146        let (_d, lib, _) = setup();
147        let s = scan(lib.root(), lib.items());
148        assert_eq!(s.scanned, 4);
149        assert_eq!(s.total(), 0);
150    }
151
152    #[test]
153    fn finds_and_clears_each_kind() {
154        let (_d, mut lib, ids) = setup();
155        let images = lib.root().join("images");
156        // A folder that is no item and has no metadata.json.
157        fs::create_dir_all(images.join("ORPHAN.info")).unwrap();
158        fs::write(images.join("ORPHAN.info/x.png"), b"x").unwrap();
159        // A folder with an unreadable metadata.json is left alone.
160        fs::create_dir_all(images.join("DAMAGED.info")).unwrap();
161        fs::write(images.join("DAMAGED.info/metadata.json"), b"{").unwrap();
162        // A missing original.
163        let a = lib.item(&ids[0]).unwrap().clone();
164        fs::remove_file(a.original_path(lib.root())).unwrap();
165        // A folder that has gone.
166        let b = lib.item(&ids[1]).unwrap().clone();
167        fs::remove_dir_all(b.info_dir(lib.root())).unwrap();
168        // A missing original already in the Trash is left alone.
169        lib.set_trashed(std::slice::from_ref(&ids[2]), true).unwrap();
170        let c = lib.item(&ids[2]).unwrap().clone();
171        fs::remove_file(c.original_path(lib.root())).unwrap();
172
173        let s = scan(lib.root(), lib.items());
174        assert_eq!(s.orphans, [images.join("ORPHAN.info")]);
175        assert_eq!(s.broken, [ids[0].clone()]);
176        assert_eq!(s.gone, [ids[1].clone()]);
177        assert_eq!(s.total(), 3);
178
179        let mut discarded = Vec::new();
180        lib.begin_transaction("Clear Invalid Content");
181        let done = lib
182            .clear_invalid(&s, |p| {
183                discarded.push(p.to_path_buf());
184                fs::remove_dir_all(p)
185            })
186            .unwrap();
187        let tx = lib.end_transaction().unwrap();
188        assert_eq!(done, Cleared { trashed: 1, removed: 1, forgotten: 1, failed: vec![] });
189        assert_eq!(discarded, [images.join("ORPHAN.info")]);
190        assert!(lib.item(&ids[0]).unwrap().is_deleted);
191        assert!(lib.item(&ids[1]).is_none());
192        assert!(images.join("DAMAGED.info").exists());
193        assert!(!lib.item(&ids[3]).unwrap().is_deleted);
194        // Forgetting a vanished item can't be undone.
195        assert!(tx.irreversible);
196        let again = scan(lib.root(), lib.items());
197        assert_eq!(again.total(), 0);
198    }
199
200    #[test]
201    fn an_empty_item_folder_counts_as_broken() {
202        let (_d, lib, ids) = setup();
203        let item = lib.item(&ids[3]).unwrap().clone();
204        for e in fs::read_dir(item.info_dir(lib.root())).unwrap() {
205            let p = e.unwrap().path();
206            if p.file_name().unwrap() != "metadata.json" {
207                fs::remove_file(p).unwrap();
208            }
209        }
210        fs::write(item.info_dir(lib.root()).join(".DS_Store"), b"").unwrap();
211        assert_eq!(scan(lib.root(), lib.items()).broken, [ids[3].clone()]);
212    }
213
214    #[test]
215    fn an_item_whose_original_exists_is_never_invalid() {
216        let (_d, lib, ids) = setup();
217        let item = lib.item(&ids[3]).unwrap().clone();
218        let dir = item.info_dir(lib.root());
219        let original = item.original_path(lib.root());
220        for e in fs::read_dir(&dir).unwrap() {
221            let p = e.unwrap().path();
222            if p != original {
223                fs::remove_file(p).unwrap();
224            }
225        }
226        // Only the original is left: one file, but the original exists.
227        assert_eq!(fs::read_dir(&dir).unwrap().count(), 1);
228        let s = scan(lib.root(), lib.items());
229        assert!(s.broken.is_empty());
230        assert_eq!(s.total(), 0);
231    }
232
233    #[test]
234    fn a_refused_discard_is_reported() {
235        let (_d, mut lib, _) = setup();
236        let orphan = lib.root().join("images/ORPHAN.info");
237        fs::create_dir_all(&orphan).unwrap();
238        let s = scan(lib.root(), lib.items());
239        let done = lib.clear_invalid(&s, |_| Err(std::io::Error::other("no Trash"))).unwrap();
240        assert_eq!(done.removed, 0);
241        assert_eq!(done.failed.len(), 1);
242        assert!(orphan.exists());
243    }
244}