use std::{
io::Write,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, anyhow};
use repo::{Repository, ThreadManager, ThreadMode};
use sha2::{Digest, Sha256};
const FINGERPRINT_HEX_WIDTH: usize = 16;
pub(crate) const ADVISORY_ACTIVE_THREAD_THRESHOLD: usize = 1;
pub(crate) fn workspace_root_is_rust(repo: &Repository) -> bool {
repo.root().join("Cargo.toml").is_file()
}
pub(crate) fn workspace_fingerprint(repo: &Repository) -> Result<String> {
let lock = repo.root().join("Cargo.lock");
let toml = repo.root().join("Cargo.toml");
let bytes = if lock.is_file() {
std::fs::read(&lock).with_context(|| format!("read {}", lock.display()))?
} else if toml.is_file() {
std::fs::read(&toml).with_context(|| format!("read {}", toml.display()))?
} else {
return Err(anyhow!(
"no Cargo.toml at workspace root '{}'; --shared-target only applies to Rust workspaces",
repo.root().display()
));
};
let mut hasher = Sha256::new();
hasher.update(&bytes);
let digest = hasher.finalize();
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
Ok(hex[..FINGERPRINT_HEX_WIDTH].to_string())
}
pub(crate) fn shared_target_dir(repo: &Repository) -> Result<PathBuf> {
let fingerprint = workspace_fingerprint(repo)?;
let dir = repo.heddle_dir().join("targets").join(fingerprint);
std::fs::create_dir_all(&dir)
.with_context(|| format!("create shared target dir '{}'", dir.display()))?;
Ok(dir)
}
pub(crate) fn write_cargo_config(checkout: &Path, target_dir: &Path) -> Result<bool> {
let cargo_dir = checkout.join(".cargo");
let config_path = cargo_dir.join("config.toml");
std::fs::create_dir_all(&cargo_dir)
.with_context(|| format!("create '{}'", cargo_dir.display()))?;
let escaped = target_dir
.display()
.to_string()
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\t', "\\t");
let body = format!(
"# Written by `heddle start --shared-target`. Redirects cargo's\n\
# `target/` directory to a workspace-wide shared path so\n\
# multiple parallel materialized threads don't each carry\n\
# their own multi-gigabyte build tree.\n\
#\n\
# Safe to delete: cargo will fall back to a per-checkout\n\
# `target/` next build.\n\
[build]\n\
target-dir = \"{escaped}\"\n",
);
let mut file = match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&config_path)
{
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
return Ok(false);
}
Err(err) => {
return Err(err).with_context(|| format!("create '{}'", config_path.display()));
}
};
file.write_all(body.as_bytes())
.with_context(|| format!("write '{}'", config_path.display()))?;
file.sync_all()
.with_context(|| format!("sync '{}'", config_path.display()))?;
Ok(true)
}
fn count_active_materialized_threads(repo: &Repository) -> usize {
let manager = ThreadManager::new(repo.heddle_dir());
let Ok(threads) = manager.list() else {
return 0;
};
threads
.into_iter()
.filter(|thread| {
matches!(
thread.mode,
ThreadMode::Materialized | ThreadMode::Lightweight
) && thread.state == repo::ThreadState::Active
})
.count()
}
pub(crate) fn should_advise_shared_target(repo: &Repository) -> bool {
workspace_root_is_rust(repo)
&& count_active_materialized_threads(repo) >= ADVISORY_ACTIVE_THREAD_THRESHOLD
}
pub(crate) fn print_advisory(name: &str) {
eprintln!(
"note: starting materialized thread '{name}' alongside an existing materialized thread \
in a Rust workspace; consider `heddle start --shared-target {name}` to share cargo's \
target/ across threads (saves multiple GB).",
);
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
#[test]
fn fingerprint_is_stable_across_calls() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("Cargo.toml"), b"[package]\nname=\"x\"\n").unwrap();
let bytes = std::fs::read(temp.path().join("Cargo.toml")).unwrap();
let mut a = Sha256::new();
a.update(&bytes);
let mut b = Sha256::new();
b.update(&bytes);
assert_eq!(a.finalize(), b.finalize());
}
#[test]
fn write_cargo_config_creates_file_with_target_dir() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("targets").join("abc123");
std::fs::create_dir_all(&target).unwrap();
let wrote = write_cargo_config(temp.path(), &target).unwrap();
assert!(wrote, "writer must report a write when no prior config");
let written =
std::fs::read_to_string(temp.path().join(".cargo").join("config.toml")).unwrap();
assert!(written.contains("[build]"));
assert!(written.contains(&format!("target-dir = \"{}\"", target.display(),)));
}
#[test]
fn write_cargo_config_preserves_existing_user_config() {
let temp = TempDir::new().unwrap();
let cargo_dir = temp.path().join(".cargo");
std::fs::create_dir_all(&cargo_dir).unwrap();
let user = "[net]\noffline = true\n";
std::fs::write(cargo_dir.join("config.toml"), user).unwrap();
let target = temp.path().join("shared");
std::fs::create_dir_all(&target).unwrap();
let wrote = write_cargo_config(temp.path(), &target).unwrap();
assert!(
!wrote,
"writer must report no-op when user config is preserved"
);
let after = std::fs::read_to_string(cargo_dir.join("config.toml")).unwrap();
assert_eq!(
after, user,
"shared-target writer must not overwrite user-managed config",
);
}
#[test]
fn write_cargo_config_escapes_quotes_in_path() {
let temp = TempDir::new().unwrap();
let weird = temp.path().join("dir with \"quotes\"");
std::fs::create_dir_all(&weird).unwrap();
let wrote = write_cargo_config(temp.path(), &weird).unwrap();
assert!(wrote);
let written =
std::fs::read_to_string(temp.path().join(".cargo").join("config.toml")).unwrap();
let parsed: toml::Value = toml::from_str(&written).unwrap();
let target_dir = parsed
.get("build")
.and_then(|t| t.get("target-dir"))
.and_then(|v| v.as_str())
.expect("[build].target-dir present");
assert_eq!(target_dir, weird.display().to_string());
}
}