use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::error::CargoError;
const BUILD_SPEC: &str = "Cargo.build-spec.json";
const DELTA: &str = "Cargo.gen.lock";
const ALLOWED: [&str; 3] = [BUILD_SPEC, DELTA, ".gitignore"];
const CRATE_HASHES: &str = "crate-hashes.json";
const GEN_SPEC_WF_PATH: &str = ".github/workflows/gen-spec.yml";
const GEN_SPEC_WF_YAML: &str = "name: gen-spec\n\
on:\n\
\x20\x20push:\n\
\x20\x20\x20\x20branches:\n\
\x20\x20\x20\x20\x20\x20- main\n\
\x20\x20\x20\x20paths:\n\
\x20\x20\x20\x20\x20\x20- \"**/Cargo.toml\"\n\
\x20\x20\x20\x20\x20\x20- \"**/Cargo.lock\"\n\
\x20\x20pull_request:\n\
\x20\x20\x20\x20paths:\n\
\x20\x20\x20\x20\x20\x20- \"**/Cargo.toml\"\n\
\x20\x20\x20\x20\x20\x20- \"**/Cargo.lock\"\n\
jobs:\n\
\x20\x20gen-spec:\n\
\x20\x20\x20\x20uses: pleme-io/substrate/.github/workflows/reusable-gen-spec.yml@main\n\
\x20\x20\x20\x20secrets: inherit\n";
fn is_discardable_artifact(path: &str) -> bool {
path == CRATE_HASHES || path == "target" || path.starts_with("target/")
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum MigrateOutcome {
Migrated {
commit_sha: String,
pushed: bool,
build_spec_retired: bool,
elapsed_ms: u64,
},
AlreadyDeltaOnly,
SkippedNotRust,
SkippedNotAGitRepo,
SkippedDirty { detail: String },
SkippedLockMutated,
SkippedNoDelta,
SkippedBuildTimeout,
SkippedLocalAhead,
Failed {
category: MigrateFailure,
detail: String,
elapsed_ms: u64,
},
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum MigrateFailure {
BuildFailed,
GitInspectionFailed,
GitStageFailed,
GitCommitFailed,
PushDiverged,
PushFailed,
VerifyMismatch,
}
#[derive(Clone, Debug)]
pub struct MigrateOpts {
pub push: bool,
pub bot_identity: bool,
pub gen_bin: PathBuf,
pub build_timeout_secs: u64,
pub jobs: usize,
pub refresh_git_deps: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MigrateReport {
pub outcomes: IndexMap<String, MigrateOutcome>,
pub total_elapsed_ms: u64,
}
impl MigrateReport {
pub fn total(&self) -> usize {
self.outcomes.len()
}
pub fn migrated_count(&self) -> usize {
self.outcomes
.values()
.filter(|o| matches!(o, MigrateOutcome::Migrated { .. }))
.count()
}
pub fn pushed_count(&self) -> usize {
self.outcomes
.values()
.filter(|o| matches!(o, MigrateOutcome::Migrated { pushed: true, .. }))
.count()
}
pub fn failed_count(&self) -> usize {
self.outcomes
.values()
.filter(|o| matches!(o, MigrateOutcome::Failed { .. }))
.count()
}
pub fn skipped_count(&self) -> usize {
self.outcomes
.values()
.filter(|o| {
matches!(
o,
MigrateOutcome::SkippedNotRust
| MigrateOutcome::SkippedNotAGitRepo
| MigrateOutcome::SkippedDirty { .. }
| MigrateOutcome::SkippedLockMutated
| MigrateOutcome::SkippedNoDelta
| MigrateOutcome::SkippedBuildTimeout
| MigrateOutcome::SkippedLocalAhead
| MigrateOutcome::AlreadyDeltaOnly
)
})
.count()
}
}
pub fn run(repos: &[PathBuf], opts: &MigrateOpts) -> Result<MigrateReport, CargoError> {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
let started = Instant::now();
let jobs = opts.jobs.max(1);
let next = AtomicUsize::new(0);
let collected: Mutex<Vec<(usize, String, MigrateOutcome)>> = Mutex::new(Vec::new());
std::thread::scope(|scope| {
for _ in 0..jobs {
scope.spawn(|| loop {
let i = next.fetch_add(1, Ordering::SeqCst);
if i >= repos.len() {
break;
}
let repo = &repos[i];
let name = repo
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| repo.display().to_string());
let outcome = migrate_one(repo, opts);
collected.lock().unwrap().push((i, name, outcome));
});
}
});
let mut rows = collected.into_inner().unwrap();
rows.sort_by_key(|(i, _, _)| *i);
let outcomes: IndexMap<String, MigrateOutcome> =
rows.into_iter().map(|(_, n, o)| (n, o)).collect();
Ok(MigrateReport {
outcomes,
total_elapsed_ms: started.elapsed().as_millis() as u64,
})
}
pub fn migrate_one(repo: &Path, opts: &MigrateOpts) -> MigrateOutcome {
let started = Instant::now();
let ms = || started.elapsed().as_millis() as u64;
if !repo.join(".git").exists() {
return MigrateOutcome::SkippedNotAGitRepo;
}
if !(repo.join("Cargo.toml").exists() && repo.join("Cargo.lock").exists()) {
return MigrateOutcome::SkippedNotRust;
}
match blocking_dirty(repo) {
Err(detail) => {
return MigrateOutcome::Failed {
category: MigrateFailure::GitInspectionFailed,
detail,
elapsed_ms: ms(),
}
}
Ok(Some(detail)) => return MigrateOutcome::SkippedDirty { detail },
Ok(None) => {}
}
discard_stale_artifacts(repo);
if opts.push {
if let Ok(branch) = current_branch(repo) {
let _ = run_git(repo, &["fetch", "--quiet", "origin", &branch]);
let remote_ref = format!("origin/{branch}");
let head = run_git(repo, &["rev-parse", "HEAD"]).unwrap_or_default();
let remote = run_git(repo, &["rev-parse", &remote_ref]).unwrap_or_default();
if !remote.is_empty() && head.trim() != remote.trim() {
if run_git(repo, &["merge-base", "--is-ancestor", "HEAD", &remote_ref]).is_ok() {
let _ = run_git(repo, &["reset", "--hard", &remote_ref]);
} else {
return MigrateOutcome::SkippedLocalAhead;
}
}
}
}
if opts.refresh_git_deps {
if let Err(detail) = refresh_git_deps(repo, opts.build_timeout_secs) {
return MigrateOutcome::Failed {
category: MigrateFailure::BuildFailed,
detail: format!("refresh git deps: {detail}"),
elapsed_ms: ms(),
};
}
}
let lock_before = std::fs::read(repo.join("Cargo.lock")).ok();
match run_build_with_timeout(repo, &opts.gen_bin, opts.build_timeout_secs) {
BuildResult::Ok => {}
BuildResult::TimedOut => {
if build_spec_tracked(repo) {
let _ = run_git(repo, &["checkout", "--", BUILD_SPEC]);
}
let _ = remove_if_untracked(repo, DELTA);
return MigrateOutcome::SkippedBuildTimeout;
}
BuildResult::Failed(detail) => {
return MigrateOutcome::Failed {
category: MigrateFailure::BuildFailed,
detail,
elapsed_ms: ms(),
};
}
}
let lock_after = std::fs::read(repo.join("Cargo.lock")).ok();
if lock_before != lock_after {
let _ = run_git(repo, &["checkout", "--", "Cargo.lock"]);
if build_spec_tracked(repo) {
let _ = run_git(repo, &["checkout", "--", BUILD_SPEC]);
}
let _ = remove_if_untracked(repo, DELTA);
return MigrateOutcome::SkippedLockMutated;
}
if !repo.join(DELTA).exists() {
if build_spec_tracked(repo) {
let _ = run_git(repo, &["checkout", "--", BUILD_SPEC]);
}
return MigrateOutcome::SkippedNoDelta;
}
if let Err(e) = ensure_lock_committable(repo) {
return MigrateOutcome::Failed {
category: MigrateFailure::GitStageFailed,
detail: e,
elapsed_ms: ms(),
};
}
let mut retired = false;
if build_spec_tracked(repo) {
if let Err(e) = run_git(repo, &["rm", "--quiet", "--cached", "--", BUILD_SPEC]) {
return MigrateOutcome::Failed {
category: MigrateFailure::GitStageFailed,
detail: e,
elapsed_ms: ms(),
};
}
retired = true;
}
if let Err(e) = ensure_gitignored(repo, BUILD_SPEC) {
return MigrateOutcome::Failed {
category: MigrateFailure::GitStageFailed,
detail: e,
elapsed_ms: ms(),
};
}
let crate_hashes_retired =
run_git(repo, &["ls-files", "--error-unmatch", CRATE_HASHES]).is_ok();
if crate_hashes_retired {
let _ = run_git(repo, &["rm", "--quiet", "--cached", "--", CRATE_HASHES]);
let _ = ensure_gitignored(repo, CRATE_HASHES);
}
if let Err(e) = ensure_gen_spec_workflow(repo) {
return MigrateOutcome::Failed {
category: MigrateFailure::GitStageFailed,
detail: e,
elapsed_ms: ms(),
};
}
let mut stage: Vec<&str> = vec!["add", "-f", "--", DELTA, ".gitignore", GEN_SPEC_WF_PATH];
if repo.join("Cargo.lock").exists() {
stage.push("Cargo.lock");
}
if let Err(e) = run_git(repo, &stage) {
return MigrateOutcome::Failed {
category: MigrateFailure::GitStageFailed,
detail: e,
elapsed_ms: ms(),
};
}
if run_git(repo, &["diff", "--cached", "--quiet"]).is_ok() {
return MigrateOutcome::AlreadyDeltaOnly;
}
let msg = "spec: retire committed Cargo.build-spec.json — delta-only\n\n\
Drop the full build-spec from version control + gitignore it; the slim\n\
Cargo.gen.lock delta is the sole committed spec source. substrate's\n\
lockfile-builder reconstructs the build-spec in pure Nix from Cargo.lock\n\
+ the delta (delta > build-spec > IFD), so the big artifact is redundant\n\
operator-surface noise. Cargo.lock unchanged (gen build is read-only).\n\n\
Migrated by `gen fleet-migrate`.";
let mut commit_args: Vec<&str> = Vec::new();
if opts.bot_identity {
commit_args.extend_from_slice(&[
"-c",
"user.name=gen-spec-bot",
"-c",
"user.email=gen-spec-bot@pleme-io.invalid",
]);
}
commit_args.extend_from_slice(&["commit", "--quiet", "-m", msg]);
if let Err(e) = run_git(repo, &commit_args) {
return MigrateOutcome::Failed {
category: MigrateFailure::GitCommitFailed,
detail: e,
elapsed_ms: ms(),
};
}
let sha = run_git(repo, &["rev-parse", "HEAD"])
.map(|s| s.trim().to_string())
.unwrap_or_default();
let mut pushed = false;
if opts.push {
let branch = current_branch(repo).unwrap_or_else(|_| "main".to_string());
match run_git(repo, &["push", "origin", &branch]) {
Ok(_) => {}
Err(e) => {
let category = if e.contains("non-fast-forward") || e.contains("rejected") {
MigrateFailure::PushDiverged
} else {
MigrateFailure::PushFailed
};
return MigrateOutcome::Failed {
category,
detail: e,
elapsed_ms: ms(),
};
}
}
let remote = run_git(repo, &["ls-remote", "--heads", "origin", &branch])
.ok()
.and_then(|s| s.split_whitespace().next().map(str::to_string))
.unwrap_or_default();
if remote != sha {
return MigrateOutcome::Failed {
category: MigrateFailure::VerifyMismatch,
detail: format!("remote={remote} local={sha}"),
elapsed_ms: ms(),
};
}
pushed = true;
}
MigrateOutcome::Migrated {
commit_sha: sha,
pushed,
build_spec_retired: retired,
elapsed_ms: ms(),
}
}
fn blocking_dirty(repo: &Path) -> Result<Option<String>, String> {
let out = run_git(repo, &["status", "--porcelain"])?;
for line in out.lines() {
if line.len() < 4 {
continue;
}
let (status, rest) = line.split_at(2);
if status == "??" {
continue; }
let path = rest.trim();
let path = path.rsplit(" -> ").next().unwrap_or(path).trim();
if !ALLOWED.contains(&path) && !is_discardable_artifact(path) {
return Ok(Some(format!("{status} {path}")));
}
}
Ok(None)
}
fn discard_stale_artifacts(repo: &Path) {
let _ = run_git(repo, &["checkout", "--", "target", CRATE_HASHES]);
}
fn build_spec_tracked(repo: &Path) -> bool {
run_git(repo, &["ls-files", "--error-unmatch", BUILD_SPEC]).is_ok()
}
fn current_branch(repo: &Path) -> Result<String, String> {
run_git(repo, &["rev-parse", "--abbrev-ref", "HEAD"]).map(|s| s.trim().to_string())
}
fn remove_if_untracked(repo: &Path, rel: &str) -> Result<(), String> {
if run_git(repo, &["ls-files", "--error-unmatch", rel]).is_err() {
let _ = std::fs::remove_file(repo.join(rel));
}
Ok(())
}
fn ensure_gen_spec_workflow(repo: &Path) -> Result<(), String> {
let path = repo.join(GEN_SPEC_WF_PATH);
if std::fs::read_to_string(&path).ok().as_deref() == Some(GEN_SPEC_WF_YAML) {
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir workflows: {e}"))?;
}
std::fs::write(&path, GEN_SPEC_WF_YAML).map_err(|e| format!("write gen-spec.yml: {e}"))
}
fn ensure_lock_committable(repo: &Path) -> Result<(), String> {
let gi = repo.join(".gitignore");
let cur = match std::fs::read_to_string(&gi) {
Ok(s) => s,
Err(_) => return Ok(()), };
let kept: Vec<&str> = cur
.lines()
.filter(|l| {
let t = l.trim();
t != "Cargo.lock" && t != "/Cargo.lock" && t != DELTA && t != "/Cargo.gen.lock"
})
.collect();
if kept.len() == cur.lines().count() {
return Ok(()); }
let mut new = kept.join("\n");
if !new.is_empty() {
new.push('\n');
}
std::fs::write(&gi, new).map_err(|e| format!("rewrite .gitignore: {e}"))
}
fn ensure_gitignored(repo: &Path, entry: &str) -> Result<(), String> {
let gi = repo.join(".gitignore");
let cur = std::fs::read_to_string(&gi).unwrap_or_default();
if cur.lines().any(|l| l.trim() == entry) {
return Ok(());
}
let mut new = cur;
if !new.is_empty() && !new.ends_with('\n') {
new.push('\n');
}
new.push_str(entry);
new.push('\n');
std::fs::write(&gi, new).map_err(|e| format!("write .gitignore: {e}"))
}
enum BuildResult {
Ok,
TimedOut,
Failed(String),
}
fn run_build_with_timeout(repo: &Path, gen_bin: &Path, timeout_secs: u64) -> BuildResult {
let rp = repo.to_string_lossy();
run_proc_with_timeout(gen_bin, &["build", rp.as_ref()], timeout_secs)
}
fn run_proc_with_timeout(program: &Path, args: &[&str], timeout_secs: u64) -> BuildResult {
use std::os::unix::process::CommandExt;
use std::time::Duration;
let mut child = match Command::new(program)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.process_group(0) .spawn()
{
Ok(c) => c,
Err(e) => return BuildResult::Failed(format!("spawn {}: {e}", program.display())),
};
let pid = child.id();
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
loop {
match child.try_wait() {
Ok(Some(status)) => {
return if status.success() {
BuildResult::Ok
} else {
BuildResult::Failed(format!(
"{} exited {}",
program.display(),
status.code().unwrap_or(-1)
))
};
}
Ok(None) => {
if Instant::now() >= deadline {
let _ = Command::new("kill")
.arg("-KILL")
.arg(format!("-{pid}"))
.status();
let _ = child.kill();
let _ = child.wait();
return BuildResult::TimedOut;
}
std::thread::sleep(Duration::from_millis(200));
}
Err(e) => return BuildResult::Failed(format!("wait {}: {e}", program.display())),
}
}
}
fn refresh_git_deps(repo: &Path, timeout_secs: u64) -> Result<bool, String> {
let lock_path = repo.join("Cargo.lock");
let lock = std::fs::read_to_string(&lock_path).map_err(|e| format!("read lock: {e}"))?;
let mut names: Vec<String> = Vec::new();
let mut cur: Option<String> = None;
for line in lock.lines() {
if let Some(rest) = line.strip_prefix("name = \"") {
cur = rest.strip_suffix('"').map(str::to_string);
} else if line.starts_with("source = \"git+https://github.com/pleme-io/") {
if let Some(n) = cur.take() {
names.push(n);
}
}
}
names.sort();
names.dedup();
if names.is_empty() {
return Ok(false);
}
let before = std::fs::read(&lock_path).ok();
let manifest = repo.join("Cargo.toml").to_string_lossy().into_owned();
for n in &names {
match run_proc_with_timeout(
Path::new("cargo"),
&["update", "--manifest-path", &manifest, "-p", n],
timeout_secs,
) {
BuildResult::TimedOut => return Err(format!("cargo update -p {n} timed out")),
BuildResult::Ok | BuildResult::Failed(_) => {} }
}
Ok(std::fs::read(&lock_path).ok() != before)
}
fn run_git(repo: &Path, args: &[&str]) -> Result<String, String> {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.map_err(|e| format!("git {args:?}: spawn failed: {e}"))?;
if !output.status.success() {
return Err(format!(
"git {args:?} → exit {}: {}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_git_repo(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("genfm-{}-{}", std::process::id(), tag));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
run_git(&dir, &["init", "--quiet"]).unwrap();
run_git(&dir, &["config", "user.email", "t@example.com"]).unwrap();
run_git(&dir, &["config", "user.name", "t"]).unwrap();
dir
}
fn write(dir: &Path, rel: &str, body: &str) {
std::fs::write(dir.join(rel), body).unwrap();
}
fn test_opts() -> MigrateOpts {
MigrateOpts {
push: false,
bot_identity: false,
gen_bin: PathBuf::from("/nonexistent-gen"),
build_timeout_secs: 5,
jobs: 1,
refresh_git_deps: false,
}
}
#[test]
fn ensure_gitignored_appends_and_is_idempotent() {
let dir = temp_git_repo("gi");
ensure_gitignored(&dir, BUILD_SPEC).unwrap();
let after_first = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(after_first.lines().any(|l| l == BUILD_SPEC));
ensure_gitignored(&dir, BUILD_SPEC).unwrap();
let after_second = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
assert_eq!(after_first, after_second);
assert_eq!(
after_second.lines().filter(|l| *l == BUILD_SPEC).count(),
1
);
}
#[test]
fn ensure_lock_committable_drops_lock_ignore() {
let dir = temp_git_repo("lockgi");
write(&dir, ".gitignore", "/target\nCargo.build-spec.json\nCargo.lock\n");
ensure_lock_committable(&dir).unwrap();
let gi = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(!gi.lines().any(|l| l.trim() == "Cargo.lock"), "lock ignore dropped");
assert!(gi.lines().any(|l| l.trim() == "Cargo.build-spec.json"), "build-spec ignore kept");
assert!(gi.lines().any(|l| l.trim() == "/target"), "other ignores kept");
ensure_lock_committable(&dir).unwrap();
assert_eq!(std::fs::read_to_string(dir.join(".gitignore")).unwrap(), gi);
}
#[test]
fn ensure_gitignored_preserves_existing_entries() {
let dir = temp_git_repo("gi2");
write(&dir, ".gitignore", "/target\n");
ensure_gitignored(&dir, BUILD_SPEC).unwrap();
let gi = std::fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(gi.contains("/target"));
assert!(gi.lines().any(|l| l == BUILD_SPEC));
}
#[test]
fn blocking_dirty_clean_tree_is_none() {
let dir = temp_git_repo("clean");
write(&dir, "foo.txt", "hi");
run_git(&dir, &["add", "."]).unwrap();
run_git(&dir, &["commit", "--quiet", "-m", "init"]).unwrap();
assert_eq!(blocking_dirty(&dir).unwrap(), None);
}
#[test]
fn blocking_dirty_flags_tracked_change_outside_allowlist() {
let dir = temp_git_repo("dirty");
write(&dir, "foo.txt", "hi");
run_git(&dir, &["add", "."]).unwrap();
run_git(&dir, &["commit", "--quiet", "-m", "init"]).unwrap();
write(&dir, "foo.txt", "modified"); let blocked = blocking_dirty(&dir).unwrap();
assert!(blocked.is_some(), "tracked non-allowed change must block");
assert!(blocked.unwrap().contains("foo.txt"));
}
#[test]
fn blocking_dirty_allows_migration_files_and_untracked() {
let dir = temp_git_repo("allow");
write(&dir, "foo.txt", "hi");
write(&dir, BUILD_SPEC, "{}");
run_git(&dir, &["add", "."]).unwrap();
run_git(&dir, &["commit", "--quiet", "-m", "init"]).unwrap();
write(&dir, BUILD_SPEC, "{\"v\":10}");
write(&dir, "scratch.tmp", "x");
assert_eq!(
blocking_dirty(&dir).unwrap(),
None,
"allowed-file change + untracked file must not block"
);
}
#[test]
fn blocking_dirty_allows_stale_artifacts_but_blocks_source() {
let dir = temp_git_repo("artifacts");
std::fs::create_dir_all(dir.join("target")).unwrap();
write(&dir, "target/x.o", "a");
write(&dir, "crate-hashes.json", "{}");
write(&dir, "src.rs", "a");
run_git(&dir, &["add", "-A"]).unwrap();
run_git(&dir, &["commit", "--quiet", "-m", "init"]).unwrap();
write(&dir, "target/x.o", "b");
std::fs::remove_file(dir.join("crate-hashes.json")).unwrap();
assert_eq!(blocking_dirty(&dir).unwrap(), None, "stale artifacts must not block");
write(&dir, "src.rs", "b");
assert!(
blocking_dirty(&dir).unwrap().is_some(),
"real source WIP must block"
);
}
#[test]
fn ensure_gen_spec_workflow_writes_canonical_idempotent() {
let dir = temp_git_repo("wf");
ensure_gen_spec_workflow(&dir).unwrap();
let p = dir.join(GEN_SPEC_WF_PATH);
assert!(p.exists(), "workflow written");
let body = std::fs::read_to_string(&p).unwrap();
assert!(body.contains("reusable-gen-spec.yml@main"), "calls the reusable workflow");
assert!(body.contains("**/Cargo.lock"), "triggers on lock change");
ensure_gen_spec_workflow(&dir).unwrap();
assert_eq!(std::fs::read_to_string(&p).unwrap(), body);
}
#[test]
fn build_spec_tracked_reflects_git_state() {
let dir = temp_git_repo("track");
write(&dir, BUILD_SPEC, "{}");
assert!(!build_spec_tracked(&dir), "untracked before add");
run_git(&dir, &["add", "."]).unwrap();
run_git(&dir, &["commit", "--quiet", "-m", "init"]).unwrap();
assert!(build_spec_tracked(&dir), "tracked after commit");
}
#[test]
fn migrate_one_non_rust_repo_skipped() {
let dir = temp_git_repo("notrust");
assert!(matches!(
migrate_one(&dir, &test_opts()),
MigrateOutcome::SkippedNotRust
));
}
#[test]
fn migrate_one_non_git_skipped() {
let dir = std::env::temp_dir().join(format!("genfm-{}-nogit", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
write(&dir, "Cargo.toml", "[package]");
write(&dir, "Cargo.lock", "");
assert!(matches!(
migrate_one(&dir, &test_opts()),
MigrateOutcome::SkippedNotAGitRepo
));
}
#[test]
fn build_timeout_kills_hung_process_group() {
use std::os::unix::fs::PermissionsExt;
use std::time::Duration;
let dir = temp_git_repo("timeout");
let fakegen = dir.join("fakegen.sh");
std::fs::write(&fakegen, "#!/bin/sh\nsleep 30\n").unwrap();
std::fs::set_permissions(&fakegen, std::fs::Permissions::from_mode(0o755)).unwrap();
let start = Instant::now();
let r = run_build_with_timeout(&dir, &fakegen, 1);
assert!(matches!(r, BuildResult::TimedOut), "expected TimedOut, got {r:?}");
assert!(
start.elapsed() < Duration::from_secs(8),
"timeout must kill ~1s, not wait the full 30s sleep"
);
}
}
#[cfg(test)]
impl std::fmt::Debug for BuildResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BuildResult::Ok => write!(f, "Ok"),
BuildResult::TimedOut => write!(f, "TimedOut"),
BuildResult::Failed(d) => write!(f, "Failed({d})"),
}
}
}