use std::path::{Path, PathBuf};
use std::process::Command;
const STAMP: &str = ".fgate-checkouts";
const COMPILING: [&str; 14] = [
"build", "b", "check", "c", "test", "t", "run", "r", "clippy", "doc", "d", "bench", "rustc",
"rustdoc",
];
#[derive(Debug, PartialEq, Eq)]
pub struct Build {
pub target_dir: Option<PathBuf>,
pub manifest: Option<PathBuf>,
pub flags: Vec<String>,
pub key: String,
}
pub fn parse(command: &[String]) -> Option<Build> {
let program = command.first()?;
let name = Path::new(program)
.file_stem()?
.to_string_lossy()
.to_lowercase();
if name != "cargo" {
return None;
}
let args: Vec<&String> = command[1..]
.iter()
.take_while(|a| a.as_str() != "--")
.collect();
let mut sub = None;
let mut target_dir = None;
let mut manifest = None;
let mut profile: Option<String> = None;
let mut triple: Option<String> = None;
let mut i = 0;
while i < args.len() {
let a = args[i].as_str();
let mut value = |flag: &str| -> Option<String> {
if a == flag {
i += 1;
args.get(i).map(|v| v.to_string())
} else {
a.strip_prefix(&format!("{flag}=")).map(str::to_string)
}
};
if let Some(v) = value("--target-dir") {
target_dir = Some(PathBuf::from(v));
} else if let Some(v) = value("--manifest-path") {
manifest = Some(PathBuf::from(v));
} else if let Some(v) = value("--profile") {
profile = Some(v);
} else if let Some(v) = value("--target") {
triple = Some(v);
} else if a == "--release" || a == "-r" {
profile = Some("release".into());
} else if sub.is_none() && !a.starts_with('-') && !a.starts_with('+') {
sub = Some(a.to_string());
}
i += 1;
}
let sub = sub?;
if !COMPILING.contains(&sub.as_str()) {
return None;
}
let default = if sub == "bench" { "bench" } else { "dev" };
let profile = profile.unwrap_or_else(|| default.into());
let dir = match profile.as_str() {
"dev" | "test" => "debug",
"release" | "bench" => "release",
other => other,
};
let mut flags = match profile.as_str() {
"dev" => vec![],
"release" => vec!["--release".into()],
other => vec!["--profile".into(), other.to_string()],
};
if let Some(t) = &triple {
flags.extend(["--target".to_string(), t.clone()]);
}
let key = match &triple {
Some(t) => format!("{t}/{dir}"),
None => dir.to_string(),
};
Some(Build {
target_dir,
manifest,
flags,
key,
})
}
pub fn prepare(command: &[String]) {
let Some(build) = parse(command) else { return };
let Some(target) = build
.target_dir
.clone()
.or_else(|| std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from))
.or_else(|| std::env::var_os("CARGO_BUILD_TARGET_DIR").map(PathBuf::from))
else {
return;
};
let Some(root) = workspace_root(&build) else {
return;
};
let stamp = target.join(STAMP);
let text = std::fs::read_to_string(&stamp).unwrap_or_default();
let last = last_builder(&text, &build.key);
if last.as_deref().is_some_and(|l| same(l, &root)) {
return;
}
let Some(members) = members(&build) else {
return;
};
match &last {
Some(other) => eprintln!(
"fgate: {} in {} was last built by another checkout ({other}): cleaning this workspace's {} \
package(s) there first, so cargo doesn't reuse them (switch_clean)",
build.key,
target.display(),
members.len()
),
None => eprintln!(
"fgate: nobody is recorded as building {} in {}: cleaning this workspace's {} package(s) there once \
(switch_clean)",
build.key,
target.display(),
members.len()
),
}
let mut clean = Command::new("cargo");
clean.arg("clean");
for m in &members {
clean.args(["-p", m]);
}
clean.args(&build.flags);
if let Some(d) = &build.target_dir {
clean.arg("--target-dir").arg(d);
}
if let Some(m) = &build.manifest {
clean.arg("--manifest-path").arg(m);
}
match clean.status() {
Ok(s) if s.success() => {
let _ = std::fs::create_dir_all(&target);
let _ = std::fs::write(&stamp, record(&text, &build.key, &root.to_string_lossy()));
}
Ok(s) => eprintln!("fgate: cargo clean failed ({s}); building without it"),
Err(e) => eprintln!("fgate: could not run cargo clean ({e}); building without it"),
}
}
fn workspace_root(build: &Build) -> Option<PathBuf> {
let mut c = Command::new("cargo");
c.args(["locate-project", "--workspace", "--message-format", "plain"]);
if let Some(m) = &build.manifest {
c.arg("--manifest-path").arg(m);
}
let out = c.output().ok().filter(|o| o.status.success())?;
let toml = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim());
toml.parent().map(Path::to_path_buf)
}
fn members(build: &Build) -> Option<Vec<String>> {
let mut c = Command::new("cargo");
c.args(["metadata", "--no-deps", "--format-version", "1"]);
if let Some(m) = &build.manifest {
c.arg("--manifest-path").arg(m);
}
let out = c.output().ok().filter(|o| o.status.success())?;
let json: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
let names: Vec<String> = json
.get("packages")?
.as_array()?
.iter()
.filter_map(|p| p.get("name")?.as_str().map(str::to_string))
.collect();
(!names.is_empty()).then_some(names)
}
fn last_builder(text: &str, key: &str) -> Option<String> {
text.lines().find_map(|l| {
let (k, root) = l.split_once('\t')?;
(k == key).then(|| root.to_string())
})
}
fn record(text: &str, key: &str, root: &str) -> String {
let mut out: Vec<String> = text
.lines()
.filter(|l| l.split_once('\t').is_some_and(|(k, _)| k != key))
.map(str::to_string)
.collect();
out.push(format!("{key}\t{root}"));
out.join("\n") + "\n"
}
fn same(a: &str, b: &Path) -> bool {
let norm = |s: &str| {
let s = s.replace('\\', "/");
let s = s.trim_end_matches('/');
if cfg!(windows) {
s.to_lowercase()
} else {
s.to_string()
}
};
norm(a) == norm(&b.to_string_lossy())
}
#[cfg(test)]
mod tests {
use super::*;
fn cmd(s: &str) -> Vec<String> {
s.split_whitespace().map(str::to_string).collect()
}
#[test]
fn finds_compiling_cargo_commands_and_their_profile() {
let b = parse(&cmd(
"cargo run -p viewer --release -- --building x --release",
))
.unwrap();
assert_eq!(
(b.key.as_str(), b.flags.clone()),
("release", vec!["--release".to_string()])
);
let b = parse(&cmd("cargo.exe +nightly t -p sim")).unwrap();
assert_eq!((b.key.as_str(), b.flags.len()), ("debug", 0));
let b = parse(&cmd(
"cargo clippy --workspace --profile=ci --target x86_64-pc-windows-msvc",
))
.unwrap();
assert_eq!(b.key, "x86_64-pc-windows-msvc/ci");
assert_eq!(b.flags, cmd("--profile ci --target x86_64-pc-windows-msvc"));
assert_eq!(parse(&cmd("cargo bench")).unwrap().key, "release");
let b = parse(&cmd(
"cargo build --target-dir E:/t --manifest-path a/Cargo.toml",
))
.unwrap();
assert_eq!(b.target_dir, Some(PathBuf::from("E:/t")));
assert_eq!(b.manifest, Some(PathBuf::from("a/Cargo.toml")));
let b = parse(&cmd(
"cargo --target-dir E:/t --manifest-path x/Cargo.toml build",
))
.unwrap();
assert_eq!(
(b.target_dir, b.key.as_str()),
(Some(PathBuf::from("E:/t")), "debug")
);
assert!(parse(&cmd("cargo --manifest-path build fmt")).is_none());
}
#[test]
fn leaves_other_commands_alone() {
for c in [
"cargo fmt --all",
"cargo clean -p a",
"cargo install fgate",
"python shots.py",
"cargo",
] {
assert!(parse(&cmd(c)).is_none(), "{c}");
}
}
#[test]
fn the_record_keeps_one_builder_per_profile() {
let t = record("", "debug", "E:/a");
let t = record(&t, "release", "E:/b");
let t = record(&t, "debug", "E:/c");
assert_eq!(last_builder(&t, "debug").as_deref(), Some("E:/c"));
assert_eq!(last_builder(&t, "release").as_deref(), Some("E:/b"));
assert_eq!(last_builder(&t, "ci"), None);
assert!(same("E:/code/game/", Path::new("E:/code/game")));
}
}