Skip to main content

lattice_plugin_loader/
build.rs

1//! PM.5: the build service — a plugin source directory becomes a cached
2//! `.wasm` component.
3//!
4//! Design: [`plugin-manager.md`](../../../docs/dev/architecture/plugin-manager.md)
5//! §5. One primitive with two callers (§6): every user plugin, and the
6//! user's own `init.rs`.
7//!
8//! ## The requirement that shapes everything here
9//!
10//! **A warm boot with an unchanged source must not rebuild.** A cold
11//! component build is seconds to minutes; paying that on every start
12//! would make the editor unusable, so the service is a *cache* with a
13//! build fallback, not a build step with a cache in front. That is what
14//! the `.build-stamp` is for: it records what the artifact was built
15//! from and (WT.3) what it was built *against*, and a stamp matching on
16//! both short-circuits to a pure load.
17//!
18//! The second half is not symmetry for its own sake. A source that did
19//! not change, compiled against an ABI that did, looked current under a
20//! source-only stamp — so it was loaded, failed to instantiate, and
21//! said nothing at all. That is the whole of the failure
22//! `wit-ownership.md` was written for, and no amount of source
23//! fingerprinting can see it.
24//!
25//! ## Failure is a skip, never a stall
26//!
27//! Three failure modes, three different answers, and the middle one is
28//! the one worth naming:
29//!
30//! - **No artifact, build fails** → [`BuildOutcome::Failed`]. The plugin
31//!   does not load. Logged, surfaced in `:plugins`; boot continues.
32//! - **An artifact exists, a *stale* rebuild fails** →
33//!   [`BuildOutcome::StaleKept`]. The previous artifact keeps loading.
34//!   A user who pushes a broken revision to a plugin they depend on
35//!   should lose the *new* code, not the working editor they had five
36//!   minutes ago.
37//! - **Stamp matches** → [`BuildOutcome::Cached`]. No toolchain is
38//!   invoked at all, so a machine with no Rust installed still boots
39//!   every already-built plugin.
40//!
41//! Nothing in this module panics and nothing blocks: the caller runs
42//! [`build_plugin`] on `spawn_blocking` (paramount goal #1 / #4 — never
43//! the boot or actor thread).
44
45use std::path::{Path, PathBuf};
46
47/// The file recording what the cached artifact was built from.
48const STAMP_FILE: &str = ".build-stamp";
49
50/// Produces a `.wasm` component from a source directory.
51///
52/// A trait rather than a free function so the staleness, caching and
53/// failure logic below can be tested without a `wasm32-wasip2`
54/// toolchain — those are the parts with the interesting behaviour, and
55/// they should not be untestable on a machine that cannot compile a
56/// component.
57pub trait ComponentBuilder: Send + Sync {
58    /// Build `source_dir`; return the path of the produced component.
59    fn build(&self, source_dir: &Path) -> Result<PathBuf, String>;
60}
61
62/// The real builder: `cargo build --release --target wasm32-wasip2`.
63///
64/// Runs in a **clean environment**. Inherited workspace `RUSTFLAGS` /
65/// target / rustc-wrapper settings break a wasm build, which is the
66/// same lesson `lattice-plugin-host`'s `build.rs` and `cargo xtask
67/// build-core-plugins` both already encode — this is the third site, so
68/// the env-scrubbing list is deliberately identical to theirs.
69#[derive(Debug, Default, Clone, Copy)]
70pub struct CargoComponentBuilder;
71
72impl ComponentBuilder for CargoComponentBuilder {
73    fn build(&self, source_dir: &Path) -> Result<PathBuf, String> {
74        let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
75        let target_dir = source_dir.join("target");
76        let output = std::process::Command::new(&cargo)
77            .current_dir(source_dir)
78            .args(["build", "--release", "--target", "wasm32-wasip2"])
79            // Pin the target dir so a leaked `CARGO_TARGET_DIR` cannot
80            // redirect the output away from where we stage from.
81            .arg("--target-dir")
82            .arg(&target_dir)
83            .env_remove("CARGO_ENCODED_RUSTFLAGS")
84            .env_remove("RUSTFLAGS")
85            .env_remove("CARGO_BUILD_RUSTFLAGS")
86            .env_remove("CARGO_BUILD_TARGET")
87            .env_remove("CARGO_TARGET_DIR")
88            .env_remove("RUSTC")
89            .env_remove("RUSTC_WRAPPER")
90            .env_remove("RUSTC_WORKSPACE_WRAPPER")
91            .output()
92            .map_err(|e| format!("failed to run cargo: {e}"))?;
93        if !output.status.success() {
94            // The compiler's own diagnostics are the useful part; keep
95            // the tail so `:plugins` can show why without holding a
96            // whole build log in memory.
97            let stderr = String::from_utf8_lossy(&output.stderr);
98            let tail: String = stderr
99                .lines()
100                .rev()
101                .take(20)
102                .collect::<Vec<_>>()
103                .into_iter()
104                .rev()
105                .collect::<Vec<_>>()
106                .join("\n");
107            return Err(format!(
108                "cargo build failed ({}). Is the target installed? \
109                 `rustup target add wasm32-wasip2`\n{tail}",
110                output.status
111            ));
112        }
113        let release = target_dir.join("wasm32-wasip2").join("release");
114        find_component(&release)
115            .ok_or_else(|| format!("build produced no .wasm in {}", release.display()))
116    }
117}
118
119/// The single `.wasm` in `dir`, if there is exactly one.
120///
121/// "Exactly one" rather than "the first": a directory with two
122/// components is ambiguous, and silently picking one would stage an
123/// artifact the user did not mean to ship.
124fn find_component(dir: &Path) -> Option<PathBuf> {
125    let mut found: Option<PathBuf> = None;
126    for entry in std::fs::read_dir(dir).ok()?.flatten() {
127        let path = entry.path();
128        if path.extension().and_then(|e| e.to_str()) == Some("wasm") {
129            if found.is_some() {
130                tracing::warn!(dir = %dir.display(), "more than one .wasm; refusing to guess");
131                return None;
132            }
133            found = Some(path);
134        }
135    }
136    found
137}
138
139/// What [`build_plugin`] did.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum BuildOutcome {
142    /// The stamp matched; no toolchain was invoked.
143    Cached { artifact: PathBuf },
144    /// Built (or rebuilt) now.
145    Fresh { artifact: PathBuf },
146    /// A stale rebuild failed, but a previous artifact is still there
147    /// and still loads. The plugin runs old code; the error surfaces.
148    StaleKept { artifact: PathBuf, error: String },
149    /// No usable artifact. The plugin does not load.
150    Failed { error: String },
151}
152
153impl BuildOutcome {
154    /// The artifact to load, if any.
155    pub fn artifact(&self) -> Option<&Path> {
156        match self {
157            BuildOutcome::Cached { artifact }
158            | BuildOutcome::Fresh { artifact }
159            | BuildOutcome::StaleKept { artifact, .. } => Some(artifact),
160            BuildOutcome::Failed { .. } => None,
161        }
162    }
163
164    /// The error, if the build did not fully succeed. `StaleKept`
165    /// carries one *and* an artifact — a partial success is not a
166    /// silent one.
167    pub fn error(&self) -> Option<&str> {
168        match self {
169            BuildOutcome::StaleKept { error, .. } | BuildOutcome::Failed { error } => Some(error),
170            _ => None,
171        }
172    }
173}
174
175/// A fingerprint of `source_dir`'s contents, for staleness comparison.
176///
177/// Max mtime plus file count, walking the source tree and skipping
178/// `target/`, `.git/` and hidden entries. The file count is what makes
179/// a *deletion* register: mtimes only ever move forward, so a removed
180/// file would otherwise leave the stamp unchanged and the artifact
181/// wrongly considered current.
182///
183/// Not a content hash. Hashing every byte of a cargo project on every
184/// boot is real I/O for a check that runs before we know whether we
185/// even need to build, and mtime+count is what cargo itself trusts for
186/// the same job.
187pub fn source_stamp(source_dir: &Path) -> String {
188    let mut newest: u128 = 0;
189    let mut files: u64 = 0;
190    let mut stack = vec![source_dir.to_path_buf()];
191    while let Some(dir) = stack.pop() {
192        let entries = match std::fs::read_dir(&dir) {
193            Ok(e) => e,
194            // An unreadable subdirectory is not worth failing over; it
195            // just does not contribute to the fingerprint.
196            Err(e) => {
197                tracing::debug!(dir = %dir.display(), error = %e, "stamp: skipping unreadable dir");
198                continue;
199            }
200        };
201        for entry in entries.flatten() {
202            let name = entry.file_name();
203            let name = name.to_string_lossy();
204            // Build output and VCS metadata churn constantly and say
205            // nothing about the source; including them would make every
206            // boot look stale.
207            if name.starts_with('.') || name == "target" {
208                continue;
209            }
210            let path = entry.path();
211            match entry.file_type() {
212                Ok(ft) if ft.is_dir() => stack.push(path),
213                Ok(_) => {
214                    files += 1;
215                    if let Ok(meta) = entry.metadata()
216                        && let Ok(modified) = meta.modified()
217                        && let Ok(since) = modified.duration_since(std::time::UNIX_EPOCH)
218                    {
219                        newest = newest.max(since.as_nanos());
220                    }
221                }
222                Err(_) => {}
223            }
224        }
225    }
226    format!("mtime:{newest}:files:{files}")
227}
228
229/// WT.3: what a cached artifact was built **from** and **against**.
230///
231/// The stamp used to record only the source fingerprint, which left one case
232/// unrepresentable and therefore invisible: a source that did not change, built
233/// against an ABI that did. That artifact looked current, was loaded, failed to
234/// instantiate, and said nothing — which is the whole reported failure.
235///
236/// Rendered as two prefixed lines so it stays greppable by a human looking at a
237/// broken install, and so a later field can be added without another format
238/// break:
239///
240/// ```text
241/// abi:1c4e9f2a70b3d581
242/// source:mtime:1756...:files:12
243/// ```
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct Stamp {
246    /// The `lattice-wit` package fingerprint the component was compiled against.
247    pub abi: String,
248    /// The source-tree fingerprint from [`source_stamp`].
249    pub source: String,
250}
251
252impl Stamp {
253    /// The stamp for `source_dir` as built by *this* lattice, right now.
254    pub fn current(source_dir: &Path) -> Self {
255        Self {
256            abi: lattice_wit::ABI_FINGERPRINT.to_string(),
257            source: source_stamp(source_dir),
258        }
259    }
260
261    /// Parse a stamp file's contents.
262    ///
263    /// `None` for anything without both fields — which includes every stamp
264    /// written by a lattice predating this slice. That is deliberate and is the
265    /// conservative direction: an unparseable stamp makes no claim, so the
266    /// artifact is rebuilt rather than trusted. Treating a legacy stamp as a
267    /// match would keep exactly the artifacts most likely to be skewed.
268    pub fn parse(text: &str) -> Option<Self> {
269        let field = |key: &str| {
270            text.lines()
271                .find_map(|l| l.trim().strip_prefix(key))
272                .map(str::to_string)
273        };
274        Some(Self {
275            abi: field("abi:")?,
276            source: field("source:")?,
277        })
278    }
279
280    /// Whether this artifact is current for `other` — both halves must agree.
281    fn matches(&self, other: &Stamp) -> bool {
282        self.abi == other.abi && self.source == other.source
283    }
284
285    fn render(&self) -> String {
286        format!("abi:{}\nsource:{}\n", self.abi, self.source)
287    }
288}
289
290/// Where a plugin's cached artifact lives.
291pub fn artifact_path(user_root: &Path, name: &str) -> PathBuf {
292    user_root.join(name).join(format!("{name}.wasm"))
293}
294
295fn stamp_path(user_root: &Path, name: &str) -> PathBuf {
296    user_root.join(name).join(STAMP_FILE)
297}
298
299/// Build `name` from `source_dir` into `user_root`, unless the cached
300/// artifact is already current.
301///
302/// `pinned` skips the staleness check entirely: build only if the
303/// artifact is **absent**. That is the escape hatch for a user who
304/// wants a known-good build to stay put regardless of what the source
305/// tree does.
306///
307/// Blocking. Run it on `spawn_blocking` — never the boot or actor
308/// thread (§5).
309pub fn build_plugin(
310    builder: &dyn ComponentBuilder,
311    source_dir: &Path,
312    name: &str,
313    user_root: &Path,
314    pinned: bool,
315) -> BuildOutcome {
316    let artifact = artifact_path(user_root, name);
317    let has_artifact = artifact.is_file();
318
319    let cached_stamp = || {
320        std::fs::read_to_string(stamp_path(user_root, name))
321            .ok()
322            .as_deref()
323            .and_then(Stamp::parse)
324    };
325
326    // Pinned + present: never look at the source at all.
327    if pinned && has_artifact {
328        warn_if_abi_skewed(cached_stamp().as_ref(), name);
329        return BuildOutcome::Cached { artifact };
330    }
331
332    refresh_wit_package(source_dir);
333
334    let stamp = Stamp::current(source_dir);
335    if has_artifact && !pinned && cached_stamp().is_some_and(|cached| cached.matches(&stamp)) {
336        tracing::debug!(
337            plugin = name,
338            "build: stamp matches; loading cached artifact"
339        );
340        return BuildOutcome::Cached { artifact };
341    }
342
343    tracing::info!(plugin = name, "building plugin from source");
344    match builder.build(source_dir) {
345        Ok(produced) => match stage(&produced, source_dir, &artifact, user_root, name, &stamp) {
346            Ok(()) => BuildOutcome::Fresh { artifact },
347            Err(error) => fail(has_artifact, artifact, error, name),
348        },
349        Err(error) => fail(has_artifact, artifact, error, name),
350    }
351}
352
353/// WT.3: a pinned artifact built against a different ABI — say so, load anyway.
354///
355/// **The plan proposed refusing here, and refusing is wrong.** The fingerprint
356/// hashes the whole package, so it moves when *any* file changes — including
357/// files the plugin never imports. A mismatch therefore means "this may not
358/// load", not "this cannot load", and refusing on it would stop plugins that
359/// work perfectly well. A pin exists precisely to say *keep this build*; the
360/// honest response to a coarse signal is to load it and put the skew on record.
361///
362/// `warn!` rather than `debug!` because it is one-shot and user-actionable: the
363/// answer is `:plugin-unpin` and a rebuild. If the component then fails to
364/// instantiate, WT.4 names that failure and this line is already there to
365/// explain it.
366fn warn_if_abi_skewed(stamped: Option<&Stamp>, name: &str) {
367    let Some(stamped) = stamped else { return };
368    if stamped.abi != lattice_wit::ABI_FINGERPRINT {
369        tracing::warn!(
370            plugin = name,
371            built_against = %stamped.abi,
372            this_editor = %lattice_wit::ABI_FINGERPRINT,
373            "pinned artifact was built against a different plugin ABI; \
374             loading it anyway — unpin to rebuild if it fails to load"
375        );
376    }
377}
378
379/// WT.2b: write the canonical `wit/` package into the source before cargo runs.
380///
381/// **This is where the plugin API stops being a folder someone remembered to
382/// copy.** `wit_bindgen::generate!` resolves its `path` at macro expansion, so
383/// the files must be on disk beside the crate — a build-time need, which the
384/// build is what should meet. Until this existed the copy was made once at
385/// scaffold time and never again: three ABI changes in one day left an
386/// `init.wasm` and the plugin it `require`d both unloadable, with no message
387/// anywhere.
388///
389/// Doing it *here* rather than in a dependency the scaffold declares is what
390/// makes the coupling exact. This is not the `lattice` binary that happens to be
391/// on `PATH` — it is the process that is about to instantiate the component it
392/// is compiling. `wit-ownership.md` §3(b) rejected an export path because it
393/// "ties the plugin's ABI to whichever lattice is on PATH"; that objection does
394/// not reach a refresh performed by the loader itself.
395///
396/// **Before the staleness check, and content-preserving.** `write_to` leaves a
397/// file already holding the right bytes untouched, so a warm boot moves no
398/// mtime and the stamp still matches — the cache survives. When the ABI has
399/// moved the rewrite does bump a mtime, which makes the source read as edited;
400/// that is a welcome side effect but it is not the mechanism relied upon, since
401/// it turns on filesystem timestamp resolution. WT.3's explicit ABI fingerprint
402/// in the stamp is the durable detector, and it also covers the case no mtime
403/// can reach: a prebuilt artifact with no source to rebuild from.
404///
405/// A source built out-of-tree may also carry a `lattice-wit` build-dependency
406/// doing the same write. That one wins, because `build.rs` runs after this — a
407/// pin the repo declares should override the ambient refresh, and WT.3's
408/// fingerprint is what makes the resulting mismatch legible rather than silent.
409///
410/// Failure is logged and skipped, never fatal: whatever `wit/` is already there
411/// may well be fine, and refusing to build a plugin because a cache refresh
412/// failed would turn a recoverable condition into a missing feature.
413fn refresh_wit_package(source_dir: &Path) {
414    let dir = source_dir.join("wit");
415    match lattice_wit::write_to(&dir) {
416        Ok(()) => tracing::debug!(dir = %dir.display(), "wit package refreshed"),
417        Err(error) => tracing::warn!(
418            dir = %dir.display(),
419            %error,
420            "could not refresh the wit package; building against whatever is there"
421        ),
422    }
423}
424
425/// Whether two paths name the same file on disk.
426///
427/// Compared after canonicalisation rather than as strings: the same directory
428/// reached as itself and as `<parent>/<name>` is one directory spelled two
429/// ways, which is precisely the init dir's case. A path that cannot be
430/// canonicalised (the destination does not exist yet — the common staging
431/// case) is not the source, so the copy proceeds.
432fn is_same_file(a: &Path, b: &Path) -> bool {
433    match (a.canonicalize(), b.canonicalize()) {
434        (Ok(a), Ok(b)) => a == b,
435        _ => false,
436    }
437}
438
439/// Turn a build/stage error into the right outcome: keep a previous
440/// artifact when one exists, otherwise report a hard failure.
441fn fail(has_artifact: bool, artifact: PathBuf, error: String, name: &str) -> BuildOutcome {
442    if has_artifact {
443        tracing::warn!(
444            plugin = name,
445            %error,
446            "rebuild failed; keeping the previously built artifact"
447        );
448        BuildOutcome::StaleKept { artifact, error }
449    } else {
450        tracing::warn!(plugin = name, %error, "build failed; plugin will not load");
451        BuildOutcome::Failed { error }
452    }
453}
454
455/// Copy the built component and the source's manifest into the user
456/// root, then write the stamp.
457///
458/// The stamp is written **last, and only on full success**. A stamp
459/// written before the copy would mark a half-staged plugin as current
460/// and suppress the rebuild that would fix it — the artifact and the
461/// claim about it have to land in that order.
462fn stage(
463    produced: &Path,
464    source_dir: &Path,
465    artifact: &Path,
466    user_root: &Path,
467    name: &str,
468    stamp: &Stamp,
469) -> Result<(), String> {
470    let dir = user_root.join(name);
471    std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
472    if !is_same_file(produced, artifact) {
473        std::fs::copy(produced, artifact)
474            .map_err(|e| format!("stage {} → {}: {e}", produced.display(), artifact.display()))?;
475    }
476    // The manifest travels with the artifact: discovery reads both out
477    // of the user root, and a staged `.wasm` with no `plugin.toml`
478    // beside it is invisible to it.
479    let manifest_src = source_dir.join("plugin.toml");
480    if manifest_src.is_file() {
481        let manifest_dst = dir.join("plugin.toml");
482        // **A source that IS its own staging dir must not be copied over.**
483        // `init.rs` is exactly that: `build_init_if_needed` passes
484        // `user_root = <config>/lattice` and `name = "init"`, so
485        // `user_root.join(name)` is the init dir the source lives in, and
486        // `manifest_src == manifest_dst`.
487        //
488        // `fs::copy(p, p)` does not no-op — it opens the destination with
489        // `O_TRUNC` before reading the source, then reports `Ok(0)`. The
490        // manifest is left EMPTY and the build reports success. Next boot the
491        // empty manifest has no `id`, so init.rs fails to load, and because
492        // that failure is a `debug!` the user sees an editor with no config
493        // and no message. Whatever init.rs `require`d never installs either.
494        if !is_same_file(&manifest_src, &manifest_dst) {
495            std::fs::copy(&manifest_src, &manifest_dst)
496                .map_err(|e| format!("stage manifest → {}: {e}", manifest_dst.display()))?;
497        }
498    } else {
499        return Err(format!(
500            "source has no plugin.toml at {}",
501            manifest_src.display()
502        ));
503    }
504    std::fs::write(stamp_path(user_root, name), stamp.render())
505        .map_err(|e| format!("write build stamp: {e}"))?;
506    Ok(())
507}
508
509#[cfg(test)]
510mod tests {
511    #![allow(clippy::unwrap_used, clippy::panic)]
512    use super::*;
513    use std::sync::atomic::{AtomicUsize, Ordering};
514
515    /// A builder that records how many times it ran and writes a stub
516    /// component. Standing in for the toolchain is the whole point —
517    /// the behaviour under test is the caching, not cargo.
518    struct FakeBuilder {
519        calls: AtomicUsize,
520        fail: bool,
521    }
522
523    impl FakeBuilder {
524        fn ok() -> Self {
525            Self {
526                calls: AtomicUsize::new(0),
527                fail: false,
528            }
529        }
530        fn failing() -> Self {
531            Self {
532                calls: AtomicUsize::new(0),
533                fail: true,
534            }
535        }
536        fn calls(&self) -> usize {
537            self.calls.load(Ordering::SeqCst)
538        }
539    }
540
541    impl ComponentBuilder for FakeBuilder {
542        fn build(&self, source_dir: &Path) -> Result<PathBuf, String> {
543            self.calls.fetch_add(1, Ordering::SeqCst);
544            if self.fail {
545                return Err("compile error".into());
546            }
547            // Write OUTSIDE the source tree. Real cargo emits into
548            // `target/`, which the stamp excludes; a fake that dirtied
549            // the source would make every build look stale and quietly
550            // invert the test it appears in.
551            let out = source_dir
552                .parent()
553                .unwrap_or(source_dir)
554                .join("fake-build-out.wasm");
555            std::fs::write(&out, b"\0asm-stub").unwrap();
556            Ok(out)
557        }
558    }
559
560    static COUNTER: AtomicUsize = AtomicUsize::new(0);
561
562    /// Unique temp dir. The counter is load-bearing under parallel
563    /// `cargo test`: a timestamp alone collides.
564    fn tempdir(tag: &str) -> PathBuf {
565        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
566        let pid = std::process::id();
567        let dir = std::env::temp_dir().join(format!("lattice-pm5-{tag}-{pid}-{n}"));
568        let _ = std::fs::remove_dir_all(&dir);
569        std::fs::create_dir_all(&dir).unwrap();
570        dir
571    }
572
573    /// **The init directory's shape: the source IS the staging destination.**
574    ///
575    /// `build_init_if_needed` passes `user_root = <config>/lattice` and
576    /// `name = "init"`, so `user_root.join(name)` is the very directory the
577    /// source lives in. `fs::copy(p, p)` truncates — it opens the destination
578    /// with `O_TRUNC` before reading the source and then reports `Ok(0)` — so
579    /// staging emptied the user's own `plugin.toml` and called it a success.
580    ///
581    /// The cost was invisible and total: next boot the empty manifest has no
582    /// `id`, init.rs fails to load behind a `debug!`, and everything it
583    /// `require`d never installs. Found on a real machine whose
584    /// `~/.config/lattice/init/plugin.toml` was 0 bytes.
585    #[test]
586    fn staging_into_the_source_directory_does_not_empty_the_manifest() {
587        let root = tempdir("selfstage");
588        // The layout `build_init_if_needed` produces.
589        let user_root = root.join("lattice");
590        let init_dir = user_root.join("init");
591        std::fs::create_dir_all(init_dir.join("src")).unwrap();
592        let manifest = "id = \"init\"\nprovides = [\"modes\"]\n";
593        std::fs::write(init_dir.join("plugin.toml"), manifest).unwrap();
594        std::fs::write(init_dir.join("src").join("lib.rs"), "// init").unwrap();
595
596        let builder = FakeBuilder::ok();
597        let outcome = build_plugin(&builder, &init_dir, "init", &user_root, false);
598
599        assert!(
600            matches!(outcome, BuildOutcome::Fresh { .. }),
601            "the build still succeeds: {outcome:?}"
602        );
603        assert_eq!(
604            std::fs::read_to_string(init_dir.join("plugin.toml")).unwrap(),
605            manifest,
606            "the manifest survives staging into its own directory"
607        );
608        assert!(
609            artifact_path(&user_root, "init").is_file(),
610            "and the artifact still lands"
611        );
612    }
613
614    /// A minimal plugin source: a manifest and one source file.
615    fn source(dir: &Path) -> PathBuf {
616        let src = dir.join("src-tree");
617        std::fs::create_dir_all(src.join("src")).unwrap();
618        std::fs::write(src.join("plugin.toml"), "id = \"demo\"\n").unwrap();
619        std::fs::write(src.join("src").join("lib.rs"), "// v1").unwrap();
620        src
621    }
622
623    #[test]
624    fn a_first_build_produces_and_stages_the_artifact() {
625        let root = tempdir("first");
626        let src = source(&root);
627        let user = root.join("user");
628        let b = FakeBuilder::ok();
629
630        let out = build_plugin(&b, &src, "demo", &user, false);
631        let artifact = user.join("demo").join("demo.wasm");
632        assert_eq!(
633            out,
634            BuildOutcome::Fresh {
635                artifact: artifact.clone()
636            }
637        );
638        assert!(artifact.is_file(), "the component is staged");
639        assert!(
640            user.join("demo").join("plugin.toml").is_file(),
641            "the manifest travels with it, or discovery cannot see the plugin"
642        );
643        assert_eq!(b.calls(), 1);
644    }
645
646    #[test]
647    fn an_unchanged_source_does_not_rebuild() {
648        // The requirement the whole module exists for: a warm boot is a
649        // pure load.
650        let root = tempdir("warm");
651        let src = source(&root);
652        let user = root.join("user");
653        let b = FakeBuilder::ok();
654
655        build_plugin(&b, &src, "demo", &user, false);
656        let second = build_plugin(&b, &src, "demo", &user, false);
657
658        assert!(matches!(second, BuildOutcome::Cached { .. }));
659        assert_eq!(b.calls(), 1, "the toolchain must not be invoked again");
660    }
661
662    #[test]
663    fn an_edited_source_rebuilds() {
664        let root = tempdir("edit");
665        let src = source(&root);
666        let user = root.join("user");
667        let b = FakeBuilder::ok();
668
669        build_plugin(&b, &src, "demo", &user, false);
670        // Move the mtime forward decisively — a same-nanosecond write
671        // would make this test about clock resolution instead of about
672        // staleness.
673        let f = src.join("src").join("lib.rs");
674        std::fs::write(&f, "// v2").unwrap();
675        let later = std::time::SystemTime::now() + std::time::Duration::from_secs(120);
676        let _ = filetime_set(&f, later);
677
678        let second = build_plugin(&b, &src, "demo", &user, false);
679        assert!(matches!(second, BuildOutcome::Fresh { .. }));
680        assert_eq!(b.calls(), 2);
681    }
682
683    #[test]
684    fn a_deleted_source_file_registers_as_stale() {
685        // mtimes only move forward, so a deletion is invisible to a
686        // max-mtime stamp on its own. The file count is what catches it.
687        let root = tempdir("delete");
688        let src = source(&root);
689        std::fs::write(src.join("src").join("extra.rs"), "// x").unwrap();
690        let user = root.join("user");
691        let b = FakeBuilder::ok();
692
693        build_plugin(&b, &src, "demo", &user, false);
694        std::fs::remove_file(src.join("src").join("extra.rs")).unwrap();
695        let second = build_plugin(&b, &src, "demo", &user, false);
696
697        assert!(matches!(second, BuildOutcome::Fresh { .. }));
698        assert_eq!(b.calls(), 2);
699    }
700
701    #[test]
702    fn target_and_dotdirs_do_not_make_a_source_look_stale() {
703        // Build output churns on every build; if it counted, nothing
704        // would ever be cached.
705        let root = tempdir("ignore");
706        let src = source(&root);
707        let user = root.join("user");
708        let b = FakeBuilder::ok();
709        build_plugin(&b, &src, "demo", &user, false);
710
711        std::fs::create_dir_all(src.join("target").join("deep")).unwrap();
712        std::fs::write(src.join("target").join("deep").join("x.rlib"), "junk").unwrap();
713        std::fs::create_dir_all(src.join(".git")).unwrap();
714        std::fs::write(src.join(".git").join("HEAD"), "ref").unwrap();
715
716        let second = build_plugin(&b, &src, "demo", &user, false);
717        assert!(matches!(second, BuildOutcome::Cached { .. }));
718        assert_eq!(b.calls(), 1);
719    }
720
721    /// WT.2b: the source is compiled against the API of the process compiling
722    /// it. A source with no `wit/` at all — a fresh clone of a repo that
723    /// gitignores it, which is the shape WT.2 gave org — becomes buildable.
724    #[test]
725    fn the_build_writes_the_wit_package_into_the_source() {
726        let root = tempdir("witwrite");
727        let src = source(&root);
728        let user = root.join("user");
729        let b = FakeBuilder::ok();
730
731        assert!(!src.join("wit").exists(), "no wit/ before the build");
732        build_plugin(&b, &src, "demo", &user, false);
733
734        for name in lattice_wit::file_names() {
735            assert!(
736                src.join("wit").join(name).is_file(),
737                "the package landed: wit/{name}"
738            );
739        }
740    }
741
742    /// A copy that drifted is repaired rather than believed. This is the
743    /// original defect in miniature: the plugin's `wit/` was a fork nothing
744    /// updated, so it compiled against an ABI the host no longer served.
745    #[test]
746    fn a_drifted_wit_file_is_repaired_before_the_build() {
747        let root = tempdir("witdrift");
748        let src = source(&root);
749        let user = root.join("user");
750        let b = FakeBuilder::ok();
751        build_plugin(&b, &src, "demo", &user, false);
752
753        let probe = src.join("wit").join("types.wit");
754        std::fs::write(&probe, "// a fork from three ABI changes ago\n").unwrap();
755
756        build_plugin(&b, &src, "demo", &user, false);
757        let repaired = std::fs::read_to_string(&probe).unwrap();
758        assert!(
759            !repaired.contains("three ABI changes ago"),
760            "the stale copy is overwritten from the canonical package"
761        );
762    }
763
764    /// **The refresh must not become a rebuild trigger.** It runs before the
765    /// staleness check and `wit/` counts toward the source stamp, so an
766    /// unconditional write would move a mtime forward every boot, make every
767    /// source read as edited, and rebuild every plugin from cold on every
768    /// start — the exact opposite of what the cache exists for. The property
769    /// that prevents it lives in `lattice_wit::write_to`; this is the test that
770    /// would catch its loss from the side that pays for it.
771    #[test]
772    fn refreshing_the_wit_package_does_not_invalidate_the_cache() {
773        let root = tempdir("witwarm");
774        let src = source(&root);
775        let user = root.join("user");
776        let b = FakeBuilder::ok();
777
778        build_plugin(&b, &src, "demo", &user, false);
779        std::thread::sleep(std::time::Duration::from_millis(20));
780        let second = build_plugin(&b, &src, "demo", &user, false);
781
782        assert!(matches!(second, BuildOutcome::Cached { .. }));
783        assert_eq!(
784            b.calls(),
785            1,
786            "a warm boot with an untouched source stays a pure load"
787        );
788    }
789
790    /// Rewrite the ABI half of a staged stamp, standing in for the editor's
791    /// `wit/` package having moved under an artifact that was already built.
792    /// The real change is a new lattice binary, which a unit test cannot have.
793    fn forge_abi(user_root: &Path, name: &str, abi: &str) {
794        let path = user_root.join(name).join(".build-stamp");
795        let text = std::fs::read_to_string(&path).unwrap();
796        let stamp = Stamp::parse(&text).unwrap();
797        std::fs::write(
798            &path,
799            Stamp {
800                abi: abi.to_string(),
801                source: stamp.source,
802            }
803            .render(),
804        )
805        .unwrap();
806    }
807
808    /// **WT.3, the case that was unrepresentable.** A source nobody touched,
809    /// built against an ABI that has since moved, used to look `Cached`: it was
810    /// loaded, failed to instantiate, and said nothing. That is the entire
811    /// reported failure, and the source fingerprint alone cannot see it.
812    #[test]
813    fn an_artifact_built_against_another_abi_is_stale() {
814        let root = tempdir("abistale");
815        let src = source(&root);
816        let user = root.join("user");
817        let b = FakeBuilder::ok();
818
819        build_plugin(&b, &src, "demo", &user, false);
820        forge_abi(&user, "demo", "0000deadbeef0000");
821
822        let second = build_plugin(&b, &src, "demo", &user, false);
823        assert!(
824            matches!(second, BuildOutcome::Fresh { .. }),
825            "the untouched source is rebuilt against the current ABI: {second:?}"
826        );
827        assert_eq!(b.calls(), 2);
828
829        // And the rebuild records the ABI it actually built against, or the
830        // next boot would rebuild all over again.
831        let text = std::fs::read_to_string(user.join("demo").join(".build-stamp")).unwrap();
832        assert_eq!(
833            Stamp::parse(&text).unwrap().abi,
834            lattice_wit::ABI_FINGERPRINT
835        );
836        assert!(matches!(
837            build_plugin(&b, &src, "demo", &user, false),
838            BuildOutcome::Cached { .. }
839        ));
840        assert_eq!(b.calls(), 2, "and settles back to a pure load");
841    }
842
843    /// A stamp written by a lattice predating the ABI field cannot say what its
844    /// artifact was built against. It must not therefore be read as agreement —
845    /// `Cached` is precisely the false reassurance that hid the failure, and the
846    /// artifacts carrying legacy stamps are the ones most likely to be skewed.
847    #[test]
848    fn a_legacy_stamp_does_not_support_a_cached_claim() {
849        let root = tempdir("legacy");
850        let src = source(&root);
851        let user = root.join("user");
852        let b = FakeBuilder::ok();
853
854        build_plugin(&b, &src, "demo", &user, false);
855        // The pre-WT.3 format: the bare source fingerprint, no `abi:` line.
856        let stamp_file = user.join("demo").join(".build-stamp");
857        std::fs::write(&stamp_file, source_stamp(&src)).unwrap();
858
859        let second = build_plugin(&b, &src, "demo", &user, false);
860        assert!(matches!(second, BuildOutcome::Fresh { .. }));
861        assert_eq!(b.calls(), 2, "rebuilt rather than trusted");
862    }
863
864    /// **A pin means keep this build, and the fingerprint is a coarse signal.**
865    /// It hashes the whole package, so it moves when a file the plugin never
866    /// imports changes — a mismatch means "may not load", not "cannot". The
867    /// plan proposed refusing here; refusing would stop plugins that work. The
868    /// skew is warned about and the artifact still loads.
869    #[test]
870    fn a_pinned_artifact_with_a_skewed_abi_still_loads() {
871        let root = tempdir("pinskew");
872        let src = source(&root);
873        let user = root.join("user");
874        let b = FakeBuilder::ok();
875
876        build_plugin(&b, &src, "demo", &user, false);
877        forge_abi(&user, "demo", "0000deadbeef0000");
878
879        let second = build_plugin(&b, &src, "demo", &user, true);
880        assert!(
881            matches!(second, BuildOutcome::Cached { .. }),
882            "the pin is honoured: {second:?}"
883        );
884        assert_eq!(b.calls(), 1, "and no rebuild is forced behind the pin");
885    }
886
887    #[test]
888    fn a_stamp_round_trips_and_a_legacy_one_does_not_parse() {
889        let stamp = Stamp {
890            abi: "1c4e9f2a70b3d581".into(),
891            source: "mtime:42:files:3".into(),
892        };
893        assert_eq!(Stamp::parse(&stamp.render()).unwrap(), stamp);
894        // The pre-WT.3 format, and a truncated write.
895        assert!(Stamp::parse("mtime:42:files:3").is_none());
896        assert!(Stamp::parse("abi:1c4e9f2a70b3d581").is_none());
897        assert!(Stamp::parse("").is_none());
898    }
899
900    #[test]
901    fn a_failed_first_build_yields_no_artifact() {
902        let root = tempdir("fail");
903        let src = source(&root);
904        let user = root.join("user");
905        let b = FakeBuilder::failing();
906
907        let out = build_plugin(&b, &src, "demo", &user, false);
908        assert!(matches!(out, BuildOutcome::Failed { .. }));
909        assert_eq!(out.artifact(), None);
910        assert!(out.error().unwrap().contains("compile error"));
911    }
912
913    #[test]
914    fn a_failed_rebuild_keeps_the_previous_artifact_loading() {
915        // The behaviour worth protecting: pushing a broken revision to a
916        // plugin costs you the new code, not the editor you had.
917        let root = tempdir("stale-keep");
918        let src = source(&root);
919        let user = root.join("user");
920        build_plugin(&FakeBuilder::ok(), &src, "demo", &user, false);
921
922        let f = src.join("src").join("lib.rs");
923        std::fs::write(&f, "// broken").unwrap();
924        let _ = filetime_set(
925            &f,
926            std::time::SystemTime::now() + std::time::Duration::from_secs(120),
927        );
928
929        let out = build_plugin(&FakeBuilder::failing(), &src, "demo", &user, false);
930        match out {
931            BuildOutcome::StaleKept { artifact, error } => {
932                assert!(artifact.is_file(), "the old artifact is still there");
933                assert!(
934                    error.contains("compile error"),
935                    "and the failure is not silent"
936                );
937            }
938            other => panic!("expected StaleKept, got {other:?}"),
939        }
940    }
941
942    #[test]
943    fn a_pinned_plugin_ignores_source_changes() {
944        let root = tempdir("pinned");
945        let src = source(&root);
946        let user = root.join("user");
947        let b = FakeBuilder::ok();
948        build_plugin(&b, &src, "demo", &user, false);
949
950        let f = src.join("src").join("lib.rs");
951        std::fs::write(&f, "// v2").unwrap();
952        let _ = filetime_set(
953            &f,
954            std::time::SystemTime::now() + std::time::Duration::from_secs(120),
955        );
956
957        let out = build_plugin(&b, &src, "demo", &user, true);
958        assert!(matches!(out, BuildOutcome::Cached { .. }));
959        assert_eq!(b.calls(), 1, "pinned means do not look at the source");
960    }
961
962    #[test]
963    fn a_pinned_plugin_with_no_artifact_still_builds() {
964        // Pinned means "don't rebuild", not "never build" — otherwise a
965        // pinned plugin could never be installed in the first place.
966        let root = tempdir("pinned-cold");
967        let src = source(&root);
968        let user = root.join("user");
969        let b = FakeBuilder::ok();
970
971        let out = build_plugin(&b, &src, "demo", &user, true);
972        assert!(matches!(out, BuildOutcome::Fresh { .. }));
973        assert_eq!(b.calls(), 1);
974    }
975
976    #[test]
977    fn a_source_without_a_manifest_fails_rather_than_staging_half_a_plugin() {
978        let root = tempdir("nomanifest");
979        let src = root.join("bare");
980        std::fs::create_dir_all(&src).unwrap();
981        std::fs::write(src.join("lib.rs"), "// x").unwrap();
982        let user = root.join("user");
983
984        let out = build_plugin(&FakeBuilder::ok(), &src, "demo", &user, false);
985        assert!(matches!(out, BuildOutcome::Failed { .. }));
986        assert!(out.error().unwrap().contains("plugin.toml"));
987        assert!(
988            !stamp_path(&user, "demo").exists(),
989            "no stamp may be written for a build that did not fully stage"
990        );
991    }
992
993    #[test]
994    fn a_half_staged_plugin_rebuilds_rather_than_reporting_cached() {
995        // Guards the stamp-written-last ordering: if a stamp could
996        // outlive its artifact, the rebuild that would fix the install
997        // would be suppressed forever.
998        let root = tempdir("halfstage");
999        let src = source(&root);
1000        let user = root.join("user");
1001        let b = FakeBuilder::ok();
1002        build_plugin(&b, &src, "demo", &user, false);
1003
1004        std::fs::remove_file(artifact_path(&user, "demo")).unwrap();
1005        let out = build_plugin(&b, &src, "demo", &user, false);
1006
1007        assert!(matches!(out, BuildOutcome::Fresh { .. }));
1008        assert_eq!(b.calls(), 2);
1009    }
1010
1011    #[test]
1012    fn stamps_differ_between_different_sources() {
1013        let root = tempdir("stamps");
1014        let a = source(&root);
1015        let b = root.join("other");
1016        std::fs::create_dir_all(&b).unwrap();
1017        std::fs::write(b.join("plugin.toml"), "id = \"other\"\n").unwrap();
1018        assert_ne!(source_stamp(&a), source_stamp(&b));
1019    }
1020
1021    #[test]
1022    fn stamping_a_missing_dir_is_not_a_panic() {
1023        let stamp = source_stamp(Path::new("/definitely/not/here/lattice-pm5"));
1024        assert_eq!(stamp, "mtime:0:files:0");
1025    }
1026
1027    /// Set a file's mtime.
1028    ///
1029    /// `std::fs::File::set_times` rather than shelling out to `touch`:
1030    /// the `-d @epoch` form is GNU-only and macOS rejects it, which made
1031    /// the first cut of these tests depend on a silent fallback that
1032    /// merely happened to move the clock far enough.
1033    fn filetime_set(path: &Path, when: std::time::SystemTime) -> std::io::Result<()> {
1034        let f = std::fs::OpenOptions::new().write(true).open(path)?;
1035        f.set_times(std::fs::FileTimes::new().set_modified(when))
1036    }
1037}