dev_prune/adapters/
cmake_build.rs1use super::{BloatDir, EnforcePolicy, PackageManager, dir_size};
28use anyhow::{Result, anyhow};
29use std::fs;
30use std::path::{Path, PathBuf};
31
32const CMAKE_CACHE: &str = "CMakeCache.txt";
34
35const HOME_DIRECTORY_KEY: &str = "CMAKE_HOME_DIRECTORY";
37
38const MAX_DEPTH: usize = 3;
42
43const MAX_CONTAINER_ENTRIES: usize = 8;
49
50pub struct CmakeBuild;
52
53fn cache_entry(cache: &str, key: &str) -> Option<String> {
55 cache.lines().find_map(|line| {
56 let (name, rest) = line.trim().split_once(':')?;
57 if name != key {
58 return None;
59 }
60 rest.split_once('=').map(|(_, v)| v.trim().to_string())
61 })
62}
63
64fn belongs_to(candidate: &Path, project: &Path) -> bool {
70 let Ok(cache) = fs::read_to_string(candidate.join(CMAKE_CACHE)) else {
71 return false;
72 };
73 let Some(home) = cache_entry(&cache, HOME_DIRECTORY_KEY) else {
74 return false;
75 };
76 let home = PathBuf::from(home);
77 if !home.join("CMakeLists.txt").is_file() {
80 return false;
81 }
82 match (fs::canonicalize(&home), fs::canonicalize(project)) {
83 (Ok(home), Ok(project)) => home.starts_with(&project),
84 _ => false,
85 }
86}
87
88fn is_container(dir: &Path) -> bool {
90 let Ok(entries) = fs::read_dir(dir) else {
91 return false;
92 };
93 let mut count = 0;
94 for entry in entries.flatten() {
95 count += 1;
96 if count > MAX_CONTAINER_ENTRIES || !entry.path().is_dir() {
97 return false;
98 }
99 }
100 count > 0
101}
102
103fn find_build_trees(project: &Path, dir: &Path, depth: usize, found: &mut Vec<PathBuf>) {
109 if depth > MAX_DEPTH {
110 return;
111 }
112 let Ok(entries) = fs::read_dir(dir) else {
113 return;
114 };
115 for entry in entries.flatten() {
116 let path = entry.path();
117 if !path.is_dir() || path.file_name().is_some_and(|n| n == ".git") {
118 continue;
119 }
120 if path.join(CMAKE_CACHE).is_file() {
121 if belongs_to(&path, project) {
124 found.push(path);
125 }
126 continue;
127 }
128 if is_container(&path) {
129 find_build_trees(project, &path, depth + 1, found);
130 }
131 }
132}
133
134impl PackageManager for CmakeBuild {
135 fn name(&self) -> &'static str {
136 "cmake_build"
137 }
138
139 fn detect(&self, path: &Path) -> bool {
140 path.join("CMakeLists.txt").is_file()
141 }
142
143 fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
144 let mut found = Vec::new();
145 find_build_trees(path, path, 1, &mut found);
146 found.sort();
147 found
148 .into_iter()
149 .map(|tree| BloatDir {
150 name: tree
151 .strip_prefix(path)
152 .unwrap_or(&tree)
153 .to_string_lossy()
154 .replace('\\', "/"),
155 size_bytes: dir_size(&tree),
156 path: tree,
157 shared_bytes: 0,
158 })
159 .collect()
160 }
161
162 fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
169 let manifest = path.join("CMakeLists.txt");
170 let content = fs::read_to_string(&manifest).map_err(|e| {
171 anyhow!(
172 "`CMakeLists.txt` could not be read ({e}) — nothing to reconfigure the build \
173 tree from."
174 )
175 })?;
176 let lowered = content.to_ascii_lowercase();
177 if !lowered.contains("cmake_minimum_required") && !lowered.contains("project(") {
178 return Err(anyhow!(
179 "`CMakeLists.txt` declares neither `cmake_minimum_required` nor `project()` — \
180 refusing to treat the build tree as reconfigurable from it."
181 ));
182 }
183 Ok(())
184 }
185
186 fn restore(&self, _path: &Path, _timeout: std::time::Duration) -> Result<()> {
187 println!(
188 "CMake build tree will regenerate on the next `cmake -S . -B <dir> && cmake --build <dir>`"
189 );
190 Ok(())
191 }
192
193 fn lockfiles(&self) -> &'static [&'static str] {
194 &["CMakeLists.txt"]
195 }
196
197 fn opt_in(&self) -> bool {
198 true
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use tempfile::tempdir;
206
207 fn project(dir: &Path) -> PathBuf {
211 fs::write(
212 dir.join("CMakeLists.txt"),
213 "cmake_minimum_required(VERSION 3.20)\nproject(demo)\n",
214 )
215 .unwrap();
216 fs::canonicalize(dir).unwrap()
217 }
218
219 fn build_tree(at: &Path, home: &Path) {
221 fs::create_dir_all(at).unwrap();
222 fs::write(
223 at.join(CMAKE_CACHE),
224 format!(
225 "# This is the CMakeCache file.\nCMAKE_BUILD_TYPE:STRING=Debug\n{HOME_DIRECTORY_KEY}:INTERNAL={}\n",
226 home.display().to_string().replace('\\', "/")
227 ),
228 )
229 .unwrap();
230 fs::write(at.join("build.ninja"), "# generated").unwrap();
231 }
232
233 fn claimed(project: &Path) -> Vec<String> {
234 CmakeBuild
235 .bloat_dirs(project)
236 .into_iter()
237 .map(|b| b.name)
238 .collect()
239 }
240
241 #[test]
242 fn detects_on_the_top_level_cmakelists() {
243 let dir = tempdir().unwrap();
244 assert!(!CmakeBuild.detect(dir.path()));
245 project(dir.path());
246 assert!(CmakeBuild.detect(dir.path()));
247 }
248
249 #[test]
250 fn a_cache_file_is_what_separates_cmakes_build_from_yours() {
251 let dir = tempdir().unwrap();
254 let root = project(dir.path());
255 build_tree(&root.join("build"), &root);
256 fs::create_dir(root.join("output")).unwrap();
257 fs::write(root.join("output").join("notes.txt"), "hand made").unwrap();
258
259 assert_eq!(claimed(&root), vec!["build"]);
260 }
261
262 #[test]
263 fn a_build_tree_configured_from_somewhere_else_is_refused() {
264 let dir = tempdir().unwrap();
267 let other = tempdir().unwrap();
268 let root = project(dir.path());
269 let elsewhere = project(other.path());
270 build_tree(&root.join("build"), &elsewhere);
271
272 assert!(claimed(&root).is_empty());
273 }
274
275 #[test]
276 fn a_cache_pointing_at_a_vanished_source_tree_is_refused() {
277 let dir = tempdir().unwrap();
278 let root = project(dir.path());
279 build_tree(&root.join("build"), &root.join("gone"));
280
281 assert!(claimed(&root).is_empty());
282 }
283
284 #[test]
285 fn an_in_source_build_never_claims_the_repository() {
286 let dir = tempdir().unwrap();
289 let root = project(dir.path());
290 build_tree(&root, &root);
291
292 assert!(claimed(&root).is_empty());
293 }
294
295 #[test]
296 fn the_visual_studio_layout_is_found_three_levels_down() {
297 let dir = tempdir().unwrap();
298 let root = project(dir.path());
299 build_tree(&root.join("out").join("build").join("x64-Debug"), &root);
300
301 assert_eq!(claimed(&root), vec!["out/build/x64-Debug"]);
302 }
303
304 #[test]
305 fn a_wide_directory_is_not_walked_into() {
306 let dir = tempdir().unwrap();
309 let root = project(dir.path());
310 let modules = root.join("node_modules");
311 for i in 0..MAX_CONTAINER_ENTRIES + 2 {
312 fs::create_dir_all(modules.join(format!("pkg{i}"))).unwrap();
313 }
314 build_tree(&modules.join("pkg0").join("build"), &root);
315
316 assert!(claimed(&root).is_empty());
317 }
318
319 #[test]
320 fn a_sub_build_goes_with_the_tree_that_configured_it() {
321 let dir = tempdir().unwrap();
325 let root = project(dir.path());
326 build_tree(&root.join("build"), &root);
327 build_tree(&root.join("build").join("_deps").join("fmt-build"), &root);
328
329 assert_eq!(claimed(&root), vec!["build"]);
330 }
331
332 #[test]
333 fn every_configured_tree_is_claimed() {
334 let dir = tempdir().unwrap();
336 let root = project(dir.path());
337 build_tree(&root.join("cmake-build-debug"), &root);
338 build_tree(&root.join("cmake-build-release"), &root);
339
340 assert_eq!(
341 claimed(&root),
342 vec!["cmake-build-debug", "cmake-build-release"]
343 );
344 }
345
346 #[test]
347 fn a_missing_or_bogus_cmakelists_is_refused() {
348 let dir = tempdir().unwrap();
349 let policy = EnforcePolicy::default();
350 assert!(CmakeBuild.enforce_lockfile(dir.path(), policy).is_err());
351 fs::write(dir.path().join("CMakeLists.txt"), "hello there").unwrap();
352 assert!(CmakeBuild.enforce_lockfile(dir.path(), policy).is_err());
353 fs::write(
355 dir.path().join("CMakeLists.txt"),
356 "CMAKE_MINIMUM_REQUIRED(VERSION 3.20)\nPROJECT(demo)\n",
357 )
358 .unwrap();
359 assert!(CmakeBuild.enforce_lockfile(dir.path(), policy).is_ok());
360 }
361
362 #[test]
363 fn cache_entries_are_read_whatever_their_type_is() {
364 let cache = "CMAKE_HOME_DIRECTORY:INTERNAL=/src/proj\nOTHER:BOOL=ON\n";
365 assert_eq!(
366 cache_entry(cache, HOME_DIRECTORY_KEY),
367 Some("/src/proj".to_string())
368 );
369 assert_eq!(cache_entry(cache, "MISSING"), None);
370 assert_eq!(
371 cache_entry("CMAKE_HOME_DIRECTORY\n", HOME_DIRECTORY_KEY),
372 None
373 );
374 }
375
376 #[test]
377 fn cmake_build_is_opt_in() {
378 assert!(CmakeBuild.opt_in());
379 }
380}