use std::collections::BTreeMap;
use std::fs;
use std::path::{Component, Path, PathBuf};
use toml_edit::DocumentMut;
use crate::config::Paths;
use crate::{fsutil, ledger, scan};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Detected {
pub root: PathBuf,
pub reason: String,
}
pub fn template_root(value: &str, cargo_home: &Path) -> Option<PathBuf> {
let expanded = value.replace("{cargo-cache-home}", &cargo_home.to_string_lossy());
let path = Path::new(&expanded);
if !path.is_absolute() {
return None;
}
let mut root = PathBuf::new();
for component in path.components() {
if let Component::Normal(name) = component
&& name.to_string_lossy().contains('{')
{
break;
}
root.push(component);
}
(root.components().count() > 1).then_some(root)
}
pub fn mount_point(path: &Path) -> Option<PathBuf> {
let device = fsutil::device(path)?;
path.ancestors()
.take_while(|a| fsutil::device(a) == Some(device))
.last()
.map(Path::to_path_buf)
}
pub fn recorded_build_dirs(paths: &Paths) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = ledger::load(paths).roots.into_keys().collect();
if let Ok(text) = fs::read_to_string(paths.ledger()) {
dirs.extend(
text.lines()
.filter_map(|l| serde_json::from_str::<ledger::Record>(l).ok())
.map(|r| r.root),
);
}
dirs.sort();
dirs.dedup();
dirs
}
#[derive(Debug, Default)]
pub struct Sources<'a> {
pub target_dir_env: Option<PathBuf>,
pub cargo_config: Option<&'a DocumentMut>,
pub cargo_home: PathBuf,
pub recorded: Vec<PathBuf>,
pub home: Option<PathBuf>,
}
pub fn detect(sources: &Sources) -> Vec<Detected> {
let mut found: Vec<Detected> = Vec::new();
if let Some(dir) = &sources.target_dir_env
&& let Some(parent) = dir.parent()
{
found.push(Detected {
root: parent.to_path_buf(),
reason: format!("parent of CARGO_TARGET_DIR={}", dir.display()),
});
}
if let Some(doc) = sources.cargo_config {
for key in ["build-dir", "target-dir"] {
if let Some(value) = doc
.get("build")
.and_then(|b| b.get(key))
.and_then(|v| v.as_str())
&& let Some(root) = template_root(value, &sources.cargo_home)
{
found.push(Detected {
root,
reason: format!("build.{key} = {value:?} in Cargo's user config"),
});
}
}
}
let home_device = sources.home.as_deref().and_then(fsutil::device);
let mut volumes: BTreeMap<PathBuf, usize> = BTreeMap::new();
for dir in &sources.recorded {
let Some(device) = fsutil::device(dir) else {
continue;
};
if Some(device) == home_device {
continue;
}
if let Some(mount) = mount_point(dir) {
*volumes.entry(mount).or_default() += 1;
}
}
for (mount, count) in volumes {
found.push(Detected {
root: mount,
reason: format!(
"volume holding {count} recorded build director{}",
if count == 1 { "y" } else { "ies" }
),
});
}
finalize(found, sources.home.as_deref())
}
fn finalize(found: Vec<Detected>, home: Option<&Path>) -> Vec<Detected> {
let home = home.and_then(|h| h.canonicalize().ok());
let mut by_root: BTreeMap<PathBuf, String> = BTreeMap::new();
for detected in found {
let Ok(root) = detected.root.canonicalize() else {
continue;
};
if !root.is_dir() || root.parent().is_none() {
continue;
}
if home.as_deref().is_some_and(|h| h.starts_with(&root)) {
continue;
}
by_root.entry(root).or_insert(detected.reason);
}
let kept = scan::outermost(by_root.keys().cloned().collect());
kept.into_iter()
.filter_map(|root| {
let reason = by_root.remove(&root)?;
Some(Detected { root, reason })
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn takes_the_fixed_part_of_build_dir_templates() {
let home = Path::new("/home/u/.cargo");
assert_eq!(
template_root("/Volumes/Scratch/cargo-build/{workspace-path-hash}", home),
Some(PathBuf::from("/Volumes/Scratch/cargo-build"))
);
assert_eq!(
template_root("{cargo-cache-home}/build/{workspace-path-hash}", home),
Some(PathBuf::from("/home/u/.cargo/build"))
);
assert_eq!(
template_root("/srv/target", home),
Some(PathBuf::from("/srv/target"))
);
assert_eq!(template_root("{workspace-root}/target", home), None);
assert_eq!(template_root("target", home), None);
assert_eq!(template_root("/{workspace-path-hash}", home), None);
}
#[test]
fn finds_the_mount_point_of_a_path() {
let tmp = tempfile::tempdir().unwrap();
let deep = tmp.path().join("a/b/c");
fs::create_dir_all(&deep).unwrap();
let mount = mount_point(&deep).unwrap();
assert!(deep.starts_with(&mount));
assert_eq!(fsutil::device(&mount), fsutil::device(&deep));
if let Some(parent) = mount.parent() {
assert_ne!(fsutil::device(parent), fsutil::device(&deep));
}
#[cfg(unix)]
{
let dev = Path::new("/dev");
assert_eq!(mount_point(dev).as_deref(), Some(dev));
assert_ne!(fsutil::device(Path::new("/")), fsutil::device(dev));
}
assert_eq!(mount_point(&tmp.path().join("missing")), None);
}
#[test]
fn detects_roots_from_the_environment_and_cargo_config() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().canonicalize().unwrap();
let scratch = base.join("scratch");
fs::create_dir_all(scratch.join("cargo/proj/main")).unwrap();
fs::create_dir_all(base.join("builds")).unwrap();
let home = base.join("home");
fs::create_dir_all(&home).unwrap();
let doc: DocumentMut = format!(
"[build]\nbuild-dir = \"{}/builds/{{workspace-path-hash}}\"\ntarget-dir = \"{}/missing\"\n",
base.display(),
base.display()
)
.parse()
.unwrap();
let sources = Sources {
target_dir_env: Some(scratch.join("cargo/proj/main")),
cargo_config: Some(&doc),
cargo_home: home.join(".cargo"),
recorded: Vec::new(),
home: Some(home.clone()),
};
let detected = detect(&sources);
let roots: Vec<&Path> = detected.iter().map(|d| d.root.as_path()).collect();
assert_eq!(roots, [base.join("builds"), scratch.join("cargo/proj")]);
assert!(detected[0].reason.contains("build.build-dir"));
assert!(detected[1].reason.contains("CARGO_TARGET_DIR"));
}
#[test]
fn keeps_outer_roots_and_skips_broad_ones() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().canonicalize().unwrap();
let home = base.join("home/u");
fs::create_dir_all(home.join("projects")).unwrap();
fs::create_dir_all(base.join("vol/cargo/x")).unwrap();
let found = vec![
Detected {
root: base.join("vol/cargo/x"),
reason: "inner".into(),
},
Detected {
root: base.join("vol"),
reason: "outer".into(),
},
Detected {
root: base.join("home"),
reason: "contains home".into(),
},
Detected {
root: PathBuf::from("/"),
reason: "root".into(),
},
Detected {
root: home.join("projects"),
reason: "under home".into(),
},
];
let kept = finalize(found, Some(&home));
assert_eq!(
kept,
[
Detected {
root: home.join("projects"),
reason: "under home".into()
},
Detected {
root: base.join("vol"),
reason: "outer".into()
},
]
);
}
#[test]
fn reads_recorded_dirs_from_the_registry_and_the_ledger() {
let tmp = tempfile::tempdir().unwrap();
let paths = Paths {
home: tmp.path().join("home"),
};
assert!(recorded_build_dirs(&paths).is_empty());
let record = |root: &str| ledger::Record {
ts: 1,
root: root.into(),
workspace: None,
ms: 0,
linked: None,
};
ledger::append(&paths, &record("/b/one"));
ledger::compact(&paths).unwrap();
ledger::append(&paths, &record("/b/two"));
ledger::append(&paths, &record("/b/one"));
fs::write(
paths.ledger(),
format!("{}not json\n", fs::read_to_string(paths.ledger()).unwrap()),
)
.unwrap();
assert_eq!(
recorded_build_dirs(&paths),
[PathBuf::from("/b/one"), PathBuf::from("/b/two")]
);
}
#[test]
fn detects_volumes_holding_recorded_build_dirs() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().canonicalize().unwrap();
let (one, two) = (base.join("one"), base.join("two"));
fs::create_dir_all(&one).unwrap();
fs::create_dir_all(&two).unwrap();
let recorded = vec![one.clone(), two.clone(), base.join("gone")];
let same = Sources {
recorded: recorded.clone(),
home: Some(base.clone()),
..Sources::default()
};
assert!(detect(&same).is_empty());
#[cfg(unix)]
{
let elsewhere = Path::new("/dev");
assert_ne!(fsutil::device(elsewhere), fsutil::device(&base));
let mount = mount_point(&one).unwrap().canonicalize().unwrap();
let detected = detect(&Sources {
recorded,
home: Some(elsewhere.to_path_buf()),
..Sources::default()
});
if mount.parent().is_none() {
assert!(detected.is_empty(), "{detected:?}");
} else {
assert_eq!(
detected,
[Detected {
root: mount,
reason: "volume holding 2 recorded build directories".into()
}]
);
}
let detected = detect(&Sources {
recorded: vec![elsewhere.to_path_buf()],
home: Some(base.clone()),
..Sources::default()
});
assert_eq!(
detected,
[Detected {
root: elsewhere.to_path_buf(),
reason: "volume holding 1 recorded build directory".into()
}]
);
}
}
}