crates/nornir-xtask-support/src/lib.rs
//! # nornir-xtask-support — the shared, std-only `cargo xtask` primitives
//!
//! The nordisk constellation drives every build/deploy step from a Rust
//! `xtask` crate (no YAML CI). Four of those xtasks — nornir, holger, dwarves and
//! facett — grew a **byte-identical** block of the same plumbing: a hand-rolled
//! `which`, a `run` that maps a non-zero exit to an `Err`, recursive fs helpers,
//! `git rev-parse --short HEAD`, the `$CARGO_TARGET_DIR`-honouring target-dir
//! resolver, and the wasm bootstrap trio that installs the `wasm32` target plus a
//! `wasm-bindgen` CLI **matching `Cargo.lock`** (the fix for the classic
//! blank-canvas version mismatch). This leaf holds that block ONCE (LAW #5).
//!
//! ## Why a leaf (L5a)
//! These are self-contained, dep-light primitives with no domain coupling — a
//! third unrelated Rust xtask wants them *as-is* (facett's `release-streamlit`
//! path already re-uses `which`/`run`/`fresh_dir`/`target_dir`/`ensure_wasm_*`).
//! The *parameterized* gh-pages demo/deploy FLOW lives one crate up in
//! [`nornir-pages`](../nornir_pages/index.html); this crate is only the toolbox.
//!
//! ## Error style — adapt to every caller
//! Everything fallible returns [`Result`], a boxed `Send + Sync` error alias.
//! `anyhow::Error`, `Box<dyn Error>` and `Result<_, String>` callers all `?` or
//! `From`-adapt it, so one implementation serves all four xtasks' error styles.
//!
//! ## Zero deps by default
//! std only. `nornir-testmatrix` is pulled ONLY under the `testmatrix` feature to
//! self-emit the pure primitives' health; the release/default build links neither.
use std::path::{Path, PathBuf};
use std::process::Command;
pub mod ci;
pub mod lockcheck;
pub mod selftest;
pub mod spawn_guard;
/// A boxed dynamic error that is `Send + Sync` — the dependency-free stand-in for
/// `anyhow::Error`. Chosen `Send + Sync` (unlike a bare `Box<dyn Error>`) so an
/// `anyhow` caller's `?` adapts it and it can cross threads. `String`/`&str` both
/// `.into()` it (std provides those `From` impls), so `format!(…).into()` is the
/// idiom for an ad-hoc error here.
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
/// The wasm target triple every constellation demo builds for.
pub const WASM_TARGET: &str = "wasm32-unknown-unknown";
// ── paths ─────────────────────────────────────────────────────────────────────
/// Expand a leading `~/` against `$HOME` — the ONE implementation.
///
/// `~` is a **shell** convention. Nothing expands it inside a Rust string, so a
/// config literal like `~/.ssh/id_ed25519` becomes a real directory named `~`
/// the moment it is handed to `std::fs`. Every tool in the constellation that
/// reads a path out of a config file, a CLI flag or an env var therefore needs
/// exactly this three-line function — and, until 2026-09-10, wrote its own:
/// gunnar carried **four** copies (`xtask/src/lib.rs`, `bench/src/lib.rs`,
/// `installtest/src/lib.rs`, `deploy/upcloud/src/site.rs`), three of them
/// byte-identical, each with a doc comment claiming to be "the one place that
/// does". Found by `nornir mimir analyze duplicates`; this is the home the
/// keep/kill recommendation named.
///
/// A path that does not start with `~/` is returned unchanged, so a caller may
/// pass an absolute override straight through. A bare `~` (no slash) is also
/// returned unchanged: it is a legal relative filename and expanding it would
/// change the meaning of `./~`.
///
/// `$HOME` unset falls back to `/root`, which is the behaviour every copy had
/// and the one that matters where it bites — a systemd unit or a container that
/// starts with no `HOME` runs as root, and pointing at `/root` beats pointing at
/// a relative path under the process's cwd.
pub fn expand_home(p: &str) -> PathBuf {
match p.strip_prefix("~/") {
Some(rest) => {
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".to_string())).join(rest)
}
None => PathBuf::from(p),
}
}
/// The workspace root for an xtask crate: the **parent** of its manifest dir.
///
/// An xtask lives at `<root>/xtask`, so its `CARGO_MANIFEST_DIR` is `<root>/xtask`
/// and the workspace root is one directory up. Pass `env!("CARGO_MANIFEST_DIR")`
/// (as a `Path`) from the calling xtask — the leaf can't read the *caller's*
/// compile-time manifest dir, so the caller supplies it.
pub fn workspace_root_of(manifest_dir: &Path) -> PathBuf {
manifest_dir
.parent()
.expect("xtask manifest dir has a parent (the workspace root)")
.to_path_buf()
}
/// The cargo **target** directory for `root` — **asked of cargo**, not re-derived.
///
/// Every caller of this uses it for the same thing: to find an artifact `cargo build`
/// has just written. So the only correct answer is the one cargo itself would give,
/// and cargo gives it: `cargo metadata --no-deps` reports `target_directory` with the
/// whole resolution already applied — `--target-dir`, `$CARGO_TARGET_DIR`,
/// `build.target-dir` from every `.cargo/config.toml` up the tree, and the workspace
/// root default, in cargo's own precedence.
///
/// # Why this stopped being a two-line env read
///
/// It honoured `$CARGO_TARGET_DIR` **and nothing else**, so a `build.target-dir` in
/// `.cargo/config.toml` — the documented way, and the one a developer uses to keep
/// builds off a small `/home` — was invisible to it. cargo wrote the artifact to the
/// configured directory; this function looked under `<root>/target`; and the caller
/// reported *"expected wasm artifact missing"* **immediately after building it
/// successfully**. That cost a t14s lane an hour on 2026-09-06, and the symptom does
/// not name the cause: nothing in "artifact missing" points at a config key.
///
/// MEASURED on a throwaway crate before this was written, rather than assumed: with
/// `[build] target-dir` configured, `cargo build` writes to the configured directory
/// and `<root>/target` is **never created**, while `cargo metadata --no-deps` reports
/// the configured path as `target_directory`.
///
/// # The fallback, and why it is not a second answer
///
/// If cargo cannot be run at all (not on `PATH`, no manifest at `root`, a spawn
/// failure) this returns the old heuristic — `$CARGO_TARGET_DIR` else `<root>/target`.
/// That is not a rival implementation: it is what cargo would answer for a default
/// project, kept so a caller in a stripped environment degrades to the previous
/// behaviour instead of panicking. When cargo IS available its answer wins outright.
pub fn target_dir(root: &Path) -> PathBuf {
cargo_target_directory(root).unwrap_or_else(|| target_dir_fallback(root))
}
/// `cargo metadata --no-deps`'s `target_directory`, or `None` when cargo could not be
/// asked (missing, failed, or an answer without the field).
fn cargo_target_directory(root: &Path) -> Option<PathBuf> {
let manifest = root.join("Cargo.toml");
if !manifest.is_file() {
return None;
}
let out = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
.current_dir(root)
.args([
"metadata",
"--no-deps",
"--format-version",
"1",
"--manifest-path",
])
.arg(&manifest)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8(out.stdout).ok()?;
json_string_field(&text, "target_directory").map(PathBuf::from)
}
/// The pre-2026-09-06 answer: `$CARGO_TARGET_DIR` (absolute verbatim, relative joined
/// onto `root`), else `<root>/target`.
/// Serialises the tests that mutate `CARGO_TARGET_DIR`.
///
/// `std::env::set_var` mutates PROCESS state and `cargo test` runs tests on threads
/// of ONE process, so two tests touching the same variable interleave: each passes
/// when run alone and they flake when run together. THREE tests in this crate set
/// it — `target_dir_falls_back_to_the_env_override_when_cargo_cannot_be_asked`,
/// `target_dir_honours_build_target_dir_from_cargo_config`, and `selftest`'s
/// feature-gated wiring test. It lives at crate level rather than inside
/// `mod tests` because that third one is in a DIFFERENT module, and a lock only
/// some of the mutators take is not a lock.
#[cfg(test)]
pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn target_dir_fallback(root: &Path) -> PathBuf {
match std::env::var_os("CARGO_TARGET_DIR") {
Some(v) if !v.is_empty() => {
let p = PathBuf::from(v);
if p.is_absolute() {
p
} else {
root.join(p)
}
}
_ => root.join("target"),
}
}
/// Pull one **top-level** JSON string field out of `text`.
///
/// Deliberately not a JSON parser and deliberately not a dependency: this crate is
/// std-only by design (its own description says so, and the `testmatrix` feature
/// exists to keep it that way in the default build), and one string value out of one
/// known document does not justify pulling serde into every xtask in the
/// constellation.
///
/// It does decode the escapes a path can actually carry — `\"`, `\\`, `\/`, the
/// control shorthands and `\uXXXX` — because a directory name may contain any of
/// them and silently truncating at the wrong byte is exactly the class of bug this
/// function was written to remove.
fn json_string_field(text: &str, field: &str) -> Option<String> {
let key = format!("\"{field}\"");
let mut from = 0usize;
loop {
let at = text[from..].find(&key)? + from;
let after = &text[at + key.len()..];
let rest = after.trim_start();
// Only a `"key":` occurrence is the field; `"key"` as a VALUE is not.
let Some(rest) = rest.strip_prefix(':') else {
from = at + key.len();
continue;
};
let rest = rest.trim_start();
let body = rest.strip_prefix('"')?;
return decode_json_string(body);
}
}
/// Decode a JSON string body (everything after the opening quote) up to its closing
/// quote.
fn decode_json_string(body: &str) -> Option<String> {
let mut out = String::with_capacity(body.len());
let mut it = body.chars();
while let Some(c) = it.next() {
match c {
'"' => return Some(out),
'\\' => match it.next()? {
'"' => out.push('"'),
'\\' => out.push('\\'),
'/' => out.push('/'),
'b' => out.push('\u{8}'),
'f' => out.push('\u{c}'),
'n' => out.push('\n'),
'r' => out.push('\r'),
't' => out.push('\t'),
'u' => {
let hex: String = (0..4).filter_map(|_| it.next()).collect();
if hex.len() != 4 {
return None;
}
let n = u32::from_str_radix(&hex, 16).ok()?;
out.push(char::from_u32(n)?);
}
_ => return None,
},
_ => out.push(c),
}
}
// No closing quote: a truncated document, which must not become a truncated path.
None
}
// ── process / lookup ──────────────────────────────────────────────────────────
/// Run a command, streaming its output to our stdio; map a non-zero exit to an
/// `Err` tagged with `what` (a human label for the step).
pub fn run(cmd: &mut Command, what: &str) -> Result<()> {
let status = cmd.status().map_err(|e| format!("spawn {what}: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("{what} exited with {status}").into())
}
}
/// `which`-style PATH lookup (plus `~/.cargo/bin`), no external dep. Returns the
/// first matching file. `~/.cargo/bin` is appended because a freshly
/// `cargo install`ed tool (e.g. `wasm-bindgen`) lands there and may not be on a
/// bare shell's `PATH`.
pub fn which(bin: &str) -> Option<PathBuf> {
let mut dirs: Vec<PathBuf> = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect())
.unwrap_or_default();
if let Some(home) = std::env::var_os("HOME") {
dirs.push(PathBuf::from(home).join(".cargo/bin"));
}
for d in dirs {
let cand = d.join(bin);
if cand.is_file() {
return Some(cand);
}
}
None
}
/// Short `HEAD` revision of `root` — the cache-bust / deploy-message rev. Read
/// in-process via gix through the `nornir-git` leaf (`gitops::short_head_sha`),
/// NEVER the `git` CLI (LAW: every git op goes through nornir-git). `None` (not an
/// `Err`) when HEAD can't be read (no commit yet / not a repo), so a caller can
/// fall back to a placeholder.
pub fn short_rev(root: &Path) -> Option<String> {
nornir_git::gitops::short_head_sha(root).ok()
}
// ── filesystem ────────────────────────────────────────────────────────────────
/// Remove `dir` (if any) and recreate it empty — the fresh-output-dir idiom.
pub fn fresh_dir(dir: &Path) -> Result<()> {
if dir.exists() {
std::fs::remove_dir_all(dir).map_err(|e| format!("clear {}: {e}", dir.display()))?;
}
std::fs::create_dir_all(dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?;
Ok(())
}
/// Delete every entry in a worktree **except** `.git` (the worktree gitfile/dir),
/// so the tree can be repopulated wholesale (the pages-deploy squash pattern).
pub fn clear_worktree(wt: &Path) -> Result<()> {
for entry in std::fs::read_dir(wt).map_err(|e| format!("read {}: {e}", wt.display()))? {
let entry = entry.map_err(|e| format!("dir entry: {e}"))?;
if entry.file_name() == ".git" {
continue;
}
let p = entry.path();
let r = if p.is_dir() {
std::fs::remove_dir_all(&p)
} else {
std::fs::remove_file(&p)
};
r.map_err(|e| format!("rm {}: {e}", p.display()))?;
}
Ok(())
}
/// Recursively copy `src/*` into `dst` (both must exist; `dst`'s missing
/// sub-dirs are created).
pub fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
for entry in std::fs::read_dir(src).map_err(|e| format!("read {}: {e}", src.display()))? {
let entry = entry.map_err(|e| format!("dir entry: {e}"))?;
let from = entry.path();
let to = dst.join(entry.file_name());
if from.is_dir() {
std::fs::create_dir_all(&to).map_err(|e| format!("mkdir {}: {e}", to.display()))?;
copy_dir(&from, &to)?;
} else {
std::fs::copy(&from, &to)
.map_err(|e| format!("copy {} → {}: {e}", from.display(), to.display()))?;
}
}
Ok(())
}
// ── wasm bootstrap ────────────────────────────────────────────────────────────
/// Ensure the `wasm32-unknown-unknown` rustup target is installed (idempotent).
/// The cheap `rustup target list --installed` check, then `rustup target add` only
/// if missing.
pub fn ensure_wasm_target() -> Result<()> {
let out = Command::new("rustup")
.args(["target", "list", "--installed"])
.output()
.map_err(|e| format!("run rustup (is rustup installed?): {e}"))?;
let installed = String::from_utf8_lossy(&out.stdout);
if installed.lines().any(|l| l.trim() == WASM_TARGET) {
return Ok(());
}
println!("xtask: installing rustup target {WASM_TARGET}…");
run(
Command::new("rustup").args(["target", "add", WASM_TARGET]),
"rustup target add",
)
}
/// Ensure a `wasm-bindgen` CLI **matching the locked crate version** is available;
/// return the binary to invoke. A version mismatch between the CLI and the
/// `wasm-bindgen` crate is the classic "blank canvas" failure, so pin exactly:
/// if a `wasm-bindgen` on PATH already reports the wanted version use it, else
/// `cargo install wasm-bindgen-cli --version <locked> --locked` (idempotent).
pub fn ensure_wasm_bindgen(root: &Path) -> Result<PathBuf> {
let want = locked_wasm_bindgen_version(root)?;
if let Some(path) = which("wasm-bindgen") {
if let Ok(out) = Command::new(&path).arg("--version").output() {
let v = String::from_utf8_lossy(&out.stdout);
// `wasm-bindgen 0.2.123`
if v.split_whitespace().nth(1) == Some(want.as_str()) {
return Ok(path);
}
}
}
println!("xtask: installing wasm-bindgen-cli {want} (matching Cargo.lock)…");
run(
Command::new("cargo").args(["install", "wasm-bindgen-cli", "--version", &want, "--locked"]),
"cargo install wasm-bindgen-cli",
)?;
which("wasm-bindgen").ok_or_else(|| {
"wasm-bindgen installed but not found on PATH — ensure ~/.cargo/bin is on PATH".into()
})
}
/// Read the resolved `wasm-bindgen` version out of `<root>/Cargo.lock` — the exact
/// version [`ensure_wasm_bindgen`] pins the CLI to. Scans for the
/// `name = "wasm-bindgen"` `[[package]]` block and returns its `version = "…"`.
pub fn locked_wasm_bindgen_version(root: &Path) -> Result<String> {
let lock = std::fs::read_to_string(root.join("Cargo.lock"))
.map_err(|e| format!("read Cargo.lock: {e}"))?;
let mut in_block = false;
for line in lock.lines() {
let t = line.trim();
if t == "name = \"wasm-bindgen\"" {
in_block = true;
continue;
}
if in_block && t.starts_with("version = ") {
return Ok(t.trim_start_matches("version = ").trim_matches('"').to_string());
}
if in_block && t == "[[package]]" {
break; // moved past the block without a version
}
}
Err("could not find wasm-bindgen in Cargo.lock (resolve the workspace for wasm first)".into())
}
// ── tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// `expand_home` replaces a leading `~/` with `$HOME` and touches nothing else.
///
/// RED-when-broken on all four behaviours the four gunnar copies had between
/// them, because a repoint that quietly changed ANY of them would break a
/// caller that never read this crate: the `~/` expansion itself; an absolute
/// path passed through verbatim (every caller's "override" door); a BARE `~`
/// left alone (a legal relative filename — expanding it would silently
/// rewrite `./~`); and the `/root` fallback when `$HOME` is unset, which is
/// what a systemd unit and a container both hit.
///
/// Takes `ENV_LOCK`: `set_var`/`remove_var` are PROCESS state and `cargo
/// test` threads share one process, so this and the `CARGO_TARGET_DIR` tests
/// must not interleave.
#[test]
fn expand_home_expands_only_a_leading_tilde_slash() {
let _g = crate::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var_os("HOME");
std::env::set_var("HOME", "/home/someone");
assert_eq!(
expand_home("~/.ssh/id_ed25519"),
PathBuf::from("/home/someone/.ssh/id_ed25519"),
"a leading `~/` is the one thing that expands"
);
assert_eq!(
expand_home("/etc/gunnar/site.toml"),
PathBuf::from("/etc/gunnar/site.toml"),
"an absolute override passes through untouched"
);
assert_eq!(
expand_home("~"),
PathBuf::from("~"),
"a bare `~` is a legal relative filename, not a home reference"
);
assert_eq!(
expand_home("a/~/b"),
PathBuf::from("a/~/b"),
"a `~` that is not the FIRST segment means nothing"
);
std::env::remove_var("HOME");
assert_eq!(
expand_home("~/x"),
PathBuf::from("/root/x"),
"no $HOME ⇒ /root, the behaviour every copy had"
);
match prev {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
/// `workspace_root_of` returns the parent of the manifest dir (the `<root>/xtask`
/// → `<root>` climb).
#[test]
fn workspace_root_climbs_one_dir() {
let root = workspace_root_of(Path::new("/home/x/repo/xtask"));
assert_eq!(root, PathBuf::from("/home/x/repo"));
}
/// The FALLBACK honours `$CARGO_TARGET_DIR` (absolute wins verbatim; relative is
/// joined onto root; unset falls back to `<root>/target`). `/ws` holds no
/// `Cargo.toml`, so `target_dir` cannot ask cargo and this is exactly the path it
/// takes — which is the point: a caller in a stripped environment still gets the
/// old behaviour rather than a panic.
#[test]
fn target_dir_falls_back_to_the_env_override_when_cargo_cannot_be_asked() {
let _g = crate::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let root = Path::new("/ws");
let prev = std::env::var_os("CARGO_TARGET_DIR");
std::env::remove_var("CARGO_TARGET_DIR");
assert_eq!(target_dir(root), PathBuf::from("/ws/target"));
std::env::set_var("CARGO_TARGET_DIR", "/mnt/work4t/cargo-target/edda");
assert_eq!(
target_dir(root),
PathBuf::from("/mnt/work4t/cargo-target/edda")
);
std::env::set_var("CARGO_TARGET_DIR", "rel/dir");
assert_eq!(target_dir(root), PathBuf::from("/ws/rel/dir"));
std::env::set_var("CARGO_TARGET_DIR", "");
assert_eq!(target_dir(root), PathBuf::from("/ws/target"));
match prev {
Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
None => std::env::remove_var("CARGO_TARGET_DIR"),
}
}
/// **`build.target-dir` in `.cargo/config.toml` is honoured — the defect this
/// function was rewritten for.**
///
/// Driven against a REAL crate and a REAL cargo, not a fixture: a throwaway
/// package is written to a temp dir, cargo is asked where its target directory is,
/// and `target_dir` must agree. Then the config is added and both must MOVE
/// together.
///
/// RED before the rewrite: the second half answered `<root>/target` while cargo
/// answered the configured path — and a caller looking for a freshly built
/// artifact under the first one reported it missing, right after building it.
/// That is the hour it cost a t14s lane on 2026-09-06.
///
/// Skipped, loudly, if cargo is not on PATH — the assertion is about agreeing with
/// cargo, and with no cargo there is nothing to agree with.
#[test]
fn target_dir_honours_build_target_dir_from_cargo_config() {
let _g = crate::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some(_cargo) = which("cargo") else {
eprintln!("nornir-xtask-support: no cargo on PATH; skipping the config test");
return;
};
let base = std::env::temp_dir().join(format!("nxs-td-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("src")).unwrap();
std::fs::write(
base.join("Cargo.toml"),
b"[package]\nname = \"nxs-td-probe\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::write(base.join("src/main.rs"), b"fn main() {}\n").unwrap();
// The env var must not decide either arm; this test is about the CONFIG.
let prev = std::env::var_os("CARGO_TARGET_DIR");
std::env::remove_var("CARGO_TARGET_DIR");
// …and neither must the DEVELOPER'S OWN cargo config. `$CARGO_HOME` is
// pointed at an empty directory for the duration.
//
// Not tidiness — without this, arm 1 asserts something about the machine
// rather than about the code, and it FAILS on any machine that sets
// `build.target-dir` in `~/.cargo/config.toml`. Measured here, red:
//
// assertion `left == right` failed: with no config, cargo's answer is
// <root>/target
// left: "~/cargo-target"
// right: "/tmp/nxs-td-3769149/target"
//
// That is `cargo metadata` being exactly right — it obeys `$CARGO_HOME`'s
// config, which is the whole point of asking cargo instead of re-deriving —
// and the test being wrong about what "no config" means. A shared scratch
// target dir is the normal setup on these boxes, so the arm that proves the
// fallback still works has to say WHICH configs it means by "none".
let prev_home = std::env::var_os("CARGO_HOME");
let empty_home = std::env::temp_dir().join(format!("nxs-td-home-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&empty_home);
std::fs::create_dir_all(&empty_home).unwrap();
std::env::set_var("CARGO_HOME", &empty_home);
// ── arm 1: no config. The default, and the positive control — without it a
// `target_dir` that always returned the configured path would pass arm 2.
assert_eq!(
target_dir(&base),
base.join("target"),
"with no config, cargo's answer is <root>/target"
);
// ── arm 2: the config cargo actually obeys.
let elsewhere = base.join("elsewhere");
std::fs::create_dir_all(base.join(".cargo")).unwrap();
std::fs::write(
base.join(".cargo/config.toml"),
format!("[build]\ntarget-dir = \"{}\"\n", elsewhere.display()).as_bytes(),
)
.unwrap();
assert_eq!(
target_dir(&base),
elsewhere,
"cargo writes artifacts to `build.target-dir`; this lookup must find them there"
);
match prev {
Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
None => std::env::remove_var("CARGO_TARGET_DIR"),
}
match prev_home {
Some(v) => std::env::set_var("CARGO_HOME", v),
None => std::env::remove_var("CARGO_HOME"),
}
let _ = std::fs::remove_dir_all(&empty_home);
let _ = std::fs::remove_dir_all(&base);
}
/// The one-field JSON reader, including the escapes a directory name can carry and
/// the two shapes that must NOT be mistaken for the field.
#[test]
fn the_field_reader_decodes_what_a_path_can_contain_and_refuses_what_it_must() {
assert_eq!(
json_string_field(r#"{"a":1,"target_directory":"/tmp/x","b":2}"#, "target_directory"),
Some("/tmp/x".to_string())
);
// A path carrying every escape a directory name can actually produce: an
// escaped quote, an escaped backslash, an escaped solidus and a \u codepoint.
// Written as a raw string so what the reader sees is the JSON bytes cargo
// would emit, not Rust's idea of them.
assert_eq!(
json_string_field(
r#"{"target_directory":"/tmp/a\"b\\c\/då"}"#,
"target_directory"
),
Some("/tmp/a\"b\\c/då".to_string())
);
// The key appearing as a VALUE must not be mistaken for the field — this is
// why the reader requires a `:` after the key and keeps looking otherwise.
assert_eq!(
json_string_field(
r#"{"note":"target_directory","target_directory":"/real"}"#,
"target_directory"
),
Some("/real".to_string())
);
// Absent, and truncated: both `None`, because a truncated document must never
// become a truncated path.
assert_eq!(json_string_field(r#"{"other":"x"}"#, "target_directory"), None);
assert_eq!(json_string_field(r#"{"target_directory":"/tmp/x"#, "target_directory"), None);
}
/// `copy_dir` round-trips a nested tree byte-for-byte, and `fresh_dir` /
/// `clear_worktree` clear correctly.
#[test]
fn copy_dir_round_trips_nested_tree() {
let base = std::env::temp_dir().join(format!("nxs-copy-{}", std::process::id()));
let src = base.join("src");
let dst = base.join("dst");
let _ = std::fs::remove_dir_all(&base);
fresh_dir(&src).unwrap();
fresh_dir(&dst).unwrap();
std::fs::write(src.join("a.txt"), b"alpha").unwrap();
std::fs::create_dir_all(src.join("sub")).unwrap();
std::fs::write(src.join("sub/b.bin"), [0u8, 1, 2, 255]).unwrap();
copy_dir(&src, &dst).unwrap();
assert_eq!(std::fs::read(dst.join("a.txt")).unwrap(), b"alpha");
assert_eq!(
std::fs::read(dst.join("sub/b.bin")).unwrap(),
vec![0u8, 1, 2, 255]
);
// clear_worktree removes everything but a `.git` marker.
std::fs::write(dst.join(".git"), b"gitdir: elsewhere").unwrap();
clear_worktree(&dst).unwrap();
assert!(dst.join(".git").exists(), ".git must survive clear_worktree");
assert!(!dst.join("a.txt").exists());
assert!(!dst.join("sub").exists());
let _ = std::fs::remove_dir_all(&base);
}
/// `which` finds a real, known-present binary somewhere on PATH (`cargo`/`ls`)
/// and returns `None` for a bogus name.
#[test]
fn which_finds_and_misses() {
assert!(which("definitely-not-a-real-binary-xyzzy").is_none());
// `cargo` is on PATH in any build env that ran this test; if not, `sh` is.
assert!(
which("cargo").is_some() || which("sh").is_some(),
"expected cargo or sh on PATH"
);
}
}