1use std::path::{Path, PathBuf};
8
9pub struct Usage {
11 pub label: &'static str,
12 pub path: PathBuf,
13 pub bytes: u64,
14 pub files: u64,
15 pub rebuildable: bool,
19}
20
21pub 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 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
55pub 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
101pub 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}