use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::path::Path;
use std::time::{Duration, SystemTime};
use anyhow::{Context as _, Result};
pub fn static_regex(pattern: &str) -> regex::Regex {
regex::Regex::new(pattern)
.unwrap_or_else(|e| panic!("invalid static regex literal `{}`: {}", pattern, e))
}
pub fn topological_sort(items: &[(impl AsRef<str>, impl AsRef<[String]>)]) -> Vec<String> {
let names: HashSet<&str> = items.iter().map(|(n, _)| n.as_ref()).collect();
let mut in_degree: HashMap<&str, usize> = items
.iter()
.map(|(n, deps)| {
let deg = deps
.as_ref()
.iter()
.filter(|d| names.contains(d.as_str()))
.count();
(n.as_ref(), deg)
})
.collect();
let mut edges: HashMap<&str, Vec<&str>> = HashMap::new();
for (n, deps) in items {
for dep in deps.as_ref() {
if names.contains(dep.as_str()) {
edges.entry(dep.as_str()).or_default().push(n.as_ref());
}
}
}
let mut queue: VecDeque<&str> = {
let mut v: Vec<&str> = in_degree
.iter()
.filter(|(_, d)| **d == 0)
.map(|(&n, _)| n)
.collect();
v.sort_unstable();
VecDeque::from(v)
};
let mut result = Vec::with_capacity(items.len());
while let Some(node) = queue.pop_front() {
result.push(node.to_string());
if let Some(dependents) = edges.get(node) {
let mut next: Vec<&str> = dependents
.iter()
.filter_map(|&dep| {
let deg = in_degree.get_mut(dep)?;
*deg -= 1;
if *deg == 0 { Some(dep) } else { None }
})
.collect();
next.sort_unstable();
for n in next {
queue.push_back(n);
}
}
}
if result.len() < items.len() {
let in_result: HashSet<String> = result.iter().cloned().collect();
for (n, _) in items {
if !in_result.contains(n.as_ref()) {
result.push(n.as_ref().to_string());
}
}
}
result
}
pub fn parse_mod_timestamp(raw: &str) -> Result<SystemTime> {
if let Ok(epoch_secs) = raw.parse::<u64>() {
return Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs));
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(raw) {
let epoch_secs = dt.timestamp() as u64;
return Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs));
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S") {
let epoch_secs = dt.and_utc().timestamp() as u64;
return Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs));
}
anyhow::bail!(
"mod_timestamp value '{raw}' is not a valid timestamp. \
Accepted formats: Unix epoch seconds (e.g. \"1704067200\") or \
RFC 3339 datetime (e.g. \"2024-01-01T00:00:00Z\")"
)
}
pub fn apply_mod_timestamp(dir: &Path, raw: &str, log: &crate::log::StageLogger) -> Result<()> {
let mtime = parse_mod_timestamp(raw)?;
let mut stack: Vec<std::path::PathBuf> = vec![dir.to_path_buf()];
while let Some(p) = stack.pop() {
for entry in
fs::read_dir(&p).with_context(|| format!("read staging dir {}", p.display()))?
{
let entry = entry?;
let path = entry.path();
let ft = entry.file_type()?;
if ft.is_dir() {
stack.push(path);
} else if ft.is_file() {
set_file_mtime(&path, mtime)?;
}
}
}
log.status(&format!("applied mod_timestamp={raw} to staging files"));
Ok(())
}
pub fn set_file_mtime(path: &Path, mtime: SystemTime) -> Result<()> {
let file = std::fs::OpenOptions::new()
.write(true)
.open(path)
.with_context(|| format!("open {} for mtime update", path.display()))?;
file.set_times(
std::fs::FileTimes::new()
.set_accessed(mtime)
.set_modified(mtime),
)
.with_context(|| format!("set mtime on {}", path.display()))?;
Ok(())
}
pub fn set_file_mtime_epoch(path: &Path, epoch_secs: i64) -> Result<()> {
let mtime = if epoch_secs >= 0 {
SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs as u64)
} else {
SystemTime::UNIX_EPOCH - Duration::from_secs((-epoch_secs) as u64)
};
set_file_mtime(path, mtime)
}
pub fn pin_dir_mtimes_epoch(dir: &Path, epoch_secs: i64) -> Result<()> {
let mut stack: Vec<std::path::PathBuf> = vec![dir.to_path_buf()];
while let Some(p) = stack.pop() {
for entry in
fs::read_dir(&p).with_context(|| format!("read_dir {} for mtime pin", p.display()))?
{
let entry = entry?;
let path = entry.path();
let ft = entry.file_type()?;
if ft.is_dir() {
stack.push(path);
} else if ft.is_file() {
set_file_mtime_epoch(&path, epoch_secs)
.with_context(|| format!("pin mtime on {}", path.display()))?;
}
}
}
Ok(())
}
pub fn copy_dir_tree(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst).with_context(|| format!("create dir {}", dst.display()))?;
for entry in fs::read_dir(src).with_context(|| format!("read dir {}", src.display()))? {
let entry = entry.with_context(|| format!("read entry under {}", src.display()))?;
let from = entry.path();
let to = dst.join(entry.file_name());
let file_type = entry
.file_type()
.with_context(|| format!("stat {}", from.display()))?;
if file_type.is_symlink() {
#[cfg(unix)]
{
let target = fs::read_link(&from)
.with_context(|| format!("read symlink {}", from.display()))?;
std::os::unix::fs::symlink(&target, &to).with_context(|| {
format!("recreate symlink {} -> {}", to.display(), target.display())
})?;
}
#[cfg(not(unix))]
{
if from.is_dir() {
copy_dir_tree(&from, &to)?;
} else {
fs::copy(&from, &to)
.with_context(|| format!("copy {} to {}", from.display(), to.display()))?;
}
}
} else if file_type.is_dir() {
copy_dir_tree(&from, &to)?;
} else {
fs::copy(&from, &to)
.with_context(|| format!("copy {} to {}", from.display(), to.display()))?;
}
}
Ok(())
}
pub fn collect_replace_archives(
artifacts: &crate::artifact::ArtifactRegistry,
crate_name: &str,
target: Option<&str>,
) -> Vec<std::path::PathBuf> {
artifacts
.by_kind_and_crate(crate::artifact::ArtifactKind::Archive, crate_name)
.iter()
.filter(|a| a.target.as_deref() == target)
.map(|a| a.path.clone())
.collect()
}
pub fn collect_if_replace(
replace: Option<bool>,
artifacts: &crate::artifact::ArtifactRegistry,
crate_name: &str,
target: Option<&str>,
) -> Vec<std::path::PathBuf> {
if replace.unwrap_or(false) {
collect_replace_archives(artifacts, crate_name, target)
} else {
Vec::new()
}
}
pub fn normalize_path_separators(s: &str) -> String {
s.replace('\\', "/")
}
pub fn apply_minimal_env(command: &mut std::process::Command) {
const PASSTHROUGH: &[&str] = &[
"HOME",
"USER",
"USERPROFILE",
"TMPDIR",
"TMP",
"TEMP",
"PATH",
"LOCALAPPDATA",
];
for key in PASSTHROUGH {
if let Ok(val) = std::env::var(key) {
command.env(key, val);
}
}
}
const CARGO_BUILD_INTERMEDIATE_DIRS: &[&str] = &["deps", "build", "incremental", ".fingerprint"];
pub fn free_cargo_build_intermediates(
profile_dir: &Path,
log: &crate::log::StageLogger,
) -> Vec<&'static str> {
let is_cargo_profile_dir = profile_dir
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == "release" || n == "debug");
if !is_cargo_profile_dir {
log.verbose(&format!(
"refusing to free build intermediates under non-profile dir {}",
profile_dir.display()
));
return Vec::new();
}
let mut freed = Vec::new();
for sub in CARGO_BUILD_INTERMEDIATE_DIRS {
let path = profile_dir.join(sub);
if !path.exists() {
continue;
}
match fs::remove_dir_all(&path) {
Ok(()) => freed.push(*sub),
Err(err) => log.verbose(&format!(
"could not free build intermediate {}: {err}",
path.display()
)),
}
}
freed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_topo_sort_simple_chain() {
let items = vec![
("c".to_string(), vec!["b".to_string()]),
("b".to_string(), vec!["a".to_string()]),
("a".to_string(), vec![]),
];
let sorted = topological_sort(&items);
assert_eq!(sorted, vec!["a", "b", "c"]);
}
#[test]
fn test_topo_sort_no_deps() {
let items = vec![("b".to_string(), vec![]), ("a".to_string(), vec![])];
let sorted = topological_sort(&items);
assert_eq!(sorted, vec!["a", "b"]);
}
#[test]
fn test_topo_sort_ignores_external_deps() {
let items = vec![
(
"b".to_string(),
vec!["a".to_string(), "external".to_string()],
),
("a".to_string(), vec![]),
];
let sorted = topological_sort(&items);
assert_eq!(sorted, vec!["a", "b"]);
}
#[test]
fn test_topo_sort_diamond() {
let items = vec![
("d".to_string(), vec!["b".to_string(), "c".to_string()]),
("b".to_string(), vec!["a".to_string()]),
("c".to_string(), vec!["a".to_string()]),
("a".to_string(), vec![]),
];
let sorted = topological_sort(&items);
assert_eq!(sorted[0], "a");
assert_eq!(sorted[3], "d");
}
#[test]
fn test_topo_sort_cycle_appends_remaining() {
let items = vec![
("a".to_string(), vec!["b".to_string()]),
("b".to_string(), vec!["a".to_string()]),
("c".to_string(), vec![]),
];
let sorted = topological_sort(&items);
assert_eq!(sorted.len(), 3);
assert_eq!(sorted[0], "c");
}
#[test]
fn test_topo_sort_empty() {
let items: Vec<(String, Vec<String>)> = vec![];
let sorted = topological_sort(&items);
assert!(sorted.is_empty());
}
#[test]
fn test_parse_mod_timestamp_epoch_integer() {
let t = parse_mod_timestamp("1704067200").unwrap();
let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
assert_eq!(epoch, 1704067200);
}
#[test]
fn test_parse_mod_timestamp_rfc3339() {
let t = parse_mod_timestamp("2024-01-01T00:00:00Z").unwrap();
let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
assert_eq!(epoch, 1704067200);
}
#[test]
fn test_parse_mod_timestamp_rfc3339_with_offset() {
let t = parse_mod_timestamp("2024-01-01T01:00:00+01:00").unwrap();
let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
assert_eq!(epoch, 1704067200);
}
#[test]
fn test_parse_mod_timestamp_naive_datetime() {
let t = parse_mod_timestamp("2024-01-01T00:00:00").unwrap();
let epoch = t.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
assert_eq!(epoch, 1704067200);
}
#[test]
fn test_parse_mod_timestamp_invalid() {
let err = parse_mod_timestamp("not-a-timestamp").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("not a valid timestamp"),
"unexpected error: {msg}"
);
assert!(
msg.contains("not-a-timestamp"),
"error must include the bad value, got: {msg}"
);
}
#[test]
fn test_parse_mod_timestamp_zero() {
let t = parse_mod_timestamp("0").unwrap();
assert_eq!(t, SystemTime::UNIX_EPOCH);
}
#[test]
fn test_set_file_mtime_sets_both_atime_and_mtime() {
let dir = tempfile::tempdir().unwrap();
let dir = dir.path();
let file_path = dir.join("test.txt");
std::fs::write(&file_path, "hello").unwrap();
let target = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);
set_file_mtime(&file_path, target).unwrap();
let meta = std::fs::metadata(&file_path).unwrap();
let actual_mtime = meta.modified().unwrap();
let diff = if actual_mtime > target {
actual_mtime.duration_since(target).unwrap()
} else {
target.duration_since(actual_mtime).unwrap()
};
assert!(
diff.as_secs() <= 1,
"mtime should be within 1s of target, diff={:?}",
diff
);
let actual_atime = meta.accessed().unwrap();
let diff_a = if actual_atime > target {
actual_atime.duration_since(target).unwrap()
} else {
target.duration_since(actual_atime).unwrap()
};
assert!(
diff_a.as_secs() <= 1,
"atime should be within 1s of target, diff={:?}",
diff_a
);
}
#[test]
fn test_pin_dir_mtimes_epoch_recurses_into_subdirs() {
let dir = tempfile::tempdir().unwrap();
let dir = dir.path();
let sub = dir.join("nested");
std::fs::create_dir_all(&sub).unwrap();
let top = dir.join("top.txt");
let nested = sub.join("nested.txt");
std::fs::write(&top, "top").unwrap();
std::fs::write(&nested, "nested").unwrap();
let epoch: i64 = 1704067200;
pin_dir_mtimes_epoch(dir, epoch).unwrap();
let target = SystemTime::UNIX_EPOCH + Duration::from_secs(epoch as u64);
for path in [&top, &nested] {
let mtime = std::fs::metadata(path).unwrap().modified().unwrap();
assert_eq!(
mtime,
target,
"{}: mtime must equal the pinned epoch exactly",
path.display()
);
}
}
#[test]
fn test_set_file_mtime_nonexistent_file() {
let result = set_file_mtime(Path::new("/nonexistent/file.txt"), SystemTime::UNIX_EPOCH);
assert!(result.is_err());
}
#[test]
fn test_apply_mod_timestamp_sets_mtime_on_regular_files() {
let dir = tempfile::tempdir().unwrap();
let dir = dir.path();
std::fs::write(dir.join("a.txt"), "aaa").unwrap();
std::fs::write(dir.join("b.txt"), "bbb").unwrap();
std::fs::create_dir(dir.join("subdir")).unwrap();
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
apply_mod_timestamp(dir, "1704067200", &log).unwrap();
let target = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);
for name in &["a.txt", "b.txt"] {
let meta = std::fs::metadata(dir.join(name)).unwrap();
let mtime = meta.modified().unwrap();
let diff = if mtime > target {
mtime.duration_since(target).unwrap()
} else {
target.duration_since(mtime).unwrap()
};
assert!(
diff.as_secs() <= 1,
"{name}: mtime should be within 1s of target, diff={:?}",
diff
);
}
}
#[test]
fn test_apply_mod_timestamp_recurses_into_subdirs() {
let dir = tempfile::tempdir().unwrap();
let dir = dir.path();
let sub = dir.join("docs");
std::fs::create_dir_all(&sub).unwrap();
let top = dir.join("top.txt");
let nested = sub.join("README.txt");
std::fs::write(&top, "top").unwrap();
std::fs::write(&nested, "nested").unwrap();
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
apply_mod_timestamp(dir, "1704067200", &log).unwrap();
let target = SystemTime::UNIX_EPOCH + Duration::from_secs(1704067200);
for path in [&top, &nested] {
let mtime = std::fs::metadata(path).unwrap().modified().unwrap();
let diff = if mtime > target {
mtime.duration_since(target).unwrap()
} else {
target.duration_since(mtime).unwrap()
};
assert!(
diff.as_secs() <= 1,
"{}: nested file must receive mod_timestamp, diff={:?}",
path.display(),
diff
);
}
}
#[test]
fn test_apply_mod_timestamp_invalid_timestamp_errors() {
let dir = tempfile::tempdir().unwrap();
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
let result = apply_mod_timestamp(dir.path(), "not-valid", &log);
assert!(result.is_err());
}
fn mk_release_dir(root: &Path) -> std::path::PathBuf {
let profile = root
.join("target")
.join("x86_64-unknown-linux-gnu")
.join("release");
std::fs::create_dir_all(&profile).unwrap();
profile
}
#[test]
fn test_free_cargo_build_intermediates_removes_transient_keeps_binary() {
let tmp = tempfile::tempdir().unwrap();
let profile = mk_release_dir(tmp.path());
for sub in ["deps", "build", "incremental", ".fingerprint"] {
let d = profile.join(sub);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("scratch.o"), "obj").unwrap();
}
std::fs::write(profile.join("myapp"), b"\x7fELF binary").unwrap();
std::fs::write(profile.join("myapp.d"), "depinfo").unwrap();
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
let mut freed = free_cargo_build_intermediates(&profile, &log);
freed.sort_unstable();
assert_eq!(freed, vec![".fingerprint", "build", "deps", "incremental"]);
for sub in ["deps", "build", "incremental", ".fingerprint"] {
assert!(
!profile.join(sub).exists(),
"transient subdir {sub} should be removed"
);
}
assert!(profile.join("myapp").exists(), "binary must be retained");
assert_eq!(
std::fs::read(profile.join("myapp")).unwrap(),
b"\x7fELF binary"
);
assert!(
profile.join("myapp.d").exists(),
"sibling regular file must be retained"
);
}
#[test]
fn test_free_cargo_build_intermediates_missing_dirs_is_noop() {
let tmp = tempfile::tempdir().unwrap();
let profile = mk_release_dir(tmp.path());
std::fs::write(profile.join("myapp"), "bin").unwrap();
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
let freed = free_cargo_build_intermediates(&profile, &log);
assert!(freed.is_empty(), "nothing to free when no subdirs exist");
assert!(profile.join("myapp").exists());
}
#[test]
fn test_free_cargo_build_intermediates_partial_subset() {
let tmp = tempfile::tempdir().unwrap();
let profile = mk_release_dir(tmp.path());
std::fs::create_dir_all(profile.join("deps")).unwrap();
std::fs::create_dir_all(profile.join("incremental")).unwrap();
std::fs::write(profile.join("myapp"), "bin").unwrap();
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
let mut freed = free_cargo_build_intermediates(&profile, &log);
freed.sort_unstable();
assert_eq!(freed, vec!["deps", "incremental"]);
assert!(profile.join("myapp").exists());
}
#[test]
fn test_free_cargo_build_intermediates_non_profile_dir_is_noop() {
let tmp = tempfile::tempdir().unwrap();
let not_profile = tmp.path().join("target");
std::fs::create_dir_all(not_profile.join("deps")).unwrap();
std::fs::create_dir_all(not_profile.join("build")).unwrap();
let log = crate::log::StageLogger::new("test", crate::log::Verbosity::Quiet);
let freed = free_cargo_build_intermediates(¬_profile, &log);
assert!(
freed.is_empty(),
"non-profile dir must free nothing (guard)"
);
assert!(
not_profile.join("deps").exists() && not_profile.join("build").exists(),
"guard must leave a non-profile dir's contents untouched"
);
}
}