1use crate::selection::{RowSelection, SelectionCtx};
20use anyhow::Result;
21use rusqlite::Connection;
22
23pub struct Pending<T> {
28 pub items: Vec<T>,
29 pub eligible: usize,
30}
31
32pub enum Work<T> {
34 Nothing(String),
36 Some(Pending<T>),
37}
38
39#[derive(Clone, Copy)]
47pub struct Words {
48 pub verb: &'static str,
50 pub gerund: &'static str,
52 pub nothing_pending: Option<&'static str>,
62}
63
64impl Words {
65 pub const fn new(verb: &'static str, gerund: &'static str) -> Self {
66 Words {
67 verb,
68 gerund,
69 nothing_pending: None,
70 }
71 }
72
73 pub const fn saying(mut self, nothing_pending: &'static str) -> Self {
75 self.nothing_pending = Some(nothing_pending);
76 self
77 }
78}
79
80pub fn narrow<T>(
90 pending: Vec<T>,
91 hash_of: impl Fn(&T) -> &str,
92 selection: &RowSelection,
93 conn: &Connection,
94 ctx: &SelectionCtx,
95 words: Words,
96 silent: bool,
97) -> Result<Work<T>> {
98 narrow_resolved(pending, hash_of, selection, words, silent, || {
99 selection.resolve(conn, ctx)
100 })
101}
102
103#[allow(clippy::too_many_arguments)]
107pub fn narrow_in<T>(
108 pending: Vec<T>,
109 hash_of: impl Fn(&T) -> &str,
110 selection: &RowSelection,
111 conn: &Connection,
112 ctx: &SelectionCtx,
113 library: &crate::library::LibraryContext,
114 words: Words,
115 silent: bool,
116) -> Result<Work<T>> {
117 narrow_resolved(pending, hash_of, selection, words, silent, || {
118 selection.resolve_in(conn, ctx, library)
119 })
120}
121
122fn narrow_resolved<T>(
123 pending: Vec<T>,
124 hash_of: impl Fn(&T) -> &str,
125 selection: &RowSelection,
126 words: Words,
127 silent: bool,
128 resolve: impl FnOnce() -> Result<crate::selection::Resolved>,
129) -> Result<Work<T>> {
130 if pending.is_empty() {
131 return Ok(Work::Nothing(match words.nothing_pending {
132 Some(m) => m.to_string(),
133 None => format!(
134 "Nothing to {}: everything eligible is already done.",
135 words.verb
136 ),
137 }));
138 }
139
140 let eligible = pending.len();
141 let items = if selection.is_empty() {
142 pending
143 } else {
144 let resolved = resolve()?;
145 match resolved.hashes {
146 None => pending,
150 Some(h) => pending
151 .into_iter()
152 .filter(|item| h.contains(hash_of(item)))
153 .collect(),
154 }
155 };
156
157 if !selection.is_empty() && !silent {
158 eprintln!(
162 "{} {} of {} pending item(s) ({})",
163 words.gerund,
164 items.len(),
165 eligible,
166 selection.describe()
167 );
168 }
169
170 if items.is_empty() {
171 return Ok(Work::Nothing(format!(
172 "Nothing to {}: the selection matched nothing pending.",
173 words.verb
174 )));
175 }
176
177 Ok(Work::Some(Pending { items, eligible }))
178}
179
180pub fn with_work<T, R>(
189 work: Work<T>,
190 silent: bool,
191 f: impl FnOnce(Pending<T>) -> Result<R>,
192) -> Result<Option<R>> {
193 match work {
194 Work::Nothing(msg) => {
195 if !silent {
196 eprintln!("{msg}");
197 }
198 Ok(None)
199 }
200 Work::Some(pending) => f(pending).map(Some),
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use std::cell::Cell;
208
209 const W: Words = Words::new("embed", "Embedding");
210
211 fn conn() -> Connection {
212 Connection::open_in_memory().unwrap()
213 }
214
215 fn db() -> Connection {
218 let c = Connection::open_in_memory().unwrap();
219 c.execute_batch(
220 "CREATE TABLE file_hashes (
221 path TEXT PRIMARY KEY, hash TEXT NOT NULL, size_bytes INTEGER,
222 created_at TEXT, modified_at TEXT, ext TEXT, mime TEXT, phash INTEGER,
223 exif_date TEXT, gps_lat REAL, gps_lon REAL, width INTEGER, height INTEGER);
224 INSERT INTO file_hashes (path, hash, ext, mime) VALUES
225 ('/lib/a.jpg','h_jpg','jpg','image/jpeg'),
226 ('/lib/b.mov','h_mov','mov','video/quicktime');",
227 )
228 .unwrap();
229 c
230 }
231
232 fn hash(s: &String) -> &str {
233 s.as_str()
234 }
235
236 #[test]
237 fn an_empty_pending_set_is_nothing_to_do() {
238 let c = conn();
239 let w = narrow(
240 Vec::<String>::new(),
241 hash,
242 &RowSelection::default(),
243 &c,
244 &SelectionCtx::default(),
245 W,
246 true,
247 )
248 .unwrap();
249 match w {
250 Work::Nothing(m) => {
251 assert_eq!(m, "Nothing to embed: everything eligible is already done.")
252 }
253 Work::Some(_) => panic!("an empty pending set must not be work"),
254 }
255 }
256
257 #[test]
258 fn no_selection_leaves_the_pending_set_untouched() {
259 let c = conn();
260 let w = narrow(
261 vec!["a".to_string(), "b".to_string()],
262 hash,
263 &RowSelection::default(),
264 &c,
265 &SelectionCtx::default(),
266 W,
267 true,
268 )
269 .unwrap();
270 match w {
271 Work::Some(p) => {
272 assert_eq!(p.items.len(), 2);
273 assert_eq!(p.eligible, 2, "eligible is the count before narrowing");
274 }
275 Work::Nothing(m) => panic!("unfiltered work was dropped: {m}"),
276 }
277 }
278
279 #[test]
280 fn a_caller_may_supply_its_own_empty_wording() {
281 let c = conn();
285 let w = narrow(
286 Vec::<String>::new(),
287 hash,
288 &RowSelection::default(),
289 &c,
290 &SelectionCtx::default(),
291 W.saying("All hashes already processed."),
292 true,
293 )
294 .unwrap();
295 match w {
296 Work::Nothing(m) => assert_eq!(m, "All hashes already processed."),
297 Work::Some(_) => panic!("an empty pending set must not be work"),
298 }
299 }
300
301 #[test]
302 fn a_selection_that_matches_nothing_is_nothing_to_do() {
303 let c = db();
307 let mut s = RowSelection::default();
308 s.exts = vec!["png".to_string()]; let w = narrow(
310 vec!["h_jpg".to_string(), "h_mov".to_string()],
311 hash,
312 &s,
313 &c,
314 &SelectionCtx::default(),
315 W,
316 true,
317 )
318 .unwrap();
319 match w {
320 Work::Nothing(m) => {
321 assert_eq!(
322 m,
323 "Nothing to embed: the selection matched nothing pending."
324 )
325 }
326 Work::Some(p) => panic!("{} item(s) survived a filter matching none", p.items.len()),
327 }
328 }
329
330 #[test]
331 fn a_selection_keeps_only_what_it_matched() {
332 let c = db();
333 let mut s = RowSelection::default();
334 s.exts = vec!["jpg".to_string()];
335 let w = narrow(
336 vec!["h_jpg".to_string(), "h_mov".to_string()],
337 hash,
338 &s,
339 &c,
340 &SelectionCtx::default(),
341 W,
342 true,
343 )
344 .unwrap();
345 match w {
346 Work::Some(p) => {
347 assert_eq!(p.items, vec!["h_jpg".to_string()]);
348 assert_eq!(p.eligible, 2, "the denominator is the pre-filter count");
349 }
350 Work::Nothing(m) => panic!("a matching filter dropped everything: {m}"),
351 }
352 }
353
354 #[test]
355 fn the_closure_never_runs_when_there_is_nothing_to_do() {
356 let ran = Cell::new(false);
360 let out = with_work(Work::<String>::Nothing("nothing".into()), true, |_| {
361 ran.set(true);
362 Ok(())
363 })
364 .unwrap();
365 assert!(
366 !ran.get(),
367 "no work must mean no closure, and so no model load"
368 );
369 assert!(out.is_none());
370 }
371
372 #[test]
373 fn the_closure_runs_and_returns_its_value_when_there_is_work() {
374 let ran = Cell::new(false);
375 let out = with_work(
376 Work::Some(Pending {
377 items: vec!["a".to_string()],
378 eligible: 1,
379 }),
380 true,
381 |p| {
382 ran.set(true);
383 Ok(p.items.len())
384 },
385 )
386 .unwrap();
387 assert!(ran.get());
388 assert_eq!(out, Some(1));
389 }
390
391 #[test]
392 fn an_error_from_the_closure_is_not_swallowed() {
393 let out = with_work(
394 Work::Some(Pending {
395 items: vec!["a".to_string()],
396 eligible: 1,
397 }),
398 true,
399 |_| -> Result<()> { anyhow::bail!("boom") },
400 );
401 assert!(
402 out.is_err(),
403 "the closure's failure is the caller's failure"
404 );
405 }
406}