1use std::path::{Path, PathBuf};
46
47const STAMP_FILE: &str = ".build-stamp";
49
50pub trait ComponentBuilder: Send + Sync {
58 fn build(&self, source_dir: &Path) -> Result<PathBuf, String>;
60}
61
62#[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 .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 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
119fn 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#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum BuildOutcome {
142 Cached { artifact: PathBuf },
144 Fresh { artifact: PathBuf },
146 StaleKept { artifact: PathBuf, error: String },
149 Failed { error: String },
151}
152
153impl BuildOutcome {
154 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 pub fn error(&self) -> Option<&str> {
168 match self {
169 BuildOutcome::StaleKept { error, .. } | BuildOutcome::Failed { error } => Some(error),
170 _ => None,
171 }
172 }
173}
174
175pub 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct Stamp {
246 pub abi: String,
248 pub source: String,
250}
251
252impl Stamp {
253 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 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 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
290pub 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
299pub 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 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
353fn 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
379fn 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
425fn 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
439fn 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
455fn 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 let manifest_src = source_dir.join("plugin.toml");
480 if manifest_src.is_file() {
481 let manifest_dst = dir.join("plugin.toml");
482 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 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 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 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 #[test]
586 fn staging_into_the_source_directory_does_not_empty_the_manifest() {
587 let root = tempdir("selfstage");
588 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 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 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 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 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 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 #[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 #[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 #[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 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 #[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 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 #[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 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 #[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 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 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 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 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 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}