use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::SystemTime;
const STAMP: &str = ".fgate-checkouts";
const LOCK: &str = ".fgate-lock";
pub const LOCK_VAR: &str = "FGATE_TARGET_LOCK";
pub struct TargetLock {
_file: std::fs::File,
pub target: PathBuf,
}
pub fn lock(shared: &Shared) -> Option<TargetLock> {
let target = &shared.target;
if std::env::var_os(LOCK_VAR).is_some_and(|held| same(&held.to_string_lossy(), target)) {
return None;
}
std::fs::create_dir_all(target).ok()?;
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(target.join(LOCK))
.ok()?;
let started = std::time::Instant::now();
let mut said = false;
while !crate::sys::try_lock(&file) {
if !said && started.elapsed() > std::time::Duration::from_secs(1) {
eprintln!(
"fgate: waiting for another build into {} to finish (a build's clean, build and kept executables go \
together)",
target.display()
);
said = true;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
Some(TargetLock {
_file: file,
target: target.clone(),
})
}
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 {
cargo: String,
toolchain: Option<String>,
pub target_dir: Option<PathBuf>,
pub manifest: Option<PathBuf>,
pub flags: Vec<String>,
pub key: String,
}
impl Build {
fn cargo(&self) -> Command {
let mut command = Command::new(&self.cargo);
if let Some(toolchain) = &self.toolchain {
command.arg(toolchain);
}
command
}
}
pub fn parse(command: &[String]) -> Option<Build> {
let (sub, build) = parse_any(command)?;
COMPILING.contains(&sub.as_str()).then_some(build)
}
pub fn is_cargo(command: &[String]) -> bool {
command.first().is_some_and(|program| {
Path::new(program)
.file_stem()
.is_some_and(|s| s.to_string_lossy().to_lowercase() == "cargo")
})
}
fn parse_any(command: &[String]) -> Option<(String, Build)> {
let program = command.first()?;
if !is_cargo(command) {
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?;
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(),
};
let build = Build {
cargo: program.clone(),
toolchain: command.get(1).filter(|a| a.starts_with('+')).cloned(),
target_dir,
manifest,
flags,
key,
};
Some((sub, build))
}
pub fn publishes(command: &[String]) -> Option<Build> {
let (sub, build) = parse_any(command)?;
let skips = command
.iter()
.take_while(|a| a.as_str() != "--")
.any(|a| a == "--no-verify" || a == "--list" || a == "-l");
(["publish", "package"].contains(&sub.as_str()) && !skips).then_some(build)
}
pub fn clean_after_publish(build: &Build) {
let Some(members) = metadata(build).map(|m| package_names(&m)) else {
return;
};
if members.is_empty() {
return;
}
let mut clean = build.cargo();
clean.arg("clean").arg("--quiet");
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() => eprintln!(
"fgate: cleaned this workspace's {} package(s) after the verify build, so the next cargo build \
compiles from the real sources, not the packaged copy",
members.len()
),
Ok(s) => eprintln!("fgate: cargo clean after the verify build failed ({s})"),
Err(e) => eprintln!("fgate: could not run cargo clean after the verify build ({e})"),
}
}
pub struct Shared {
build: Build,
target: PathBuf,
root: PathBuf,
}
pub fn shared(command: &[String]) -> Option<Shared> {
let build = parse(command)?;
let 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))?;
let root = workspace_root(&build)?;
Some(Shared {
build,
target,
root,
})
}
pub fn prepare(shared: &Shared) {
let Shared {
build,
target,
root,
} = shared;
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) = metadata(build).map(|m| package_names(&m)) else {
return;
};
if members.is_empty() {
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 = build.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"),
}
}
pub fn keep(shared: &Shared) {
let Shared {
build,
target,
root,
} = shared;
let text = std::fs::read_to_string(target.join(STAMP)).unwrap_or_default();
if !last_builder(&text, &build.key).is_some_and(|l| same(&l, root)) {
return;
}
let Some(bins) = metadata(build).map(|m| bin_names(&m)) else {
return;
};
let from = target.join(&build.key);
let to = kept_dir(root, &build.key);
let mut kept = Vec::new();
for name in bins {
let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
let source = from.join(&file);
if !source.is_file() {
continue;
}
match copy_if_changed(&source, &to.join(&file)) {
Ok(true) => kept.push(file),
Ok(false) => kept.push(format!("{file} (unchanged)")),
Err(e) => eprintln!(
"fgate: could not keep {file} in {}: {e} (keep_bins)",
to.display()
),
}
}
if !kept.is_empty() {
eprintln!(
"fgate: kept {} in {}: run it from there, the shared target's copy goes with the next checkout's \
build (keep_bins)",
kept.join(", "),
to.display()
);
}
}
fn kept_dir(root: &Path, key: &str) -> PathBuf {
root.join("target").join("fgate-bin").join(key)
}
pub fn copy_if_changed(from: &Path, to: &Path) -> std::io::Result<bool> {
let meta = std::fs::metadata(from)?;
let modified: SystemTime = meta.modified()?;
if let Ok(old) = std::fs::metadata(to) {
if old.len() == meta.len() && old.modified().ok() == Some(modified) {
return Ok(false);
}
}
if let Some(dir) = to.parent() {
std::fs::create_dir_all(dir)?;
}
let fresh = to.with_extension("fgate-new");
std::fs::copy(from, &fresh)?;
std::fs::File::options()
.write(true)
.open(&fresh)?
.set_modified(modified)?;
if to.exists() {
let old = to.with_extension("fgate-old");
let _ = std::fs::remove_file(&old);
std::fs::rename(to, &old)?;
let _ = std::fs::remove_file(&old);
}
std::fs::rename(&fresh, to)?;
Ok(true)
}
fn workspace_root(build: &Build) -> Option<PathBuf> {
let mut c = build.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 metadata(build: &Build) -> Option<serde_json::Value> {
let mut c = build.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())?;
serde_json::from_slice(&out.stdout).ok()
}
fn packages(metadata: &serde_json::Value) -> impl Iterator<Item = &serde_json::Value> {
metadata
.get("packages")
.and_then(|p| p.as_array())
.into_iter()
.flatten()
}
fn package_names(metadata: &serde_json::Value) -> Vec<String> {
packages(metadata)
.filter_map(|p| p.get("name")?.as_str().map(str::to_string))
.collect()
}
fn bin_names(metadata: &serde_json::Value) -> Vec<String> {
packages(metadata)
.filter_map(|p| p.get("targets")?.as_array())
.flatten()
.filter(|t| {
t.get("kind")
.and_then(|k| k.as_array())
.is_some_and(|k| k.iter().any(|k| k == "bin"))
})
.filter_map(|t| t.get("name")?.as_str().map(str::to_string))
.collect()
}
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 finds_publishing_that_builds_the_package() {
for c in [
"cargo publish",
"cargo +stable publish --dry-run",
"cargo package --allow-dirty",
"cargo.exe publish -p fgate --manifest-path x/Cargo.toml",
] {
assert!(publishes(&cmd(c)).is_some(), "{c}");
}
for c in [
"cargo publish --no-verify",
"cargo package --list",
"cargo build",
"cargo run -- publish",
"cargo login",
] {
assert!(publishes(&cmd(c)).is_none(), "{c}");
}
let b = publishes(&cmd("cargo publish --target x86_64-pc-windows-msvc")).unwrap();
assert_eq!(b.flags, cmd("--target x86_64-pc-windows-msvc"));
}
#[test]
fn helper_commands_preserve_the_cargo_executable_and_toolchain() {
for (input, executable, toolchain) in [
("cargo build", "cargo", None),
("cargo +nightly build", "cargo", Some("+nightly")),
(
"tools/cargo.exe +1.87.0 test",
"tools/cargo.exe",
Some("+1.87.0"),
),
("cargo run -- +nightly", "cargo", None),
] {
let build = parse(&cmd(input)).unwrap();
for subcommand in ["locate-project", "metadata", "clean"] {
let mut helper = build.cargo();
helper.arg(subcommand);
assert_eq!(helper.get_program(), executable);
let expected: Vec<_> = toolchain.into_iter().chain([subcommand]).collect();
assert_eq!(helper.get_args().collect::<Vec<_>>(), expected);
}
}
}
#[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")));
}
#[test]
fn finds_the_workspace_packages_and_executables() {
let m: serde_json::Value = serde_json::from_str(
r#"{"packages": [
{"name": "game", "targets": [
{"name": "game", "kind": ["lib"]},
{"name": "game-viewer", "kind": ["bin"]},
{"name": "bench", "kind": ["bench"]}]},
{"name": "tools", "targets": [{"name": "tools", "kind": ["bin"]}]}
]}"#,
)
.unwrap();
assert_eq!(package_names(&m), ["game", "tools"]);
assert_eq!(bin_names(&m), ["game-viewer", "tools"]);
assert!(bin_names(&serde_json::json!({})).is_empty());
}
#[test]
fn kept_executables_live_in_the_checkout_per_profile() {
let root = Path::new("/w/game");
assert_eq!(
kept_dir(root, "release"),
Path::new("/w/game/target/fgate-bin/release")
);
assert_eq!(
kept_dir(root, "x86_64-pc-windows-msvc/debug"),
Path::new("/w/game/target/fgate-bin/x86_64-pc-windows-msvc/debug")
);
}
#[test]
fn a_build_holds_the_shared_target_until_it_is_done() {
let dir = std::env::temp_dir().join(format!("fgate-lock-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let shared = Shared {
build: parse(&cmd("cargo build")).unwrap(),
target: dir.clone(),
root: dir.clone(),
};
let held = lock(&shared).unwrap();
let other = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(dir.join(LOCK))
.unwrap();
assert!(!crate::sys::try_lock(&other), "another build waits");
drop(held);
assert!(crate::sys::try_lock(&other), "and goes once it is done");
crate::sys::unlock(&other);
drop(other);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn copies_only_what_changed() {
let dir = std::env::temp_dir().join(format!("fgate-keep-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let from = dir.join("game.exe");
let to = dir.join("kept").join("game.exe");
std::fs::write(&from, "one").unwrap();
assert!(copy_if_changed(&from, &to).unwrap());
assert!(!copy_if_changed(&from, &to).unwrap());
std::fs::write(&from, "two!").unwrap();
assert!(copy_if_changed(&from, &to).unwrap());
assert_eq!(std::fs::read_to_string(&to).unwrap(), "two!");
assert!(
!to.with_extension("fgate-new").exists() && !to.with_extension("fgate-old").exists()
);
let _ = std::fs::remove_dir_all(&dir);
}
}