Skip to main content

lattice_plugin_loader/
resolve.rs

1//! PM.6: the source resolver — a declared source becomes something the
2//! build service can consume.
3//!
4//! Design: [`plugin-manager.md`](../../../docs/dev/architecture/plugin-manager.md)
5//! §4. Three source kinds, and one of them deliberately does not end in
6//! a build:
7//!
8//! - [`PluginSource::Local`] — the directory *is* the source tree.
9//!   Built in place; the tree is never copied.
10//! - [`PluginSource::Git`] — cloned/fetched into a source cache and
11//!   checked out at `rev`, then built like `Local`.
12//! - [`PluginSource::Prebuilt`] — a ready `.wasm` downloaded straight
13//!   into the user root. **No build, no toolchain** (§7), which is the
14//!   whole point of the kind: it is how a user on a machine with no
15//!   Rust installs a plugin at all.
16//!
17//! So [`resolve`] returns [`Resolved`], not a path: "where is the
18//! source" and "here is the artifact, skip the build" are different
19//! answers and collapsing them would force `Prebuilt` to invent a
20//! source tree it does not have.
21//!
22//! ## Why git is a subprocess
23//!
24//! `gix` is already in the workspace, but only with read-only features
25//! — `lattice-vcs` inspects repositories, it does not clone them.
26//! Turning on `blocking-network-client` would pull TLS and a network
27//! stack into a crate that has neither, to replace a binary every
28//! developer running a `Git` plugin source already has. It is also the
29//! same bargain the build service already strikes with `cargo`: you
30//! have the toolchain for the source kind you asked for.
31//!
32//! Everything here blocks. Callers run it on `spawn_blocking`.
33
34use std::path::{Path, PathBuf};
35
36/// Where a plugin comes from (mirrors the `plugin-source` WIT variant
37/// PM.7 exposes to `init.rs`).
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum PluginSource {
40    /// A cargo project on disk. Built in place.
41    Local(PathBuf),
42    /// A git repository, optionally pinned to a revision.
43    Git { url: String, rev: Option<String> },
44    /// A URL serving a ready-built `.wasm` component.
45    Prebuilt { url: String },
46}
47
48/// Whether a source that is already in the cache may be advanced from the
49/// network.
50///
51/// The distinction exists because "resolve" is asked for by two callers that
52/// want opposite things. Boot and rebuild want *what the user already has* —
53/// deterministic, offline-capable, no per-start network round trip. The
54/// `update` verb exists precisely to go and get something newer, and is the
55/// only caller that should.
56///
57/// A **pinned** rev ignores this entirely: a pin is the answer to "which
58/// commit", so there is nothing for either policy to decide. Both will still
59/// fetch-and-move when the checkout is not at the pin (the user edited the pin
60/// in `init.rs`), and neither touches the network when it already is.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum RefreshPolicy {
63    /// Use the checkout on disk as it stands. No network for a repository
64    /// that is already cloned.
65    #[default]
66    UseCache,
67    /// Fetch and move an unpinned checkout to the tracked head.
68    Update,
69}
70
71/// What a source resolved to.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum Resolved {
74    /// A source tree for the build service ([`crate::build_plugin`]).
75    Source(PathBuf),
76    /// An artifact already in place; the build is skipped entirely.
77    Artifact(PathBuf),
78}
79
80/// Downloads a URL to a file.
81///
82/// Behind a trait for the same reason the build service's toolchain is
83/// (PM.5): the interesting behaviour around it — where the bytes land,
84/// what manifest gets synthesised, what happens when the download fails
85/// — should be testable without reaching the network, which is neither
86/// fast nor available in CI sandboxes.
87pub trait Fetcher: Send + Sync {
88    /// Fetch `url`, writing the body to `dest`.
89    fn fetch(&self, url: &str, dest: &Path) -> Result<(), String>;
90}
91
92/// The real fetcher.
93#[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        // Download to a sibling temp path and rename into place, so an
107        // interrupted transfer cannot leave a truncated `.wasm` that
108        // looks like a valid cached artifact on the next boot.
109        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
119/// Runs git. Behind a trait so the resolver's cache/checkout logic is
120/// testable against a fake, and against a real local repository, with
121/// no network.
122pub trait GitRunner: Send + Sync {
123    /// Run `git <args>` in `cwd`; return stdout on success.
124    fn run(&self, cwd: &Path, args: &[&str]) -> Result<String, String>;
125}
126
127/// The real runner.
128#[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
151/// The source-cache directory for `name`'s git checkout.
152pub fn git_cache_dir(cache_root: &Path, name: &str) -> PathBuf {
153    cache_root.join(name)
154}
155
156/// Resolve `source` for the plugin called `name`.
157///
158/// `cache_root` holds git checkouts (`~/.config/lattice/cache/sources/`);
159/// `user_root` is the plugin cache (`~/.config/lattice/plugins/`) a
160/// `Prebuilt` artifact lands in.
161///
162/// `policy` decides whether an already-cloned git source is advanced from the
163/// network; it is meaningless for `Local` (the tree IS the source) and for
164/// `Prebuilt` (which re-downloads whenever the artifact is missing).
165///
166/// Blocking (clone / fetch / download). Run on `spawn_blocking`.
167pub 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
197/// Clone `name`'s checkout, or bring an existing one to where `rev` and
198/// `policy` say it should be.
199///
200/// Only reach the network when the answer requires it:
201///
202/// | on disk | pinned | policy | network |
203/// |---|---|---|---|
204/// | absent | either | either | clone |
205/// | present | at the pin | either | none |
206/// | present | pin differs | either | fetch + checkout |
207/// | present | unpinned | `UseCache` | **none** |
208/// | present | unpinned | `Update` | fetch + move to the tracked head |
209///
210/// **The unpinned rows are the 2026-09-14 fix.** Every re-resolve used to
211/// `fetch` an unpinned checkout and then stop — the `checkout` below is
212/// reachable only with a pin — so the objects arrived and local `HEAD` never
213/// moved. An unpinned plugin was frozen at the commit it was first cloned at,
214/// for the life of the checkout, while paying a network round trip on every
215/// single boot to stay that way. Both halves are wrong and they are each
216/// other's fix: `UseCache` drops the pointless fetch, and `Update` follows its
217/// fetch with the move that makes it mean something.
218///
219/// `reset --hard FETCH_HEAD` rather than a merge or a pull: the checkout is a
220/// cache the editor owns, never a working tree the user edits, so the tracked
221/// head is simply what it should contain. A merge could conflict, and there is
222/// nobody to resolve it.
223fn 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        // Pinned. The pin is the answer whatever the policy, so the only
242        // question is whether we are already at it.
243        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        // Unpinned. `UseCache` is every boot and every rebuild; `Update` is
252        // the `update` verb, and nothing else.
253        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
265/// Clone into `dir`, shallow unless a rev is pinned.
266///
267/// Full history when a rev is pinned (a shallow clone may not contain it);
268/// shallow when tracking the default branch, which is the common case and much
269/// cheaper.
270fn 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
285/// Download a prebuilt component and give it a manifest.
286///
287/// The manifest is **synthesised** with the plugin's id and nothing
288/// else — in particular no capabilities. A downloaded binary is the
289/// least-known code the editor runs, so it gets the smallest grant that
290/// still lets it load; a plugin that needs more ships a real
291/// `plugin.toml` through a `Local` or `Git` source, where the user can
292/// read what it asked for before it runs.
293///
294/// An existing manifest is never overwritten: a user who hand-edited
295/// one to grant a capability should not have that silently reverted by
296/// a re-download.
297fn 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    /// Records the git commands issued, so the tests can assert on the
334    /// *shape* of the interaction (shallow vs full, fetch skipped)
335    /// without a network or a real repository.
336    #[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    // --- Local ---------------------------------------------------------
398
399    #[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    // --- Git -----------------------------------------------------------
440
441    #[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        // A shallow clone may not contain the pinned rev, so pinning has
474        // to opt out of the cheap path.
475        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        // The warm-boot requirement, applied to the network: a pinned
508        // plugin must not fetch on every start.
509        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    /// Boot and rebuild resolve with `UseCache`, and an unpinned checkout that
572    /// is already on disk is then answered entirely from it.
573    ///
574    /// This asserted the opposite until 2026-09-14 — that a warm unpinned
575    /// checkout fetches — which was true and useless: the fetch was never
576    /// followed by anything that moved `HEAD`, so it bought a network round
577    /// trip per boot and changed nothing. See [`super::resolve_git`].
578    #[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    /// `Update` is the policy the `update` verb passes, and it is the whole
607    /// point: fetch AND move. A fetch on its own leaves the plugin exactly
608    /// where it was, which is the bug this pair exists to keep fixed.
609    #[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    /// A pin does not move, whatever the policy asks for. `update` on a pinned
643    /// plugin is a no-op by design — the pin IS the answer to "which commit" —
644    /// and a fetch here would be network traffic for a decision already made.
645    #[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    /// The fake asserts shape; this asserts the real thing works. A
679    /// local repository exercises clone / fetch / checkout end to end
680    /// with no network.
681    #[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        // And a second resolve at the same rev is a no-op.
720        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    // --- Prebuilt ------------------------------------------------------
736
737    #[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        // A downloaded binary is the least-known code the editor runs,
768        // so it gets the smallest grant that still lets it load.
769        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}