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    let Ok(root_meta) = std::fs::symlink_metadata(path) else {
27        return (0, 0);
28    };
29    if !root_meta.is_dir() {
30        return (root_meta.len(), 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.path().symlink_metadata() else {
42                continue;
43            };
44            if md.is_dir() {
45                stack.push(entry.path());
46            } else {
47                bytes += md.len();
48                files += 1;
49            }
50        }
51    }
52    (bytes, files)
53}
54
55/// Everything stored for one selected library, largest first.
56pub fn usage_in(ctx: &crate::library::LibraryContext) -> Vec<Usage> {
57    if ctx.ensure_root_identity().is_err() {
58        return Vec::new();
59    }
60
61    fn row(label: &'static str, path: PathBuf, rebuildable: bool) -> Option<Usage> {
62        let (bytes, files) = dir_size(&path);
63        (bytes > 0).then_some(Usage {
64            label,
65            path,
66            bytes,
67            files,
68            rebuildable,
69        })
70    }
71
72    let mut out = Vec::new();
73    out.extend(row("database", ctx.paths.db.clone(), false));
74    let (mut journal_bytes, mut journal_files) = (0, 0);
75    for suffix in ["-wal", "-shm"] {
76        let mut path = ctx.paths.db.as_os_str().to_owned();
77        path.push(suffix);
78        let (bytes, files) = dir_size(Path::new(&path));
79        journal_bytes += bytes;
80        journal_files += files;
81    }
82    if journal_bytes > 0 {
83        let mut path = ctx.paths.db.as_os_str().to_owned();
84        path.push("-wal");
85        out.push(Usage {
86            label: "database journal",
87            path: PathBuf::from(path),
88            bytes: journal_bytes,
89            files: journal_files,
90            rebuildable: true,
91        });
92    }
93    out.extend(row("embeddings", ctx.paths.embeddings.clone(), false));
94    out.extend(row("thumbnails", ctx.cache.thumbnails.clone(), true));
95    out.extend(row("place names (shared)", ctx.cache.geo.clone(), true));
96    out.extend(row("locks", ctx.paths.locks.clone(), true));
97    out.sort_by(|a, b| b.bytes.cmp(&a.bytes));
98    out
99}
100
101/// Bytes as a person would say them: `1.4 GB`, `812 MB`, `4.0 KB`.
102pub fn human_bytes(bytes: u64) -> String {
103    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
104    if bytes < 1024 {
105        return format!("{bytes} B");
106    }
107    let mut v = bytes as f64;
108    let mut unit = 0;
109    while v >= 1024.0 && unit < UNITS.len() - 1 {
110        v /= 1024.0;
111        unit += 1;
112    }
113    format!("{v:.1} {}", UNITS[unit])
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn a_missing_path_is_zero_not_an_error() {
122        assert_eq!(dir_size(Path::new("/definitely/not/here")), (0, 0));
123    }
124
125    #[test]
126    fn sizes_a_tree_including_nested_files() {
127        let d = tempfile::tempdir().unwrap();
128        std::fs::write(d.path().join("a"), b"12345").unwrap();
129        std::fs::create_dir(d.path().join("sub")).unwrap();
130        std::fs::write(d.path().join("sub/b"), b"123").unwrap();
131        assert_eq!(dir_size(d.path()), (8, 2));
132    }
133
134    #[test]
135    fn a_single_file_sizes_as_itself() {
136        let d = tempfile::tempdir().unwrap();
137        let f = d.path().join("x");
138        std::fs::write(&f, b"1234").unwrap();
139        assert_eq!(dir_size(&f), (4, 1));
140    }
141
142    #[test]
143    fn bytes_read_the_way_a_person_says_them() {
144        assert_eq!(human_bytes(0), "0 B");
145        assert_eq!(human_bytes(512), "512 B");
146        assert_eq!(human_bytes(1024), "1.0 KB");
147        assert_eq!(human_bytes(1_048_576), "1.0 MB");
148        assert_eq!(human_bytes(1_503_238_553), "1.4 GB");
149    }
150
151    #[test]
152    fn explicit_usage_reports_only_the_selected_library_and_labels_shared_geo() {
153        let temp = tempfile::tempdir().unwrap();
154        let root = temp.path().join("library");
155        let cache = temp.path().join("cache");
156        std::fs::create_dir(&root).unwrap();
157        let ctx = crate::library::LibraryContext::new(&root, &cache).unwrap();
158        std::fs::create_dir_all(&ctx.paths.embeddings).unwrap();
159        std::fs::create_dir_all(&ctx.cache.thumbnails).unwrap();
160        std::fs::create_dir_all(&ctx.cache.geo).unwrap();
161        std::fs::write(&ctx.paths.db, vec![0u8; 16]).unwrap();
162        std::fs::write(ctx.paths.embeddings.join("model.db"), vec![0u8; 32]).unwrap();
163        std::fs::write(ctx.cache.thumbnails.join("thumb.jpg"), vec![0u8; 64]).unwrap();
164        std::fs::write(ctx.cache.geo.join("cities.csv"), vec![0u8; 8]).unwrap();
165
166        let usage = usage_in(&ctx);
167        let labels: Vec<_> = usage.iter().map(|item| item.label).collect();
168        assert!(labels.contains(&"database"));
169        assert!(labels.contains(&"embeddings"));
170        assert!(labels.contains(&"thumbnails"));
171        assert!(labels.contains(&"place names (shared)"));
172        assert!(!labels.contains(&"embeddings (other libraries)"));
173    }
174
175    #[cfg(unix)]
176    #[test]
177    fn directory_size_does_not_follow_symlinks() {
178        let temp = tempfile::tempdir().unwrap();
179        let outside = tempfile::tempdir().unwrap();
180        std::fs::write(outside.path().join("large"), vec![0u8; 4096]).unwrap();
181        std::os::unix::fs::symlink(outside.path(), temp.path().join("link")).unwrap();
182        let (bytes, files) = dir_size(temp.path());
183        assert_eq!(files, 1);
184        assert!(bytes < 4096);
185    }
186}