Skip to main content

dev_prune/adapters/
cmake_build.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// CMake build-tree adapter.
5//
6// This is the adapter for the question "is that `build/` yours or CMake's?", and the
7// answer is never the directory's name. CMake writes a `CMakeCache.txt` at the top of
8// every build tree it configures and nobody writes one by hand; that file records
9// `CMAKE_HOME_DIRECTORY`, the source directory it was configured from, precisely so a
10// build tree can find its own sources again. So a directory is claimed only when it
11// carries a cache file whose recorded source directory still exists, still holds a
12// `CMakeLists.txt`, and sits inside this repository. A hand-made `build/` full of
13// someone's own artefacts has no cache file and is never touched.
14//
15// Opt-in, and held to `build_idle_days`: a build tree is object files and linked
16// binaries, and `cmake -S . -B <dir> && cmake --build <dir>` puts it back by compiling,
17// which for a C++ project of any size is the most expensive rebuild dev-prune can ask
18// for.
19//
20// The search stops descending the moment it finds a cache, so the sub-builds
21// `FetchContent` and CPM leave in `build/_deps/` are never claimed separately — they go
22// with the tree that owns them. It reaches three levels down rather than one because
23// Visual Studio's CMake integration configures into `out/build/<preset>/`, and it only
24// steps past a directory that holds a handful of subdirectories and nothing else, which
25// is what an out-of-source container looks like and what a dependency tree never does.
26
27use super::{BloatDir, EnforcePolicy, PackageManager, dir_size};
28use anyhow::{Result, anyhow};
29use std::fs;
30use std::path::{Path, PathBuf};
31
32/// The file CMake writes at the top of a build tree. Its presence is the whole proof.
33const CMAKE_CACHE: &str = "CMakeCache.txt";
34
35/// The cache entry naming the source tree this build tree was configured from.
36const HOME_DIRECTORY_KEY: &str = "CMAKE_HOME_DIRECTORY";
37
38/// How far below the project root a build tree is looked for.
39///
40/// Three, because `out/build/<preset>/CMakeCache.txt` is what Visual Studio produces.
41const MAX_DEPTH: usize = 3;
42
43/// The most entries a directory may hold and still be walked past.
44///
45/// A container in an out-of-source layout holds `build/`, sometimes `install/`, and
46/// nothing else. Anything wider is a real directory of content, and walking into it
47/// costs a `read_dir` per child for a build tree that is not there.
48const MAX_CONTAINER_ENTRIES: usize = 8;
49
50/// CMake build-tree adapter. Opt-in; see the module comment.
51pub struct CmakeBuild;
52
53/// The value of a `KEY:TYPE=VALUE` cache entry, whatever the type turns out to be.
54fn 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
64/// Whether `candidate` is a build tree configured from somewhere inside `project`.
65///
66/// Both sides are canonicalised before they are compared: CMake records the source
67/// directory with forward slashes on every platform, and on Windows the drive letter and
68/// path casing it wrote are not necessarily the ones on disk.
69fn 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    // A recorded source directory that is gone, or that no longer holds the file CMake
78    // read, cannot rebuild anything — whatever this tree is, it is not recoverable here.
79    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
88/// Whether the walk should step past `dir` looking for a build tree deeper down.
89fn 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
103/// Build trees under `project`, never `project` itself.
104///
105/// An in-source build puts `CMakeCache.txt` at the top of the repository, and the
106/// repository is not something this adapter can ever be allowed to claim, so the walk
107/// starts one level down.
108fn 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            // Stop here rather than descending: `_deps/` sub-builds belong to the tree
122            // that configured them and are deleted with it.
123            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    /// The per-directory proof already ran: [`find_build_trees`] claims a directory only
163    /// after its own `CMakeCache.txt` names a source tree inside this project. What is
164    /// left to check is that the top-level `CMakeLists.txt` is still readable and is
165    /// still a CMake script, because that is the file `cmake` is about to be pointed at.
166    /// Re-running `cmake` here to find out would configure a build tree in the middle of
167    /// a delete pass, which is the opposite of what was asked for.
168    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    /// A project root with a top-level `CMakeLists.txt`, canonicalised so the paths the
208    /// cache files record match what `fs::canonicalize` returns for them on macOS, where
209    /// the temp directory is reached through a symlink.
210    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    /// A build tree at `at`, recording `home` as the source directory it came from.
220    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        // The whole point of the adapter: two directories that both look like output,
252        // one configured by CMake and one somebody made, and only the first is claimed.
253        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        // Someone's build tree for another checkout, parked inside this repository. It
265        // is recoverable, but not from anything here, so this adapter does not own it.
266        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        // `cmake .` writes the cache file at the top of the repository. Deleting that
287        // directory would delete the project, so the walk starts one level down.
288        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        // A dependency tree is not an out-of-source container, and walking one costs a
307        // `read_dir` per package for a build tree that is not in there.
308        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        // `FetchContent` and CPM configure their dependencies inside `build/_deps/`.
322        // Claiming those separately would delete parts of a tree that is being deleted
323        // anyway, and report the same bytes twice.
324        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        // Debug and Release side by side is the normal way to work.
335        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        // CMake commands are case-insensitive, and plenty of older projects shout them.
354        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}