Skip to main content

cache_manager/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use std::env;
5use std::fs;
6use std::fs::OpenOptions;
7use std::io;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11/// Optional eviction controls applied by `CacheGroup::ensure_dir_with_policy`
12/// and `CacheRoot::ensure_group_with_policy`.
13///
14/// Rules are enforced in this order:
15/// 1. `max_age` (remove files older than or equal to threshold)
16/// 2. `max_files` (keep at most N files)
17/// 3. `max_bytes` (keep total bytes at or below threshold)
18///
19/// For `max_files` and `max_bytes`, candidates are ordered by modified time
20/// ascending (oldest first), then by path for deterministic tie-breaking.
21#[derive(Clone, Debug, PartialEq, Eq, Default)]
22pub struct EvictPolicy {
23    /// Maximum number of files to keep under the managed directory tree.
24    ///
25    /// If exceeded, the oldest files are removed first until the count is
26    /// `<= max_files`.
27    pub max_files: Option<usize>,
28    /// Maximum total size in bytes to keep under the managed directory tree.
29    ///
30    /// If exceeded, files are removed oldest-first until total bytes are
31    /// `<= max_bytes`.
32    ///
33    /// Notes:
34    /// - The limit applies to regular files recursively under the directory.
35    /// - Directories are not counted toward the byte total.
36    /// - Enforced only when using a policy-aware `ensure_*_with_policy` call.
37    pub max_bytes: Option<u64>,
38    /// Maximum file age allowed under the managed directory tree.
39    ///
40    /// Files with age `>= max_age` are removed.
41    pub max_age: Option<Duration>,
42}
43
44/// Files selected for eviction by policy evaluation.
45#[derive(Clone, Debug, PartialEq, Eq, Default)]
46pub struct EvictionReport {
47    /// Absolute paths marked for eviction, in the order they would be applied.
48    pub marked_for_eviction: Vec<PathBuf>,
49}
50
51#[derive(Clone, Debug)]
52struct FileEntry {
53    path: PathBuf,
54    modified: SystemTime,
55    len: u64,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq)]
59/// Represents a discovered or explicit cache root directory.
60///
61/// Use `CacheRoot::discover()` to find the nearest crate root from the
62/// current working directory, or `CacheRoot::from_root(...)` to construct one
63/// from an explicit path.
64pub struct CacheRoot {
65    root: PathBuf,
66}
67
68impl CacheRoot {
69    /// Discover the cache root by searching parent directories for `Cargo.toml`.
70    ///
71    /// Falls back to the current working directory when no crate root is found.
72    pub fn discover() -> io::Result<Self> {
73        let cwd = env::current_dir()?;
74        let root = find_crate_root(&cwd).unwrap_or(cwd);
75        // Prefer a canonicalized path when possible to avoid surprising
76        // differences between logically-equal paths (symlinks, tempdir
77        // representations, etc.) used by callers and tests.
78        let root = root.canonicalize().unwrap_or(root);
79        Ok(Self { root })
80    }
81
82    /// Create a `CacheRoot` from an explicit filesystem path.
83    pub fn from_root<P: Into<PathBuf>>(root: P) -> Self {
84        Self { root: root.into() }
85    }
86
87    /// Like `discover()` but never returns an `io::Result` — falls back to `.` on error.
88    pub fn discover_or_cwd() -> Self {
89        let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
90        let root = find_crate_root(&cwd).unwrap_or(cwd);
91        let root = root.canonicalize().unwrap_or(root);
92        Self { root }
93    }
94
95    /// Return the underlying path for this `CacheRoot`.
96    pub fn path(&self) -> &Path {
97        &self.root
98    }
99
100    /// Build a `CacheGroup` for a relative subdirectory under this root.
101    pub fn group<P: AsRef<Path>>(&self, relative_group: P) -> CacheGroup {
102        let path = self.root.join(relative_group.as_ref());
103        CacheGroup { path }
104    }
105
106    /// Resolve a relative group path to an absolute `PathBuf` under this root.
107    pub fn group_path<P: AsRef<Path>>(&self, relative_group: P) -> PathBuf {
108        self.root.join(relative_group.as_ref())
109    }
110
111    /// Ensure the given group directory exists, creating parents as required.
112    pub fn ensure_group<P: AsRef<Path>>(&self, relative_group: P) -> io::Result<PathBuf> {
113        let group = self.group_path(relative_group);
114        fs::create_dir_all(&group)?;
115        Ok(group)
116    }
117
118    /// Ensure the given group exists and optionally apply an eviction policy.
119    ///
120    /// When `policy` is `Some`, files will be evaluated and removed according
121    /// to the `EvictPolicy` rules. Passing `None` performs only directory creation.
122    pub fn ensure_group_with_policy<P: AsRef<Path>>(
123        &self,
124        relative_group: P,
125        policy: Option<&EvictPolicy>,
126    ) -> io::Result<PathBuf> {
127        let group = self.group(relative_group);
128        group.ensure_dir_with_policy(policy)?;
129        Ok(group.path().to_path_buf())
130    }
131
132    /// Resolve a cache entry path given a cache directory (relative to the root)
133    /// and a relative entry path. Absolute `relative_path` values are returned
134    /// unchanged.
135    pub fn cache_path<P: AsRef<Path>, Q: AsRef<Path>>(
136        &self,
137        cache_dir: P,
138        relative_path: Q,
139    ) -> PathBuf {
140        let rel = relative_path.as_ref();
141        if rel.is_absolute() {
142            return rel.to_path_buf();
143        }
144        self.group(cache_dir).entry_path(rel)
145    }
146
147    /// Discover the crate root (or use cwd) and resolve a cache entry path.
148    ///
149    /// Convenience wrapper for `CacheRoot::discover_or_cwd().cache_path(...)`.
150    pub fn discover_cache_path<P: AsRef<Path>, Q: AsRef<Path>>(
151        cache_dir: P,
152        relative_path: Q,
153    ) -> PathBuf {
154        Self::discover_or_cwd().cache_path(cache_dir, relative_path)
155    }
156}
157
158#[derive(Clone, Debug, PartialEq, Eq)]
159/// A group (subdirectory) under a `CacheRoot` that manages cache entries.
160///
161/// Use `CacheRoot::group(...)` to construct a `CacheGroup` rooted under a
162/// `CacheRoot`.
163pub struct CacheGroup {
164    path: PathBuf,
165}
166
167impl CacheGroup {
168    /// Return the path of this cache group.
169    pub fn path(&self) -> &Path {
170        &self.path
171    }
172
173    /// Ensure the group directory exists on disk, creating parents as needed.
174    pub fn ensure_dir(&self) -> io::Result<&Path> {
175        fs::create_dir_all(&self.path)?;
176        Ok(&self.path)
177    }
178
179    /// Ensures this directory exists, then applies optional eviction.
180    ///
181    /// Eviction is applied recursively to files under this directory. The
182    /// policy is best-effort for removals: individual delete failures are
183    /// ignored so initialization can continue.
184    pub fn ensure_dir_with_policy(&self, policy: Option<&EvictPolicy>) -> io::Result<&Path> {
185        fs::create_dir_all(&self.path)?;
186        if let Some(policy) = policy {
187            apply_evict_policy(&self.path, policy)?;
188        }
189        Ok(&self.path)
190    }
191
192    /// Returns a report of files that would be evicted under `policy`.
193    ///
194    /// This does not delete files. The selection order matches the internal
195    /// order used by `ensure_dir_with_policy`.
196    /// Return a report of files that would be evicted by `policy`.
197    ///
198    /// The report is non-destructive and mirrors the selection used by
199    /// `ensure_dir_with_policy` so it can be used for previewing or testing.
200    pub fn eviction_report(&self, policy: &EvictPolicy) -> io::Result<EvictionReport> {
201        build_eviction_report(&self.path, policy)
202    }
203
204    /// Create a nested subgroup under this group.
205    pub fn subgroup<P: AsRef<Path>>(&self, relative_group: P) -> Self {
206        Self {
207            path: self.path.join(relative_group.as_ref()),
208        }
209    }
210
211    /// Resolve a relative entry path under this group.
212    pub fn entry_path<P: AsRef<Path>>(&self, relative_file: P) -> PathBuf {
213        self.path.join(relative_file.as_ref())
214    }
215
216    /// Create or update (touch) a file under this group, creating parent
217    /// directories as needed. Returns the absolute path to the entry.
218    pub fn touch<P: AsRef<Path>>(&self, relative_file: P) -> io::Result<PathBuf> {
219        let entry = self.entry_path(relative_file);
220        if let Some(parent) = entry.parent() {
221            fs::create_dir_all(parent)?;
222        }
223        OpenOptions::new().create(true).append(true).open(&entry)?;
224        Ok(entry)
225    }
226}
227
228fn find_crate_root(start: &Path) -> Option<PathBuf> {
229    let mut current = start.to_path_buf();
230    loop {
231        if current.join("Cargo.toml").is_file() {
232            return Some(current);
233        }
234        if !current.pop() {
235            return None;
236        }
237    }
238}
239
240fn apply_evict_policy(root: &Path, policy: &EvictPolicy) -> io::Result<()> {
241    let report = build_eviction_report(root, policy)?;
242
243    for path in report.marked_for_eviction {
244        let _ = fs::remove_file(path);
245    }
246
247    Ok(())
248}
249
250fn sort_entries_oldest_first(entries: &mut [FileEntry]) {
251    entries.sort_by(|a, b| {
252        let ta = a
253            .modified
254            .duration_since(UNIX_EPOCH)
255            .unwrap_or(Duration::ZERO);
256        let tb = b
257            .modified
258            .duration_since(UNIX_EPOCH)
259            .unwrap_or(Duration::ZERO);
260        ta.cmp(&tb).then_with(|| a.path.cmp(&b.path))
261    });
262}
263
264fn build_eviction_report(root: &Path, policy: &EvictPolicy) -> io::Result<EvictionReport> {
265    let mut entries = collect_files(root)?;
266    let mut marked_for_eviction = Vec::new();
267
268    if let Some(max_age) = policy.max_age {
269        let now = SystemTime::now();
270        let mut survivors = Vec::with_capacity(entries.len());
271        for entry in entries {
272            let age = now.duration_since(entry.modified).unwrap_or(Duration::ZERO);
273            if age >= max_age {
274                marked_for_eviction.push(entry.path);
275            } else {
276                survivors.push(entry);
277            }
278        }
279        entries = survivors;
280    }
281
282    sort_entries_oldest_first(&mut entries);
283
284    if let Some(max_files) = policy.max_files
285        && entries.len() > max_files
286    {
287        let to_remove = entries.len() - max_files;
288        for entry in entries.iter().take(to_remove) {
289            marked_for_eviction.push(entry.path.clone());
290        }
291        entries = entries.into_iter().skip(to_remove).collect();
292        sort_entries_oldest_first(&mut entries);
293    }
294
295    if let Some(max_bytes) = policy.max_bytes {
296        let mut total: u64 = entries.iter().map(|e| e.len).sum();
297        if total > max_bytes {
298            for entry in &entries {
299                if total <= max_bytes {
300                    break;
301                }
302                marked_for_eviction.push(entry.path.clone());
303                total = total.saturating_sub(entry.len);
304            }
305        }
306    }
307
308    Ok(EvictionReport {
309        marked_for_eviction,
310    })
311}
312
313fn collect_files(root: &Path) -> io::Result<Vec<FileEntry>> {
314    let mut out = Vec::new();
315    collect_files_recursive(root, &mut out)?;
316    Ok(out)
317}
318
319fn collect_files_recursive(dir: &Path, out: &mut Vec<FileEntry>) -> io::Result<()> {
320    for entry in fs::read_dir(dir)? {
321        let entry = entry?;
322        let path = entry.path();
323        let meta = entry.metadata()?;
324        if meta.is_dir() {
325            collect_files_recursive(&path, out)?;
326        } else if meta.is_file() {
327            out.push(FileEntry {
328                path,
329                modified: meta.modified().unwrap_or(UNIX_EPOCH),
330                len: meta.len(),
331            });
332        }
333    }
334    Ok(())
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use std::collections::BTreeSet;
341    use tempfile::TempDir;
342
343    struct CwdGuard {
344        previous: PathBuf,
345    }
346
347    impl CwdGuard {
348        fn swap_to(path: &Path) -> io::Result<Self> {
349            let previous = env::current_dir()?;
350            // Try to switch to `path`. If that fails, attempt to canonicalize
351            // the path and try again (helps on platforms where the tempdir
352            // representation differs or when symlinks are involved).
353            match env::set_current_dir(path) {
354                Ok(()) => Ok(Self { previous }),
355                Err(e) => {
356                    if let Ok(canon) = path.canonicalize() {
357                        env::set_current_dir(&canon)?;
358                        Ok(Self { previous })
359                    } else {
360                        Err(e)
361                    }
362                }
363            }
364        }
365    }
366
367    impl Drop for CwdGuard {
368        fn drop(&mut self) {
369            let _ = env::set_current_dir(&self.previous);
370        }
371    }
372
373    #[test]
374    fn discover_falls_back_to_cwd_when_no_cargo_toml() {
375        let tmp = TempDir::new().expect("tempdir");
376        let _guard = CwdGuard::swap_to(tmp.path()).expect("set cwd");
377
378        let cache = CacheRoot::discover().expect("discover");
379        let got = cache
380            .path()
381            .canonicalize()
382            .expect("canonicalize discovered root");
383        let expected = tmp.path().canonicalize().expect("canonicalize temp path");
384        assert_eq!(got, expected);
385    }
386
387    #[test]
388    fn discover_prefers_nearest_crate_root() {
389        let tmp = TempDir::new().expect("tempdir");
390        let crate_root = tmp.path().join("workspace");
391        let nested = crate_root.join("src").join("nested");
392        fs::create_dir_all(&nested).expect("create nested");
393        fs::write(
394            crate_root.join("Cargo.toml"),
395            "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
396        )
397        .expect("write cargo");
398
399        let _guard = CwdGuard::swap_to(&nested).expect("set cwd");
400        let cache = CacheRoot::discover().expect("discover");
401        let got = cache
402            .path()
403            .canonicalize()
404            .expect("canonicalize discovered root");
405        let expected = crate_root.canonicalize().expect("canonicalize crate root");
406        assert_eq!(got, expected);
407    }
408
409    #[test]
410    fn from_root_supports_arbitrary_path_and_grouping() {
411        let tmp = TempDir::new().expect("tempdir");
412        let root = CacheRoot::from_root(tmp.path().join("custom-cache-root"));
413        let group = root.group("taxonomy/v1");
414
415        assert_eq!(group.path(), root.path().join("taxonomy/v1").as_path());
416    }
417
418    #[test]
419    fn group_path_building_and_dir_creation() {
420        let tmp = TempDir::new().expect("tempdir");
421        let cache = CacheRoot::from_root(tmp.path());
422        let group = cache.group("artifacts/json");
423
424        let nested_group = group.subgroup("v1");
425        let ensured = nested_group.ensure_dir().expect("ensure nested dir");
426        let expected_group_suffix = Path::new("artifacts").join("json").join("v1");
427        assert!(ensured.ends_with(&expected_group_suffix));
428        assert!(ensured.exists());
429
430        let entry = nested_group.entry_path("a/b/cache.json");
431        let expected_entry_suffix = Path::new("artifacts")
432            .join("json")
433            .join("v1")
434            .join("a")
435            .join("b")
436            .join("cache.json");
437        assert!(entry.ends_with(&expected_entry_suffix));
438    }
439
440    #[test]
441    fn touch_creates_blank_file_and_is_idempotent() {
442        let tmp = TempDir::new().expect("tempdir");
443        let cache = CacheRoot::from_root(tmp.path());
444        let group = cache.group("artifacts/json");
445
446        let touched = group.touch("a/b/cache.json").expect("touch file");
447        assert!(touched.exists());
448        let meta = fs::metadata(&touched).expect("metadata");
449        assert_eq!(meta.len(), 0);
450
451        let touched_again = group.touch("a/b/cache.json").expect("touch file again");
452        assert_eq!(touched_again, touched);
453        let meta_again = fs::metadata(&touched_again).expect("metadata again");
454        assert_eq!(meta_again.len(), 0);
455    }
456
457    #[test]
458    fn discover_cache_path_uses_root_and_group() {
459        let tmp = TempDir::new().expect("tempdir");
460        let crate_root = tmp.path().join("workspace");
461        let nested = crate_root.join("src").join("nested");
462        fs::create_dir_all(&nested).expect("create nested");
463        fs::write(
464            crate_root.join("Cargo.toml"),
465            "[package]\nname='x'\nversion='0.1.0'\nedition='2024'\n",
466        )
467        .expect("write cargo");
468
469        let _guard = CwdGuard::swap_to(&nested).expect("set cwd");
470        let p = CacheRoot::discover_cache_path(".cache", "taxonomy/taxonomy_cache.json");
471        let parent = p.parent().expect("cache path parent");
472        fs::create_dir_all(parent).expect("create cache parent");
473        // Ensure the expected (non-canonicalized) parent path also exists
474        // so canonicalization succeeds on platforms where temporary paths
475        // may differ from the discovered/canonicalized root.
476        let expected_dir = crate_root.join(".cache").join("taxonomy");
477        fs::create_dir_all(&expected_dir).expect("create expected cache parent");
478        let got_parent = p
479            .parent()
480            .expect("cache path parent")
481            .canonicalize()
482            .expect("canonicalize cache parent");
483        let expected_parent = crate_root
484            .join(".cache")
485            .join("taxonomy")
486            .canonicalize()
487            .expect("canonicalize expected parent");
488        assert_eq!(got_parent, expected_parent);
489        assert_eq!(
490            p.file_name().and_then(|s| s.to_str()),
491            Some("taxonomy_cache.json")
492        );
493    }
494
495    #[test]
496    fn cache_path_preserves_absolute_paths() {
497        let root = CacheRoot::from_root("/tmp/project");
498        let absolute = PathBuf::from("/tmp/custom/cache.json");
499        let resolved = root.cache_path(".cache", &absolute);
500        assert_eq!(resolved, absolute);
501    }
502
503    #[test]
504    fn ensure_dir_with_policy_max_files() {
505        let tmp = TempDir::new().expect("tempdir");
506        let cache = CacheRoot::from_root(tmp.path());
507        let group = cache.group("artifacts");
508        group.ensure_dir().expect("ensure dir");
509
510        fs::write(group.entry_path("a.txt"), b"1").expect("write a");
511        fs::write(group.entry_path("b.txt"), b"1").expect("write b");
512        fs::write(group.entry_path("c.txt"), b"1").expect("write c");
513
514        let policy = EvictPolicy {
515            max_files: Some(2),
516            ..EvictPolicy::default()
517        };
518        group
519            .ensure_dir_with_policy(Some(&policy))
520            .expect("ensure with policy");
521
522        let files = collect_files(group.path()).expect("collect files");
523        assert_eq!(files.len(), 2);
524    }
525
526    #[test]
527    fn ensure_dir_with_policy_max_bytes() {
528        let tmp = TempDir::new().expect("tempdir");
529        let cache = CacheRoot::from_root(tmp.path());
530        let group = cache.group("artifacts");
531        group.ensure_dir().expect("ensure dir");
532
533        fs::write(group.entry_path("a.bin"), vec![1u8; 5]).expect("write a");
534        fs::write(group.entry_path("b.bin"), vec![1u8; 5]).expect("write b");
535        fs::write(group.entry_path("c.bin"), vec![1u8; 5]).expect("write c");
536
537        let policy = EvictPolicy {
538            max_bytes: Some(10),
539            ..EvictPolicy::default()
540        };
541        group
542            .ensure_dir_with_policy(Some(&policy))
543            .expect("ensure with policy");
544
545        let total: u64 = collect_files(group.path())
546            .expect("collect files")
547            .iter()
548            .map(|f| f.len)
549            .sum();
550        assert!(total <= 10);
551    }
552
553    #[test]
554    fn ensure_dir_with_policy_max_age_zero_evicts_all() {
555        let tmp = TempDir::new().expect("tempdir");
556        let cache = CacheRoot::from_root(tmp.path());
557        let group = cache.group("artifacts");
558        group.ensure_dir().expect("ensure dir");
559
560        fs::write(group.entry_path("a.txt"), b"1").expect("write a");
561        fs::write(group.entry_path("b.txt"), b"1").expect("write b");
562
563        let policy = EvictPolicy {
564            max_age: Some(Duration::ZERO),
565            ..EvictPolicy::default()
566        };
567        group
568            .ensure_dir_with_policy(Some(&policy))
569            .expect("ensure with policy");
570
571        let files = collect_files(group.path()).expect("collect files");
572        assert!(files.is_empty());
573    }
574
575    #[test]
576    fn eviction_report_matches_applied_evictions() {
577        let tmp = TempDir::new().expect("tempdir");
578        let cache = CacheRoot::from_root(tmp.path());
579        let group = cache.group("artifacts");
580        group.ensure_dir().expect("ensure dir");
581
582        fs::write(group.entry_path("a.bin"), vec![1u8; 5]).expect("write a");
583        fs::write(group.entry_path("b.bin"), vec![1u8; 5]).expect("write b");
584        fs::write(group.entry_path("c.bin"), vec![1u8; 5]).expect("write c");
585
586        let policy = EvictPolicy {
587            max_bytes: Some(10),
588            ..EvictPolicy::default()
589        };
590
591        let before: BTreeSet<PathBuf> = collect_files(group.path())
592            .expect("collect before")
593            .into_iter()
594            .map(|f| f.path)
595            .collect();
596
597        let report = group.eviction_report(&policy).expect("eviction report");
598        let planned: BTreeSet<PathBuf> = report.marked_for_eviction.iter().cloned().collect();
599
600        group
601            .ensure_dir_with_policy(Some(&policy))
602            .expect("ensure with policy");
603
604        let after: BTreeSet<PathBuf> = collect_files(group.path())
605            .expect("collect after")
606            .into_iter()
607            .map(|f| f.path)
608            .collect();
609
610        let expected_after: BTreeSet<PathBuf> = before.difference(&planned).cloned().collect();
611        assert_eq!(after, expected_after);
612    }
613
614    #[test]
615    fn no_policy_and_default_policy_report_do_not_mark_evictions() {
616        let tmp = TempDir::new().expect("tempdir");
617        let cache = CacheRoot::from_root(tmp.path());
618        let group = cache.group("artifacts");
619        group.ensure_dir().expect("ensure dir");
620
621        fs::write(group.entry_path("a.txt"), b"1").expect("write a");
622        fs::write(group.entry_path("b.txt"), b"1").expect("write b");
623
624        let report = group
625            .eviction_report(&EvictPolicy::default())
626            .expect("eviction report");
627        assert!(report.marked_for_eviction.is_empty());
628
629        group
630            .ensure_dir_with_policy(None)
631            .expect("ensure with no policy");
632
633        let files = collect_files(group.path()).expect("collect files");
634        assert_eq!(files.len(), 2);
635    }
636
637    #[test]
638    fn single_root_supports_distinct_policies_per_subdirectory() {
639        let tmp = TempDir::new().expect("tempdir");
640        let cache = CacheRoot::from_root(tmp.path());
641
642        let images = cache.group("artifacts/images");
643        let reports = cache.group("artifacts/reports");
644
645        images.ensure_dir().expect("ensure images dir");
646        reports.ensure_dir().expect("ensure reports dir");
647
648        fs::write(images.entry_path("img1.bin"), vec![1u8; 5]).expect("write img1");
649        fs::write(images.entry_path("img2.bin"), vec![1u8; 5]).expect("write img2");
650        fs::write(images.entry_path("img3.bin"), vec![1u8; 5]).expect("write img3");
651
652        fs::write(reports.entry_path("a.txt"), b"1").expect("write report a");
653        fs::write(reports.entry_path("b.txt"), b"1").expect("write report b");
654        fs::write(reports.entry_path("c.txt"), b"1").expect("write report c");
655
656        let images_policy = EvictPolicy {
657            max_bytes: Some(10),
658            ..EvictPolicy::default()
659        };
660        let reports_policy = EvictPolicy {
661            max_files: Some(1),
662            ..EvictPolicy::default()
663        };
664
665        images
666            .ensure_dir_with_policy(Some(&images_policy))
667            .expect("apply images policy");
668        reports
669            .ensure_dir_with_policy(Some(&reports_policy))
670            .expect("apply reports policy");
671
672        let images_total: u64 = collect_files(images.path())
673            .expect("collect images files")
674            .iter()
675            .map(|f| f.len)
676            .sum();
677        assert!(images_total <= 10);
678
679        let reports_files = collect_files(reports.path()).expect("collect reports files");
680        assert_eq!(reports_files.len(), 1);
681    }
682}