use super::{BloatDir, EnforcePolicy, PackageManager, dir_size};
use anyhow::{Result, anyhow};
use std::fs;
use std::path::{Path, PathBuf};
const CMAKE_CACHE: &str = "CMakeCache.txt";
const HOME_DIRECTORY_KEY: &str = "CMAKE_HOME_DIRECTORY";
const MAX_DEPTH: usize = 3;
const MAX_CONTAINER_ENTRIES: usize = 8;
pub struct CmakeBuild;
fn cache_entry(cache: &str, key: &str) -> Option<String> {
cache.lines().find_map(|line| {
let (name, rest) = line.trim().split_once(':')?;
if name != key {
return None;
}
rest.split_once('=').map(|(_, v)| v.trim().to_string())
})
}
fn belongs_to(candidate: &Path, project: &Path) -> bool {
let Ok(cache) = fs::read_to_string(candidate.join(CMAKE_CACHE)) else {
return false;
};
let Some(home) = cache_entry(&cache, HOME_DIRECTORY_KEY) else {
return false;
};
let home = PathBuf::from(home);
if !home.join("CMakeLists.txt").is_file() {
return false;
}
match (fs::canonicalize(&home), fs::canonicalize(project)) {
(Ok(home), Ok(project)) => home.starts_with(&project),
_ => false,
}
}
fn is_container(dir: &Path) -> bool {
let Ok(entries) = fs::read_dir(dir) else {
return false;
};
let mut count = 0;
for entry in entries.flatten() {
count += 1;
if count > MAX_CONTAINER_ENTRIES || !entry.path().is_dir() {
return false;
}
}
count > 0
}
fn find_build_trees(project: &Path, dir: &Path, depth: usize, found: &mut Vec<PathBuf>) {
if depth > MAX_DEPTH {
return;
}
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() || path.file_name().is_some_and(|n| n == ".git") {
continue;
}
if path.join(CMAKE_CACHE).is_file() {
if belongs_to(&path, project) {
found.push(path);
}
continue;
}
if is_container(&path) {
find_build_trees(project, &path, depth + 1, found);
}
}
}
impl PackageManager for CmakeBuild {
fn name(&self) -> &'static str {
"cmake_build"
}
fn detect(&self, path: &Path) -> bool {
path.join("CMakeLists.txt").is_file()
}
fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
let mut found = Vec::new();
find_build_trees(path, path, 1, &mut found);
found.sort();
found
.into_iter()
.map(|tree| BloatDir {
name: tree
.strip_prefix(path)
.unwrap_or(&tree)
.to_string_lossy()
.replace('\\', "/"),
size_bytes: dir_size(&tree),
path: tree,
shared_bytes: 0,
})
.collect()
}
fn enforce_lockfile(&self, path: &Path, _policy: EnforcePolicy) -> Result<()> {
let manifest = path.join("CMakeLists.txt");
let content = fs::read_to_string(&manifest).map_err(|e| {
anyhow!(
"`CMakeLists.txt` could not be read ({e}) — nothing to reconfigure the build \
tree from."
)
})?;
let lowered = content.to_ascii_lowercase();
if !lowered.contains("cmake_minimum_required") && !lowered.contains("project(") {
return Err(anyhow!(
"`CMakeLists.txt` declares neither `cmake_minimum_required` nor `project()` — \
refusing to treat the build tree as reconfigurable from it."
));
}
Ok(())
}
fn restore(&self, _path: &Path, _timeout: std::time::Duration) -> Result<()> {
println!(
"CMake build tree will regenerate on the next `cmake -S . -B <dir> && cmake --build <dir>`"
);
Ok(())
}
fn lockfiles(&self) -> &'static [&'static str] {
&["CMakeLists.txt"]
}
fn opt_in(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn project(dir: &Path) -> PathBuf {
fs::write(
dir.join("CMakeLists.txt"),
"cmake_minimum_required(VERSION 3.20)\nproject(demo)\n",
)
.unwrap();
fs::canonicalize(dir).unwrap()
}
fn build_tree(at: &Path, home: &Path) {
fs::create_dir_all(at).unwrap();
fs::write(
at.join(CMAKE_CACHE),
format!(
"# This is the CMakeCache file.\nCMAKE_BUILD_TYPE:STRING=Debug\n{HOME_DIRECTORY_KEY}:INTERNAL={}\n",
home.display().to_string().replace('\\', "/")
),
)
.unwrap();
fs::write(at.join("build.ninja"), "# generated").unwrap();
}
fn claimed(project: &Path) -> Vec<String> {
CmakeBuild
.bloat_dirs(project)
.into_iter()
.map(|b| b.name)
.collect()
}
#[test]
fn detects_on_the_top_level_cmakelists() {
let dir = tempdir().unwrap();
assert!(!CmakeBuild.detect(dir.path()));
project(dir.path());
assert!(CmakeBuild.detect(dir.path()));
}
#[test]
fn a_cache_file_is_what_separates_cmakes_build_from_yours() {
let dir = tempdir().unwrap();
let root = project(dir.path());
build_tree(&root.join("build"), &root);
fs::create_dir(root.join("output")).unwrap();
fs::write(root.join("output").join("notes.txt"), "hand made").unwrap();
assert_eq!(claimed(&root), vec!["build"]);
}
#[test]
fn a_build_tree_configured_from_somewhere_else_is_refused() {
let dir = tempdir().unwrap();
let other = tempdir().unwrap();
let root = project(dir.path());
let elsewhere = project(other.path());
build_tree(&root.join("build"), &elsewhere);
assert!(claimed(&root).is_empty());
}
#[test]
fn a_cache_pointing_at_a_vanished_source_tree_is_refused() {
let dir = tempdir().unwrap();
let root = project(dir.path());
build_tree(&root.join("build"), &root.join("gone"));
assert!(claimed(&root).is_empty());
}
#[test]
fn an_in_source_build_never_claims_the_repository() {
let dir = tempdir().unwrap();
let root = project(dir.path());
build_tree(&root, &root);
assert!(claimed(&root).is_empty());
}
#[test]
fn the_visual_studio_layout_is_found_three_levels_down() {
let dir = tempdir().unwrap();
let root = project(dir.path());
build_tree(&root.join("out").join("build").join("x64-Debug"), &root);
assert_eq!(claimed(&root), vec!["out/build/x64-Debug"]);
}
#[test]
fn a_wide_directory_is_not_walked_into() {
let dir = tempdir().unwrap();
let root = project(dir.path());
let modules = root.join("node_modules");
for i in 0..MAX_CONTAINER_ENTRIES + 2 {
fs::create_dir_all(modules.join(format!("pkg{i}"))).unwrap();
}
build_tree(&modules.join("pkg0").join("build"), &root);
assert!(claimed(&root).is_empty());
}
#[test]
fn a_sub_build_goes_with_the_tree_that_configured_it() {
let dir = tempdir().unwrap();
let root = project(dir.path());
build_tree(&root.join("build"), &root);
build_tree(&root.join("build").join("_deps").join("fmt-build"), &root);
assert_eq!(claimed(&root), vec!["build"]);
}
#[test]
fn every_configured_tree_is_claimed() {
let dir = tempdir().unwrap();
let root = project(dir.path());
build_tree(&root.join("cmake-build-debug"), &root);
build_tree(&root.join("cmake-build-release"), &root);
assert_eq!(
claimed(&root),
vec!["cmake-build-debug", "cmake-build-release"]
);
}
#[test]
fn a_missing_or_bogus_cmakelists_is_refused() {
let dir = tempdir().unwrap();
let policy = EnforcePolicy::default();
assert!(CmakeBuild.enforce_lockfile(dir.path(), policy).is_err());
fs::write(dir.path().join("CMakeLists.txt"), "hello there").unwrap();
assert!(CmakeBuild.enforce_lockfile(dir.path(), policy).is_err());
fs::write(
dir.path().join("CMakeLists.txt"),
"CMAKE_MINIMUM_REQUIRED(VERSION 3.20)\nPROJECT(demo)\n",
)
.unwrap();
assert!(CmakeBuild.enforce_lockfile(dir.path(), policy).is_ok());
}
#[test]
fn cache_entries_are_read_whatever_their_type_is() {
let cache = "CMAKE_HOME_DIRECTORY:INTERNAL=/src/proj\nOTHER:BOOL=ON\n";
assert_eq!(
cache_entry(cache, HOME_DIRECTORY_KEY),
Some("/src/proj".to_string())
);
assert_eq!(cache_entry(cache, "MISSING"), None);
assert_eq!(
cache_entry("CMAKE_HOME_DIRECTORY\n", HOME_DIRECTORY_KEY),
None
);
}
#[test]
fn cmake_build_is_opt_in() {
assert!(CmakeBuild.opt_in());
}
}