1use std::path::{Path, PathBuf};
35
36#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum PluginSource {
40 Local(PathBuf),
42 Git { url: String, rev: Option<String> },
44 Prebuilt { url: String },
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum RefreshPolicy {
63 #[default]
66 UseCache,
67 Update,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum Resolved {
74 Source(PathBuf),
76 Artifact(PathBuf),
78}
79
80pub trait Fetcher: Send + Sync {
88 fn fetch(&self, url: &str, dest: &Path) -> Result<(), String>;
90}
91
92#[derive(Debug, Default, Clone, Copy)]
94pub struct HttpFetcher;
95
96impl Fetcher for HttpFetcher {
97 fn fetch(&self, url: &str, dest: &Path) -> Result<(), String> {
98 let mut response = ureq::get(url)
99 .call()
100 .map_err(|e| format!("GET {url}: {e}"))?;
101 let mut body = response.body_mut().as_reader();
102 if let Some(parent) = dest.parent() {
103 std::fs::create_dir_all(parent)
104 .map_err(|e| format!("create {}: {e}", parent.display()))?;
105 }
106 let tmp = dest.with_extension("wasm.part");
110 let mut file =
111 std::fs::File::create(&tmp).map_err(|e| format!("create {}: {e}", tmp.display()))?;
112 std::io::copy(&mut body, &mut file).map_err(|e| format!("download {url}: {e}"))?;
113 drop(file);
114 std::fs::rename(&tmp, dest).map_err(|e| format!("finalise {}: {e}", dest.display()))?;
115 Ok(())
116 }
117}
118
119pub trait GitRunner: Send + Sync {
123 fn run(&self, cwd: &Path, args: &[&str]) -> Result<String, String>;
125}
126
127#[derive(Debug, Default, Clone, Copy)]
129pub struct SystemGit;
130
131impl GitRunner for SystemGit {
132 fn run(&self, cwd: &Path, args: &[&str]) -> Result<String, String> {
133 let output = std::process::Command::new("git")
134 .current_dir(cwd)
135 .args(args)
136 .output()
137 .map_err(|e| format!("failed to run git: {e}. Is git installed?"))?;
138 if !output.status.success() {
139 let stderr = String::from_utf8_lossy(&output.stderr);
140 return Err(format!(
141 "git {} failed ({}): {}",
142 args.join(" "),
143 output.status,
144 stderr.trim()
145 ));
146 }
147 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
148 }
149}
150
151pub fn git_cache_dir(cache_root: &Path, name: &str) -> PathBuf {
153 cache_root.join(name)
154}
155
156pub fn resolve(
168 git: &dyn GitRunner,
169 fetcher: &dyn Fetcher,
170 source: &PluginSource,
171 name: &str,
172 cache_root: &Path,
173 user_root: &Path,
174 policy: RefreshPolicy,
175) -> Result<Resolved, String> {
176 match source {
177 PluginSource::Local(path) => {
178 if !path.is_dir() {
179 return Err(format!(
180 "local source is not a directory: {}",
181 path.display()
182 ));
183 }
184 Ok(Resolved::Source(path.clone()))
185 }
186 PluginSource::Git { url, rev } => {
187 let dir = resolve_git(git, url, rev.as_deref(), name, cache_root, policy)?;
188 Ok(Resolved::Source(dir))
189 }
190 PluginSource::Prebuilt { url } => {
191 let artifact = resolve_prebuilt(fetcher, url, name, user_root)?;
192 Ok(Resolved::Artifact(artifact))
193 }
194 }
195}
196
197fn resolve_git(
224 git: &dyn GitRunner,
225 url: &str,
226 rev: Option<&str>,
227 name: &str,
228 cache_root: &Path,
229 policy: RefreshPolicy,
230) -> Result<PathBuf, String> {
231 let dir = git_cache_dir(cache_root, name);
232 if !dir.join(".git").is_dir() {
233 clone_git(git, url, rev, &dir)?;
234 if let Some(rev) = rev {
235 git.run(&dir, &["checkout", "--detach", rev])?;
236 }
237 return Ok(dir);
238 }
239
240 match rev {
241 Some(rev) => {
244 if git.run(&dir, &["rev-parse", "HEAD"]).ok().as_deref() == Some(rev) {
245 tracing::debug!(plugin = name, rev, "git: already at the requested rev");
246 return Ok(dir);
247 }
248 git.run(&dir, &["fetch", "--tags", "origin"])?;
249 git.run(&dir, &["checkout", "--detach", rev])?;
250 }
251 None => {
254 if policy == RefreshPolicy::Update {
255 git.run(&dir, &["fetch", "--tags", "origin"])?;
256 git.run(&dir, &["reset", "--hard", "FETCH_HEAD"])?;
257 } else {
258 tracing::debug!(plugin = name, "git: using the cached checkout");
259 }
260 }
261 }
262 Ok(dir)
263}
264
265fn clone_git(git: &dyn GitRunner, url: &str, rev: Option<&str>, dir: &Path) -> Result<(), String> {
271 let parent = dir
272 .parent()
273 .ok_or_else(|| format!("bad cache path {}", dir.display()))?;
274 std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
275 let dir_str = dir.to_string_lossy().to_string();
276 let mut args = vec!["clone"];
277 if rev.is_none() {
278 args.extend(["--depth", "1"]);
279 }
280 args.extend([url, dir_str.as_str()]);
281 git.run(parent, &args)?;
282 Ok(())
283}
284
285fn resolve_prebuilt(
298 fetcher: &dyn Fetcher,
299 url: &str,
300 name: &str,
301 user_root: &Path,
302) -> Result<PathBuf, String> {
303 let dir = user_root.join(name);
304 std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
305 let artifact = dir.join(format!("{name}.wasm"));
306 fetcher.fetch(url, &artifact)?;
307 let manifest = dir.join("plugin.toml");
308 if !manifest.exists() {
309 std::fs::write(&manifest, format!("id = \"{name}\"\n"))
310 .map_err(|e| format!("write {}: {e}", manifest.display()))?;
311 }
312 Ok(artifact)
313}
314
315#[cfg(test)]
316mod tests {
317 #![allow(clippy::unwrap_used, clippy::panic)]
318 use super::*;
319 use std::sync::Mutex;
320 use std::sync::atomic::{AtomicUsize, Ordering};
321
322 static COUNTER: AtomicUsize = AtomicUsize::new(0);
323
324 fn tempdir(tag: &str) -> PathBuf {
325 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
326 let dir =
327 std::env::temp_dir().join(format!("lattice-pm6-{tag}-{}-{n}", std::process::id()));
328 let _ = std::fs::remove_dir_all(&dir);
329 std::fs::create_dir_all(&dir).unwrap();
330 dir
331 }
332
333 #[derive(Default)]
337 struct FakeGit {
338 calls: Mutex<Vec<String>>,
339 head: Mutex<Option<String>>,
340 clone_makes_repo: bool,
341 }
342
343 impl FakeGit {
344 fn calls(&self) -> Vec<String> {
345 self.calls.lock().unwrap().clone()
346 }
347 }
348
349 impl GitRunner for FakeGit {
350 fn run(&self, cwd: &Path, args: &[&str]) -> Result<String, String> {
351 self.calls.lock().unwrap().push(args.join(" "));
352 if args[0] == "clone" && self.clone_makes_repo {
353 let dest = PathBuf::from(args[args.len() - 1]);
354 std::fs::create_dir_all(dest.join(".git")).unwrap();
355 let _ = cwd;
356 }
357 if args[0] == "rev-parse" {
358 return self
359 .head
360 .lock()
361 .unwrap()
362 .clone()
363 .ok_or_else(|| "no head".to_string());
364 }
365 Ok(String::new())
366 }
367 }
368
369 struct FakeFetcher {
370 body: Vec<u8>,
371 fail: bool,
372 calls: AtomicUsize,
373 }
374
375 impl Fetcher for FakeFetcher {
376 fn fetch(&self, url: &str, dest: &Path) -> Result<(), String> {
377 self.calls.fetch_add(1, Ordering::SeqCst);
378 if self.fail {
379 return Err(format!("GET {url}: 404"));
380 }
381 if let Some(p) = dest.parent() {
382 std::fs::create_dir_all(p).unwrap();
383 }
384 std::fs::write(dest, &self.body).unwrap();
385 Ok(())
386 }
387 }
388
389 fn fetcher(body: &[u8]) -> FakeFetcher {
390 FakeFetcher {
391 body: body.to_vec(),
392 fail: false,
393 calls: AtomicUsize::new(0),
394 }
395 }
396
397 #[test]
400 fn a_local_source_resolves_to_itself_and_is_not_copied() {
401 let root = tempdir("local");
402 let src = root.join("my-plugin");
403 std::fs::create_dir_all(&src).unwrap();
404
405 let got = resolve(
406 &FakeGit::default(),
407 &fetcher(b""),
408 &PluginSource::Local(src.clone()),
409 "demo",
410 &root.join("cache"),
411 &root.join("user"),
412 RefreshPolicy::UseCache,
413 )
414 .unwrap();
415
416 assert_eq!(got, Resolved::Source(src.clone()));
417 assert!(
418 !root.join("cache").exists(),
419 "a local source must be built in place, never copied into the cache"
420 );
421 }
422
423 #[test]
424 fn a_missing_local_source_is_a_clear_error() {
425 let root = tempdir("local-missing");
426 let err = resolve(
427 &FakeGit::default(),
428 &fetcher(b""),
429 &PluginSource::Local(root.join("nope")),
430 "demo",
431 &root.join("cache"),
432 &root.join("user"),
433 RefreshPolicy::UseCache,
434 )
435 .unwrap_err();
436 assert!(err.contains("not a directory"), "got: {err}");
437 }
438
439 #[test]
442 fn an_unpinned_git_source_clones_shallow() {
443 let root = tempdir("git-shallow");
444 let git = FakeGit {
445 clone_makes_repo: true,
446 ..Default::default()
447 };
448 let got = resolve(
449 &git,
450 &fetcher(b""),
451 &PluginSource::Git {
452 url: "https://example.invalid/p.git".into(),
453 rev: None,
454 },
455 "demo",
456 &root.join("cache"),
457 &root.join("user"),
458 RefreshPolicy::UseCache,
459 )
460 .unwrap();
461
462 assert_eq!(got, Resolved::Source(root.join("cache").join("demo")));
463 let calls = git.calls();
464 assert!(calls[0].contains("--depth 1"), "got: {calls:?}");
465 assert!(
466 !calls.iter().any(|c| c.starts_with("checkout")),
467 "no rev pinned ⇒ nothing to check out: {calls:?}"
468 );
469 }
470
471 #[test]
472 fn a_pinned_git_source_clones_full_then_checks_out() {
473 let root = tempdir("git-pinned");
476 let git = FakeGit {
477 clone_makes_repo: true,
478 ..Default::default()
479 };
480 resolve(
481 &git,
482 &fetcher(b""),
483 &PluginSource::Git {
484 url: "https://example.invalid/p.git".into(),
485 rev: Some("abc123".into()),
486 },
487 "demo",
488 &root.join("cache"),
489 &root.join("user"),
490 RefreshPolicy::UseCache,
491 )
492 .unwrap();
493
494 let calls = git.calls();
495 assert!(
496 !calls[0].contains("--depth"),
497 "pinned ⇒ full clone: {calls:?}"
498 );
499 assert!(
500 calls.iter().any(|c| c == "checkout --detach abc123"),
501 "got: {calls:?}"
502 );
503 }
504
505 #[test]
506 fn a_re_resolve_at_the_same_rev_touches_no_network() {
507 let root = tempdir("git-warm");
510 let dir = root.join("cache").join("demo");
511 std::fs::create_dir_all(dir.join(".git")).unwrap();
512 let git = FakeGit {
513 head: Mutex::new(Some("abc123".into())),
514 ..Default::default()
515 };
516
517 resolve(
518 &git,
519 &fetcher(b""),
520 &PluginSource::Git {
521 url: "https://example.invalid/p.git".into(),
522 rev: Some("abc123".into()),
523 },
524 "demo",
525 &root.join("cache"),
526 &root.join("user"),
527 RefreshPolicy::UseCache,
528 )
529 .unwrap();
530
531 let calls = git.calls();
532 assert_eq!(calls, vec!["rev-parse HEAD".to_string()]);
533 assert!(
534 !calls.iter().any(|c| c.starts_with("fetch")),
535 "an unchanged rev must not fetch: {calls:?}"
536 );
537 }
538
539 #[test]
540 fn a_changed_rev_fetches_then_checks_out() {
541 let root = tempdir("git-move");
542 let dir = root.join("cache").join("demo");
543 std::fs::create_dir_all(dir.join(".git")).unwrap();
544 let git = FakeGit {
545 head: Mutex::new(Some("old".into())),
546 ..Default::default()
547 };
548
549 resolve(
550 &git,
551 &fetcher(b""),
552 &PluginSource::Git {
553 url: "https://example.invalid/p.git".into(),
554 rev: Some("new".into()),
555 },
556 "demo",
557 &root.join("cache"),
558 &root.join("user"),
559 RefreshPolicy::UseCache,
560 )
561 .unwrap();
562
563 let calls = git.calls();
564 assert!(calls.iter().any(|c| c.starts_with("fetch")), "{calls:?}");
565 assert!(
566 calls.iter().any(|c| c == "checkout --detach new"),
567 "{calls:?}"
568 );
569 }
570
571 #[test]
579 fn a_warm_unpinned_checkout_touches_no_network_under_use_cache() {
580 let root = tempdir("git-head");
581 let dir = root.join("cache").join("demo");
582 std::fs::create_dir_all(dir.join(".git")).unwrap();
583 let git = FakeGit::default();
584
585 resolve(
586 &git,
587 &fetcher(b""),
588 &PluginSource::Git {
589 url: "https://example.invalid/p.git".into(),
590 rev: None,
591 },
592 "demo",
593 &root.join("cache"),
594 &root.join("user"),
595 RefreshPolicy::UseCache,
596 )
597 .unwrap();
598
599 assert!(
600 git.calls().is_empty(),
601 "a cloned, unpinned checkout needs no git at all: {:?}",
602 git.calls()
603 );
604 }
605
606 #[test]
610 fn an_unpinned_checkout_advances_to_the_fetched_head_under_update() {
611 let root = tempdir("git-update");
612 let dir = root.join("cache").join("demo");
613 std::fs::create_dir_all(dir.join(".git")).unwrap();
614 let git = FakeGit::default();
615
616 resolve(
617 &git,
618 &fetcher(b""),
619 &PluginSource::Git {
620 url: "https://example.invalid/p.git".into(),
621 rev: None,
622 },
623 "demo",
624 &root.join("cache"),
625 &root.join("user"),
626 RefreshPolicy::Update,
627 )
628 .unwrap();
629
630 let calls = git.calls();
631 assert!(
632 calls.iter().any(|c| c.starts_with("fetch")),
633 "got: {calls:?}"
634 );
635 assert!(
636 calls.iter().any(|c| c == "reset --hard FETCH_HEAD"),
637 "the fetch must be followed by the move that makes it mean \
638 something: {calls:?}"
639 );
640 }
641
642 #[test]
646 fn a_pinned_checkout_at_its_rev_stays_put_even_under_update() {
647 let root = tempdir("git-pinned-update");
648 let dir = root.join("cache").join("demo");
649 std::fs::create_dir_all(dir.join(".git")).unwrap();
650 let git = FakeGit::default();
651 *git.head.lock().unwrap() = Some("abc123".to_string());
652
653 resolve(
654 &git,
655 &fetcher(b""),
656 &PluginSource::Git {
657 url: "https://example.invalid/p.git".into(),
658 rev: Some("abc123".into()),
659 },
660 "demo",
661 &root.join("cache"),
662 &root.join("user"),
663 RefreshPolicy::Update,
664 )
665 .unwrap();
666
667 let calls = git.calls();
668 assert!(
669 !calls.iter().any(|c| c.starts_with("fetch")),
670 "already at the pin ⇒ no network: {calls:?}"
671 );
672 assert!(
673 !calls.iter().any(|c| c.starts_with("reset")),
674 "a pin never moves: {calls:?}"
675 );
676 }
677
678 #[test]
682 fn a_real_local_repository_clones_and_checks_out() {
683 let root = tempdir("git-real");
684 let origin = root.join("origin");
685 std::fs::create_dir_all(&origin).unwrap();
686 let g = SystemGit;
687 if g.run(&origin, &["init", "-q"]).is_err() {
688 eprintln!("git unavailable; skipping");
689 return;
690 }
691 let _ = g.run(&origin, &["config", "user.email", "t@example.invalid"]);
692 let _ = g.run(&origin, &["config", "user.name", "t"]);
693 std::fs::write(origin.join("plugin.toml"), "id = \"demo\"\n").unwrap();
694 g.run(&origin, &["add", "."]).unwrap();
695 g.run(&origin, &["commit", "-qm", "one"]).unwrap();
696 let rev = g.run(&origin, &["rev-parse", "HEAD"]).unwrap();
697
698 let got = resolve(
699 &g,
700 &fetcher(b""),
701 &PluginSource::Git {
702 url: origin.to_string_lossy().to_string(),
703 rev: Some(rev.clone()),
704 },
705 "demo",
706 &root.join("cache"),
707 &root.join("user"),
708 RefreshPolicy::UseCache,
709 )
710 .unwrap();
711
712 let dir = match got {
713 Resolved::Source(d) => d,
714 other => panic!("expected a source tree, got {other:?}"),
715 };
716 assert!(dir.join("plugin.toml").is_file(), "the tree is checked out");
717 assert_eq!(g.run(&dir, &["rev-parse", "HEAD"]).unwrap(), rev);
718
719 let again = resolve(
721 &g,
722 &fetcher(b""),
723 &PluginSource::Git {
724 url: origin.to_string_lossy().to_string(),
725 rev: Some(rev),
726 },
727 "demo",
728 &root.join("cache"),
729 &root.join("user"),
730 RefreshPolicy::UseCache,
731 );
732 assert!(again.is_ok());
733 }
734
735 #[test]
738 fn a_prebuilt_source_lands_an_artifact_and_skips_the_build() {
739 let root = tempdir("prebuilt");
740 let user = root.join("user");
741 let f = fetcher(b"\0asm-prebuilt");
742
743 let got = resolve(
744 &FakeGit::default(),
745 &f,
746 &PluginSource::Prebuilt {
747 url: "https://example.invalid/demo.wasm".into(),
748 },
749 "demo",
750 &root.join("cache"),
751 &user,
752 RefreshPolicy::UseCache,
753 )
754 .unwrap();
755
756 let artifact = user.join("demo").join("demo.wasm");
757 assert_eq!(
758 got,
759 Resolved::Artifact(artifact.clone()),
760 "Artifact, not Source — a prebuilt has no source tree to build"
761 );
762 assert_eq!(std::fs::read(&artifact).unwrap(), b"\0asm-prebuilt");
763 }
764
765 #[test]
766 fn a_prebuilt_gets_a_synthesised_manifest_with_no_capabilities() {
767 let root = tempdir("prebuilt-manifest");
770 let user = root.join("user");
771 resolve(
772 &FakeGit::default(),
773 &fetcher(b"x"),
774 &PluginSource::Prebuilt {
775 url: "https://example.invalid/demo.wasm".into(),
776 },
777 "demo",
778 &root.join("cache"),
779 &user,
780 RefreshPolicy::UseCache,
781 )
782 .unwrap();
783
784 let manifest = std::fs::read_to_string(user.join("demo").join("plugin.toml")).unwrap();
785 assert!(manifest.contains("id = \"demo\""));
786 assert!(
787 !manifest.contains("capabilit"),
788 "a synthesised manifest must not grant anything: {manifest}"
789 );
790 }
791
792 #[test]
793 fn a_hand_edited_manifest_survives_a_re_download() {
794 let root = tempdir("prebuilt-keep");
795 let user = root.join("user");
796 let dir = user.join("demo");
797 std::fs::create_dir_all(&dir).unwrap();
798 std::fs::write(dir.join("plugin.toml"), "id = \"demo\"\nhand = true\n").unwrap();
799
800 resolve(
801 &FakeGit::default(),
802 &fetcher(b"x"),
803 &PluginSource::Prebuilt {
804 url: "https://example.invalid/demo.wasm".into(),
805 },
806 "demo",
807 &root.join("cache"),
808 &user,
809 RefreshPolicy::UseCache,
810 )
811 .unwrap();
812
813 let manifest = std::fs::read_to_string(dir.join("plugin.toml")).unwrap();
814 assert!(
815 manifest.contains("hand = true"),
816 "a re-download must not silently revert a user's grant: {manifest}"
817 );
818 }
819
820 #[test]
821 fn a_failed_download_is_an_error_not_a_panic() {
822 let root = tempdir("prebuilt-fail");
823 let f = FakeFetcher {
824 body: Vec::new(),
825 fail: true,
826 calls: AtomicUsize::new(0),
827 };
828 let err = resolve(
829 &FakeGit::default(),
830 &f,
831 &PluginSource::Prebuilt {
832 url: "https://example.invalid/gone.wasm".into(),
833 },
834 "demo",
835 &root.join("cache"),
836 &root.join("user"),
837 RefreshPolicy::UseCache,
838 )
839 .unwrap_err();
840 assert!(err.contains("404"), "got: {err}");
841 }
842}