1use 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#[derive(Debug, Clone, Default, PartialEq)]
34pub struct InvalidScan {
35 pub scanned: usize,
37 pub orphans: Vec<PathBuf>,
39 pub broken: Vec<String>,
42 pub gone: Vec<String>,
44}
45
46impl InvalidScan {
47 pub fn total(&self) -> usize {
49 self.orphans.len() + self.broken.len() + self.gone.len()
50 }
51}
52
53pub 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#[derive(Debug, Clone, Default, PartialEq)]
87pub struct Cleared {
88 pub trashed: usize,
89 pub removed: usize,
90 pub forgotten: usize,
91 pub failed: Vec<(PathBuf, String)>,
93}
94
95impl Library {
96 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 fs::create_dir_all(images.join("ORPHAN.info")).unwrap();
158 fs::write(images.join("ORPHAN.info/x.png"), b"x").unwrap();
159 fs::create_dir_all(images.join("DAMAGED.info")).unwrap();
161 fs::write(images.join("DAMAGED.info/metadata.json"), b"{").unwrap();
162 let a = lib.item(&ids[0]).unwrap().clone();
164 fs::remove_file(a.original_path(lib.root())).unwrap();
165 let b = lib.item(&ids[1]).unwrap().clone();
167 fs::remove_dir_all(b.info_dir(lib.root())).unwrap();
168 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 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 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}