Skip to main content

videre_core/
disk.rs

1//! What videre is using on disk, and which part of it is what.
2//!
3//! "videre is using 40GB" is not an answer anyone can act on. "of which 38GB is
4//! the thumbnail cache" is, because one of those is deletable and the rest is
5//! not. Everything here exists to make that distinction reportable.
6
7use std::path::{Path, PathBuf};
8
9/// One thing taking up space, and whether losing it would cost anything.
10pub struct Usage {
11    pub label: &'static str,
12    pub path: PathBuf,
13    pub bytes: u64,
14    pub files: u64,
15    /// True when deleting it costs only the time to rebuild. Thumbnails and
16    /// HEIC conversions regenerate from the originals; embeddings take hours
17    /// and the database cannot be rebuilt at all without a rescan.
18    pub rebuildable: bool,
19}
20
21/// Total bytes and file count under `path`, following no symlinks.
22///
23/// Returns `(0, 0)` for a missing path rather than erroring: every caller is
24/// reporting, and "not there" and "empty" mean the same thing to a reader.
25pub fn dir_size(path: &Path) -> (u64, u64) {
26    if !path.exists() {
27        return (0, 0);
28    }
29    if path.is_file() {
30        return (path.metadata().map(|m| m.len()).unwrap_or(0), 1);
31    }
32    let (mut bytes, mut files) = (0u64, 0u64);
33    let mut stack = vec![path.to_path_buf()];
34    while let Some(dir) = stack.pop() {
35        let Ok(entries) = std::fs::read_dir(&dir) else {
36            continue;
37        };
38        for entry in entries.flatten() {
39            // symlink_metadata, so a link into the library is counted as the
40            // link it is rather than the gigabytes it points at.
41            let Ok(md) = entry.metadata() else { continue };
42            if md.is_dir() {
43                stack.push(entry.path());
44            } else {
45                bytes += md.len();
46                files += 1;
47            }
48        }
49    }
50    (bytes, files)
51}
52
53/// Everything videre stores, largest first.
54///
55/// `db` is passed in because the database is the one piece that does not have
56/// to live under the home directory: `--db` puts it anywhere.
57///
58/// :warning: **Embeddings are per library, not per home.** They live in
59/// `<home>/embeddings/<db stem>-<hash16>`, so summing the whole `embeddings`
60/// directory attributes every library's vectors to whichever one is being
61/// reported. `lib_embeddings` is this library's directory; anything else under
62/// there is reported separately and labelled as belonging to other libraries,
63/// because it is real disk use but not this library's.
64///
65/// :warning: The thumbnail cache is **not** always under the home directory.
66/// With `VIDERE_HOME` unset it lives in the platform cache directory, so a
67/// report that only walked the home would silently omit the largest deletable
68/// thing videre owns. That is why it is a parameter rather than being looked up
69/// here: `thumb_cache::cache_dir()` reads the environment, and a function whose
70/// result depends on an env var it never mentions cannot be tested or reasoned
71/// about. Callers pass it in.
72pub fn usage(
73    home: &Path,
74    db: Option<&Path>,
75    thumbs: &Path,
76    lib_embeddings: Option<&Path>,
77) -> Vec<Usage> {
78    /// Sizes `path` and returns a row for it, or `None` when there is nothing
79    /// there.
80    ///
81    /// Zero-byte entries are omitted rather than listed as `0 B`: an empty WAL
82    /// or an unused locks directory is a row to skip past, not information.
83    fn row(label: &'static str, path: PathBuf, rebuildable: bool) -> Option<Usage> {
84        let (bytes, files) = dir_size(&path);
85        (bytes > 0).then_some(Usage {
86            label,
87            path,
88            bytes,
89            files,
90            rebuildable,
91        })
92    }
93
94    let mut out: Vec<Usage> = Vec::new();
95
96    if let Some(db) = db {
97        out.extend(row("database", db.to_path_buf(), false));
98
99        // WAL and shm are one thing to a reader, and can be a meaningful
100        // fraction of the total mid-run. Summed into a single row, because two
101        // rows the reader has to add up is not a report.
102        let (mut bytes, mut files) = (0u64, 0u64);
103        let mut wal = db.as_os_str().to_owned();
104        for suffix in ["-wal", "-shm"] {
105            let mut p = db.as_os_str().to_owned();
106            p.push(suffix);
107            let (b, f) = dir_size(Path::new(&p));
108            bytes += b;
109            files += f;
110        }
111        if bytes > 0 {
112            wal.push("-wal");
113            out.push(Usage {
114                label: "database journal",
115                path: PathBuf::from(wal),
116                bytes,
117                files,
118                rebuildable: true,
119            });
120        }
121    }
122    // This library's vectors, then everything else under `embeddings/` as its
123    // own row: the difference matters because `stats` is reporting one library
124    // while the disk is shared by all of them.
125    let all_embeddings = dir_size(&home.join("embeddings")).0;
126    let mine = match lib_embeddings {
127        Some(dir) => {
128            let r = row("embeddings", dir.to_path_buf(), false);
129            let n = r.as_ref().map(|u| u.bytes).unwrap_or(0);
130            out.extend(r);
131            n
132        }
133        None => {
134            out.extend(row("embeddings", home.join("embeddings"), false));
135            all_embeddings
136        }
137    };
138    if all_embeddings > mine {
139        out.push(Usage {
140            label: "embeddings (other libraries)",
141            path: home.join("embeddings"),
142            bytes: all_embeddings - mine,
143            files: 0,
144            rebuildable: false,
145        });
146    }
147    out.extend(row("thumbnails", thumbs.to_path_buf(), true));
148    out.extend(row("place names", home.join("geo"), true));
149    out.extend(row("locks", home.join("locks"), true));
150
151    out.sort_by(|a, b| b.bytes.cmp(&a.bytes));
152    out
153}
154
155/// Bytes as a person would say them: `1.4 GB`, `812 MB`, `4.0 KB`.
156pub fn human_bytes(bytes: u64) -> String {
157    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
158    if bytes < 1024 {
159        return format!("{bytes} B");
160    }
161    let mut v = bytes as f64;
162    let mut unit = 0;
163    while v >= 1024.0 && unit < UNITS.len() - 1 {
164        v /= 1024.0;
165        unit += 1;
166    }
167    format!("{v:.1} {}", UNITS[unit])
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn a_missing_path_is_zero_not_an_error() {
176        assert_eq!(dir_size(Path::new("/definitely/not/here")), (0, 0));
177    }
178
179    #[test]
180    fn sizes_a_tree_including_nested_files() {
181        let d = tempfile::tempdir().unwrap();
182        std::fs::write(d.path().join("a"), b"12345").unwrap();
183        std::fs::create_dir(d.path().join("sub")).unwrap();
184        std::fs::write(d.path().join("sub/b"), b"123").unwrap();
185        assert_eq!(dir_size(d.path()), (8, 2));
186    }
187
188    #[test]
189    fn a_single_file_sizes_as_itself() {
190        let d = tempfile::tempdir().unwrap();
191        let f = d.path().join("x");
192        std::fs::write(&f, b"1234").unwrap();
193        assert_eq!(dir_size(&f), (4, 1));
194    }
195
196    #[test]
197    fn bytes_read_the_way_a_person_says_them() {
198        assert_eq!(human_bytes(0), "0 B");
199        assert_eq!(human_bytes(512), "512 B");
200        assert_eq!(human_bytes(1024), "1.0 KB");
201        assert_eq!(human_bytes(1_048_576), "1.0 MB");
202        assert_eq!(human_bytes(1_503_238_553), "1.4 GB");
203    }
204
205    #[test]
206    fn usage_reports_the_database_and_marks_what_is_rebuildable() {
207        let d = tempfile::tempdir().unwrap();
208        let db = d.path().join("t.db");
209        std::fs::write(&db, vec![0u8; 4096]).unwrap();
210        std::fs::create_dir_all(d.path().join("embeddings")).unwrap();
211        std::fs::write(d.path().join("embeddings/m.db"), vec![0u8; 8192]).unwrap();
212
213        let u = usage(d.path(), Some(&db), &d.path().join("no-thumbs"), None);
214        let by = |l: &str| u.iter().find(|x| x.label == l);
215
216        // Largest first, so embeddings outranks the smaller database.
217        assert_eq!(u[0].label, "embeddings");
218        assert_eq!(by("embeddings").unwrap().bytes, 8192);
219        assert_eq!(by("database").unwrap().bytes, 4096);
220        assert!(!by("database").unwrap().rebuildable, "a database is not");
221        assert!(
222            !by("embeddings").unwrap().rebuildable,
223            "embeddings cost hours; losing them is not free"
224        );
225    }
226
227    #[test]
228    fn another_librarys_embeddings_are_not_counted_as_this_ones() {
229        // Embeddings live at <home>/embeddings/<stem>-<hash>, so the directory
230        // is shared by every library on the machine. Reporting the whole thing
231        // against one `--db` would inflate it by however many other libraries
232        // exist, which is exactly the kind of number nobody notices is wrong.
233        let d = tempfile::tempdir().unwrap();
234        let db = d.path().join("t.db");
235        std::fs::write(&db, vec![0u8; 16]).unwrap();
236        let mine = d.path().join("embeddings/mine-0000000000000001");
237        let theirs = d.path().join("embeddings/theirs-0000000000000002");
238        std::fs::create_dir_all(&mine).unwrap();
239        std::fs::create_dir_all(&theirs).unwrap();
240        std::fs::write(mine.join("m.db"), vec![0u8; 1000]).unwrap();
241        std::fs::write(theirs.join("m.db"), vec![0u8; 7000]).unwrap();
242
243        let u = usage(
244            d.path(),
245            Some(&db),
246            &d.path().join("no-thumbs"),
247            Some(&mine),
248        );
249        let by = |l: &str| u.iter().find(|x| x.label == l).map(|x| x.bytes);
250        assert_eq!(by("embeddings"), Some(1000), "only this library's vectors");
251        assert_eq!(
252            by("embeddings (other libraries)"),
253            Some(7000),
254            "the rest is still disk use, but it is not this library's"
255        );
256    }
257
258    #[test]
259    fn nothing_present_reports_nothing_rather_than_a_row_of_zeroes() {
260        let d = tempfile::tempdir().unwrap();
261        let u = usage(d.path(), None, &d.path().join("no-thumbs"), None);
262        assert!(
263            u.iter().all(|x| x.bytes > 0 || x.files > 0),
264            "empty locations must not be listed"
265        );
266    }
267}