mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
d67e6ab779
Follow-up to #3124, requested in [this thread](https://github.com/max-sixty/worktrunk/pull/3124#issuecomment-4747856158): turn the "build/package environment differs from the dev checkout" class inner-loop. #3123 (`env!("VERGEN_GIT_DESCRIBE")` failing on the crates.io archive) was one instance of a general pattern — *something read at compile time that exists in our git checkout but may be absent in a packaged build*. There are three mechanisms in the crate that do this, and a missing file in any of them is a hard compile error, exactly the #3123 failure mode: | Mechanism | Embeds | |-----------|--------| | `include_str!` / `include_bytes!` | `dev/*.example.toml`, `dev/opencode-plugin.ts`, `gemini-extension.json` | | askama `#[template(path = …)]` (reads files at compile time via proc-macro) | `templates/{bash.sh,zsh.zsh,fish.fish,fish_wrapper.fish,nushell.nu,powershell.ps1}` | The detection gap: a plain `cargo build`/`cargo test` always sees these files (they're in the working tree), so it can never catch a packaging drop. Today that's only caught by the nightly Nix build (slow) or by crates.io itself after publish (the worst loop). The realistic future regression is someone adding a new `include_str!("../../assets/foo")` or `#[template]` outside the currently-packaged dirs — it passes every PR check, then breaks Nix that night and `cargo install` at the next release. This adds a fast guard (`embedded_assets_ship_in_package`, no build, runs on every PR) that scans `src/` for every embedded path and asserts each is: 1. **present in `cargo package --list`** — so it ships in the crates.io archive (the #3123 surface), and 2. **covered by the `flake.nix` source filter** — so it survives the Nix build. Both currently pass. I verified the test isn't vacuous: pointing an `include_str!` at a tracked-but-unpackaged file (`tests/helpers/wt-perf/src/lib.rs`) fails it with both violations reported, distinct from the compiler's own "file not found" for a path that doesn't exist at all. This sits alongside the existing `version_build.rs` guards (the fast `env!`-vs-`option_env!` mechanism check on every PR, and the `#[ignore]`d full-archive build in nightly), extending the same philosophy to the file-presence half of the class. Co-authored-by: worktrunk-bot <worktrunk-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
211 lines
8.3 KiB
Rust
211 lines
8.3 KiB
Rust
//! Guard test: files embedded at compile time must ship in the packaged crate.
|
|
//!
|
|
//! Several macros read a file from disk *when the crate compiles*, not at
|
|
//! runtime:
|
|
//!
|
|
//! - `include_str!` / `include_bytes!` — embed `dev/*.example.toml`,
|
|
//! `dev/opencode-plugin.ts`, `gemini-extension.json`.
|
|
//! - askama's `#[template(path = "…")]` — a proc-macro that reads
|
|
//! `templates/*` from disk while expanding the derive.
|
|
//!
|
|
//! If any of these files is missing from the build environment, the crate
|
|
//! fails to *compile* — the same failure mode as #3123, just via a different
|
|
//! macro. The danger is that the build environment differs from the dev
|
|
//! checkout: a plain `cargo build` / `cargo test` always sees the files
|
|
//! (they're in the working tree), so it can never catch a packaging drop. The
|
|
//! gap only surfaces where the file is absent:
|
|
//!
|
|
//! - `cargo install worktrunk` — builds the crates.io source archive. A file
|
|
//! not listed by `cargo package` simply isn't in the archive.
|
|
//! - the Nix build (`flake.nix`) — its source filter allowlists specific
|
|
//! directories; a file outside them is filtered out before the build sees
|
|
//! it.
|
|
//!
|
|
//! Both paths are outer-loop: a missing asset passes every PR check, then
|
|
//! breaks Nix that night and `cargo install` at the next release. This test
|
|
//! pulls the whole class inner-loop. It scans `src/` for every embedded path,
|
|
//! resolves each, and asserts the file is (a) present in `cargo package
|
|
//! --list` output (so it ships to crates.io) and (b) covered by the
|
|
//! `flake.nix` source filter (so it survives the Nix build).
|
|
//!
|
|
//! Adding a new `include_str!("../../assets/foo")` or `#[template(path =
|
|
//! "…")]` whose directory isn't packaged will fail here, on the PR, instead of
|
|
//! after publish.
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::fs;
|
|
use std::path::{Component, Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
use path_slash::PathExt as _;
|
|
|
|
/// askama resolves `#[template(path = "…")]` relative to `templates/` at the
|
|
/// crate root (the default when there is no `askama.toml`).
|
|
const ASKAMA_TEMPLATE_DIR: &str = "templates";
|
|
|
|
#[test]
|
|
fn embedded_assets_ship_in_package() {
|
|
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
let src_dir = manifest_dir.join("src");
|
|
|
|
// 1. Discover every compile-time-embedded path under `src/`, as a
|
|
// repo-root-relative forward-slash string.
|
|
let mut assets = BTreeSet::new();
|
|
scan_directory(&src_dir, manifest_dir, &mut assets);
|
|
assert!(
|
|
!assets.is_empty(),
|
|
"scanned src/ but found no embedded assets — the scanner is likely broken"
|
|
);
|
|
|
|
// 2. The set of files `cargo publish` would put in the crates.io archive.
|
|
let packaged = cargo_package_list(manifest_dir);
|
|
|
|
// 3. The `flake.nix` source filter, read as text — its allowlist is what
|
|
// the Nix build sees.
|
|
let flake_nix = fs::read_to_string(manifest_dir.join("flake.nix")).expect("read flake.nix");
|
|
|
|
let mut violations = Vec::new();
|
|
for asset in &assets {
|
|
if !packaged.contains(asset) {
|
|
violations.push(format!(
|
|
"{asset}: embedded at compile time but NOT in `cargo package --list` \
|
|
— it would be absent from the crates.io archive, so `cargo install` \
|
|
fails to compile (the #3123 class)"
|
|
));
|
|
}
|
|
if !flake_covers(&flake_nix, asset) {
|
|
violations.push(format!(
|
|
"{asset}: embedded at compile time but not covered by the `flake.nix` \
|
|
source filter — it would be filtered out of the Nix build"
|
|
));
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
violations.is_empty(),
|
|
"Embedded assets missing from a packaged build:\n\n{}\n\n\
|
|
Each file above is read at compile time (include_str!/include_bytes!/askama \
|
|
#[template]) but won't be present in at least one packaged build environment. \
|
|
Add its directory to `Cargo.toml` packaging (it ships by default unless \
|
|
include/exclude says otherwise) and to the `flake.nix` source filter.",
|
|
violations.join("\n")
|
|
);
|
|
}
|
|
|
|
fn scan_directory(dir: &Path, manifest_dir: &Path, assets: &mut BTreeSet<String>) {
|
|
let Ok(entries) = fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
scan_directory(&path, manifest_dir, assets);
|
|
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
|
|
scan_file(&path, manifest_dir, assets);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn scan_file(path: &Path, manifest_dir: &Path, assets: &mut BTreeSet<String>) {
|
|
let Ok(contents) = fs::read_to_string(path) else {
|
|
return;
|
|
};
|
|
let source_dir = path.parent().unwrap_or(manifest_dir);
|
|
|
|
for line in contents.lines() {
|
|
// `include_str!("…")` / `include_bytes!("…")` — path relative to the
|
|
// source file's directory.
|
|
for macro_name in ["include_str!(\"", "include_bytes!(\""] {
|
|
for literal in literals_after(line, macro_name) {
|
|
if let Some(rel) = repo_relative(&source_dir.join(&literal), manifest_dir) {
|
|
assets.insert(rel);
|
|
}
|
|
}
|
|
}
|
|
|
|
// askama `#[template(path = "…")]` — path relative to `templates/`.
|
|
for literal in literals_after(line, "template(path = \"") {
|
|
let resolved = manifest_dir.join(ASKAMA_TEMPLATE_DIR).join(&literal);
|
|
if let Some(rel) = repo_relative(&resolved, manifest_dir) {
|
|
assets.insert(rel);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Every string literal that immediately follows `prefix` on `line`, read up to
|
|
/// the next `"`. Handles multiple matches on one line.
|
|
fn literals_after(line: &str, prefix: &str) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
let mut rest = line;
|
|
while let Some(idx) = rest.find(prefix) {
|
|
let after = &rest[idx + prefix.len()..];
|
|
if let Some(end) = after.find('"') {
|
|
out.push(after[..end].to_string());
|
|
rest = &after[end + 1..];
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Normalize a path (collapsing `.`/`..` lexically, no filesystem access) and
|
|
/// express it relative to the crate root with forward slashes. Returns `None`
|
|
/// if it escapes the crate root.
|
|
fn repo_relative(path: &Path, manifest_dir: &Path) -> Option<String> {
|
|
let normalized = lexical_normalize(path);
|
|
let relative = normalized.strip_prefix(manifest_dir).ok()?;
|
|
Some(relative.to_slash_lossy().into_owned())
|
|
}
|
|
|
|
fn lexical_normalize(path: &Path) -> PathBuf {
|
|
let mut out = PathBuf::new();
|
|
for component in path.components() {
|
|
match component {
|
|
Component::ParentDir => {
|
|
out.pop();
|
|
}
|
|
Component::CurDir => {}
|
|
other => out.push(other.as_os_str()),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// True if the `flake.nix` source filter would keep `asset`. The filter
|
|
/// allowlists whole directories by infix (`/dev/`, `/templates/`) and a few
|
|
/// root files by basename (`gemini-extension.json`). We map each asset to the
|
|
/// token the filter must mention: `/<top-dir>/` for a file in a subdirectory,
|
|
/// or the bare basename for a root-level file.
|
|
fn flake_covers(flake_nix: &str, asset: &str) -> bool {
|
|
match asset.split_once('/') {
|
|
Some((top, _)) => flake_nix.contains(&format!("/{top}/")),
|
|
None => flake_nix.contains(asset),
|
|
}
|
|
}
|
|
|
|
/// Run `cargo package --list` and collect the repo-relative paths it would
|
|
/// ship. This is the authoritative crates.io membership check — it does not
|
|
/// build, so it stays fast enough for every PR. `--allow-dirty` tolerates
|
|
/// local edits while iterating on a branch.
|
|
fn cargo_package_list(manifest_dir: &Path) -> BTreeSet<String> {
|
|
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
|
|
let output = Command::new(&cargo)
|
|
.args(["package", "--list", "-p", "worktrunk", "--allow-dirty"])
|
|
.current_dir(manifest_dir)
|
|
.output()
|
|
.expect("spawn cargo package --list");
|
|
assert!(
|
|
output.status.success(),
|
|
"`cargo package --list` failed:\n{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
String::from_utf8_lossy(&output.stdout)
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty())
|
|
.map(|line| line.to_string())
|
|
.collect()
|
|
}
|