Skip to main content

lattice_plugin_loader/
discovery.rs

1//! On-disk plugin discovery (PL8.B).
2//!
3//! A plugin lives in its own directory under `~/.config/lattice/plugins/`,
4//! holding a `plugin.toml` manifest and a `.wasm` component. Discovery scans that
5//! tree, parses each manifest, and reads the component bytes — every failure is a
6//! logged skip, never fatal: one malformed plugin dir must not stop the others
7//! or fail boot (the four-artefact graceful-degradation clause).
8
9use std::path::{Path, PathBuf};
10
11use lattice_plugin_host::PluginManifest;
12
13/// The manifest filename inside a plugin directory.
14const MANIFEST_FILE: &str = "plugin.toml";
15
16/// The lattice config home — `~/.config` on **both Linux and macOS** (honoring
17/// `$XDG_CONFIG_HOME`), `%APPDATA%` on Windows. Reuses the canonical
18/// [`lattice_config::config_home`] the TOML config root already uses, so plugins
19/// / `init.rs` live under the SAME `~/.config/lattice/` tree as `lattice.toml` —
20/// NOT the macOS-native `~/Library/Application Support` that `dirs::config_dir`
21/// returns (the convention Helix / Neovim / Zed / alacritty follow on macOS).
22fn config_root() -> Option<PathBuf> {
23    lattice_config::config_home()
24}
25
26/// A plugin found on disk, ready to load: its parsed manifest, the component
27/// bytes, and the directory it came from (for diagnostics).
28pub struct DiscoveredPlugin {
29    pub manifest: PluginManifest,
30    pub component_bytes: Vec<u8>,
31    pub dir: PathBuf,
32    /// PM.8a: where this plugin came from, read from its `.source` marker.
33    /// [`SourceRecord::Unknown`] for a hand-installed plugin or one staged by
34    /// a lattice predating the marker — the honest answer, rather than
35    /// guessing `Local` and putting a wrong path in the view.
36    pub source: crate::source_record::SourceRecord,
37}
38
39/// The default plugins directory: `~/.config/lattice/plugins/` on Linux AND
40/// macOS (honoring `$XDG_CONFIG_HOME`), `%APPDATA%\lattice\plugins` on Windows.
41/// `None` if the platform has no config dir (the editor then loads no on-disk
42/// plugins).
43pub fn default_plugins_dir() -> Option<PathBuf> {
44    config_root().map(|d| d.join("lattice").join("plugins"))
45}
46
47/// The **core-plugins root** — prebuilt plugins that ship WITH lattice
48/// (plugin-manager.md §7 / PM.1). Distinct from [`default_plugins_dir`] (the
49/// user's `require`+build cache): core plugins are the batteries-included set,
50/// discovered at boot at the `Bundled` tier. Resolved via a SEARCH PATH — the
51/// first *existing* candidate wins, except an explicit `$LATTICE_RUNTIME` override
52/// always wins (whether or not it exists yet):
53///
54/// 1. `$LATTICE_RUNTIME/plugins` — explicit override,
55/// 2. `<LATTICE_INSTALL_PREFIX>/share/lattice/plugins` — the prefix a packager
56///    bakes in at build time (`option_env!`),
57/// 3. `<exe-dir>/../share/lattice/plugins` — a relocatable install / `.app`,
58/// 4. `<exe-dir>/../../runtime/plugins` — dev, running from `target/<profile>/`.
59///
60/// `None` when no candidate exists — the editor then loads no core plugins (a
61/// benign skip, like an absent user plugins dir).
62pub fn default_core_plugins_dir() -> Option<PathBuf> {
63    core_plugins_dir_from(
64        std::env::var_os("LATTICE_RUNTIME"),
65        option_env!("LATTICE_INSTALL_PREFIX"),
66        std::env::current_exe().ok().as_deref(),
67    )
68}
69
70/// The pure search-path core of [`default_core_plugins_dir`] — takes the resolved
71/// inputs so it's testable without touching the process environment.
72fn core_plugins_dir_from(
73    runtime_env: Option<std::ffi::OsString>,
74    install_prefix: Option<&str>,
75    exe: Option<&Path>,
76) -> Option<PathBuf> {
77    // Explicit override wins unconditionally (existence is discovery's concern).
78    if let Some(root) = runtime_env {
79        return Some(PathBuf::from(root).join("plugins"));
80    }
81    let mut candidates: Vec<PathBuf> = Vec::new();
82    if let Some(prefix) = install_prefix {
83        candidates.push(
84            Path::new(prefix)
85                .join("share")
86                .join("lattice")
87                .join("plugins"),
88        );
89    }
90    if let Some(dir) = exe.and_then(Path::parent) {
91        // Installed: `<prefix>/bin/lattice` → `<prefix>/share/lattice/plugins`.
92        candidates.push(dir.join("..").join("share").join("lattice").join("plugins"));
93        // Dev: `<workspace>/target/<profile>/lattice` → `<workspace>/runtime/plugins`.
94        candidates.push(dir.join("..").join("..").join("runtime").join("plugins"));
95    }
96    candidates.into_iter().find(|p| p.exists())
97}
98
99/// The user's `init.rs` config plugin directory: `~/.config/lattice/init/` on
100/// Linux AND macOS (honoring `$XDG_CONFIG_HOME`), `%APPDATA%\lattice\init` on
101/// Windows. Holds the user's `init.rs`-compiled component + its `plugin.toml`
102/// (`id = "init"`, `provides = [...]` for the seams it uses). Loaded at boot with
103/// a boot-capability (`Bundled`) tier — it's the user's own trusted config, not
104/// an external install. `None` if the platform has no config dir.
105pub fn default_init_dir() -> Option<PathBuf> {
106    config_root().map(|d| d.join("lattice").join("init"))
107}
108
109/// PM.6/PM.7b: the git source cache — `~/.config/lattice/cache/sources/`.
110///
111/// A *cache*, not config: a deleted checkout is re-cloned, so it belongs in
112/// `<config-home>/lattice/cache/sources/` rather than beside the user's
113/// `plugin.toml`s. Falls back to the temp dir when no config home resolves,
114/// which keeps the resolver working rather than failing.
115pub fn default_source_cache_dir() -> std::path::PathBuf {
116    let dir = lattice_config::cache_home()
117        .unwrap_or_else(std::env::temp_dir)
118        .join("sources");
119    // Once: carry checkouts from the pre-0.9.2 `dirs::cache_dir()` location
120    // rather than re-cloning (and rebuilding) every `require`d plugin.
121    if let Some(legacy) = dirs::cache_dir().map(|d| d.join("lattice").join("sources")) {
122        lattice_config::migrate_path(&legacy, &dir);
123    }
124    dir
125}
126
127/// Scan `dir` for plugin subdirectories, returning every one that parses. A
128/// missing `dir` yields an empty list (no plugins installed — normal). Each
129/// subdirectory needs a `plugin.toml` + exactly one `.wasm`; anything else is
130/// logged at `warn`/`debug` and skipped.
131pub fn discover(dir: &Path) -> Vec<DiscoveredPlugin> {
132    let entries = match std::fs::read_dir(dir) {
133        Ok(entries) => entries,
134        Err(err) => {
135            // A missing plugins dir is the common, benign case (no plugins
136            // installed) — `debug`, not `warn`.
137            tracing::debug!(
138                path = %dir.display(),
139                error = %err,
140                "plugins dir not readable; loading no on-disk plugins"
141            );
142            return Vec::new();
143        }
144    };
145
146    let mut found = Vec::new();
147    for entry in entries.flatten() {
148        let plugin_dir = entry.path();
149        if !plugin_dir.is_dir() {
150            continue;
151        }
152        match load_one(&plugin_dir) {
153            Ok(Some(plugin)) => found.push(plugin),
154            Ok(None) => {} // not a plugin dir (no manifest) — silently skip.
155            Err(reason) => tracing::warn!(
156                path = %plugin_dir.display(),
157                reason,
158                "skipping malformed plugin dir"
159            ),
160        }
161    }
162    found
163}
164
165/// Parse a single explicitly-named plugin directory — the `:plugin-load <path>`
166/// entry point (PL8.C). Unlike [`discover`] (which scans a tree and silently
167/// skips non-plugin subdirs), this is a direct request for *one* dir, so a
168/// missing manifest is an error the user sees, not a silent skip.
169pub fn discover_one(plugin_dir: &Path) -> Result<DiscoveredPlugin, String> {
170    match load_one(plugin_dir) {
171        Ok(Some(plugin)) => Ok(plugin),
172        Ok(None) => Err(format!(
173            "no `{MANIFEST_FILE}` in {} (not a plugin directory)",
174            plugin_dir.display()
175        )),
176        Err(reason) => Err(reason),
177    }
178}
179
180/// Parse a single plugin directory. `Ok(None)` if it has no manifest (not a
181/// plugin dir); `Err(reason)` if it has a manifest but is otherwise malformed
182/// (bad TOML, missing/ambiguous component) — the caller logs the reason.
183fn load_one(plugin_dir: &Path) -> Result<Option<DiscoveredPlugin>, String> {
184    let manifest_path = plugin_dir.join(MANIFEST_FILE);
185    if !manifest_path.exists() {
186        return Ok(None);
187    }
188    let manifest_text = std::fs::read_to_string(&manifest_path)
189        .map_err(|e| format!("cannot read {MANIFEST_FILE}: {e}"))?;
190    let manifest = PluginManifest::from_toml_str(&manifest_text)
191        .map_err(|e| format!("invalid manifest: {e}"))?;
192
193    let component_path = sole_wasm(plugin_dir)?;
194    let component_bytes = std::fs::read(&component_path)
195        .map_err(|e| format!("cannot read component {}: {e}", component_path.display()))?;
196
197    Ok(Some(DiscoveredPlugin {
198        manifest,
199        component_bytes,
200        dir: plugin_dir.to_path_buf(),
201        source: crate::source_record::read(plugin_dir),
202    }))
203}
204
205/// The single `.wasm` file in `plugin_dir`. An error if there is none or more
206/// than one — the manifest does not name the component, so exactly one is the
207/// unambiguous contract.
208fn sole_wasm(plugin_dir: &Path) -> Result<PathBuf, String> {
209    let mut wasm: Vec<PathBuf> = Vec::new();
210    let entries =
211        std::fs::read_dir(plugin_dir).map_err(|e| format!("cannot read plugin dir: {e}"))?;
212    for entry in entries.flatten() {
213        let path = entry.path();
214        if path.extension().is_some_and(|ext| ext == "wasm") {
215            wasm.push(path);
216        }
217    }
218    match wasm.len() {
219        1 => Ok(wasm.into_iter().next().expect("len checked == 1")),
220        0 => Err("no `.wasm` component found".to_string()),
221        n => Err(format!("{n} `.wasm` files found; expected exactly one")),
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    #![allow(clippy::unwrap_used)]
228    use super::core_plugins_dir_from;
229    use std::path::{Path, PathBuf};
230
231    #[test]
232    fn runtime_env_override_wins_unconditionally() {
233        // The override is used even when it doesn't exist (discovery skips a
234        // missing dir); no other candidate is consulted.
235        let got = core_plugins_dir_from(
236            Some("/opt/lattice-runtime".into()),
237            Some("/usr"),
238            Some(Path::new("/usr/bin/lattice")),
239        );
240        assert_eq!(got, Some(PathBuf::from("/opt/lattice-runtime/plugins")));
241    }
242
243    #[test]
244    fn install_prefix_beats_exe_relative_when_it_exists() {
245        // A real dir for the prefix candidate; exe-relative candidates don't
246        // exist, so the prefix wins.
247        let tmp = tempfile::tempdir().unwrap();
248        let prefix = tmp.path();
249        let plugins = prefix.join("share").join("lattice").join("plugins");
250        std::fs::create_dir_all(&plugins).unwrap();
251        let got = core_plugins_dir_from(
252            None,
253            Some(prefix.to_str().unwrap()),
254            Some(Path::new("/nowhere/bin/lattice")),
255        );
256        assert_eq!(got, Some(plugins));
257    }
258
259    #[test]
260    fn falls_through_to_the_dev_runtime_dir() {
261        // No override, no prefix; the exe-relative dev candidate
262        // (`<exe>/../../runtime/plugins`) exists.
263        let tmp = tempfile::tempdir().unwrap();
264        // Simulate `<workspace>/target/debug/lattice`.
265        let exe = tmp.path().join("target").join("debug").join("lattice");
266        std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
267        let dev_plugins = tmp.path().join("runtime").join("plugins");
268        std::fs::create_dir_all(&dev_plugins).unwrap();
269        let got = core_plugins_dir_from(None, None, Some(&exe));
270        // `<exe>/../../runtime/plugins` normalises to the created dir.
271        assert_eq!(got.map(|p| p.exists()), Some(true));
272        assert!(got_matches(&exe, &dev_plugins));
273    }
274
275    #[test]
276    fn resolves_a_relocatable_install_from_the_bin_dir() {
277        // The release-archive layout (launch-0.9.md §3): the user extracts
278        // `lattice-<ver>-<platform>/` anywhere and runs `bin/lattice`, which
279        // must find `../share/lattice/plugins` beside it. No baked prefix —
280        // the archive is relocatable, so the prefix isn't known at build time.
281        //
282        // Compared by canonical path rather than literal `PathBuf` equality:
283        // the candidate is built by appending a `..` component instead of
284        // popping the parent, so it comes back as
285        // `<prefix>/bin/../share/lattice/plugins`. That names the right
286        // directory and `.exists()` selects it correctly — only the spelling
287        // differs, and nothing compares this path. The sibling dev-fallback
288        // test compares the same way for the same reason. Both sides are
289        // canonicalized because macOS resolves the tempdir's `/var` to
290        // `/private/var`.
291        let tmp = tempfile::tempdir().unwrap();
292        let prefix = tmp.path();
293        let plugins = prefix.join("share").join("lattice").join("plugins");
294        std::fs::create_dir_all(&plugins).unwrap();
295        std::fs::create_dir_all(prefix.join("bin")).unwrap();
296
297        let got = core_plugins_dir_from(None, None, Some(&prefix.join("bin").join("lattice")))
298            .expect("an extracted archive must find the plugins shipped beside its binary");
299
300        assert_eq!(
301            got.canonicalize().unwrap(),
302            plugins.canonicalize().unwrap(),
303            "resolved {} but expected the plugins dir beside the binary",
304            got.display()
305        );
306    }
307
308    #[test]
309    fn none_when_no_candidate_exists() {
310        assert_eq!(
311            core_plugins_dir_from(None, None, Some(Path::new("/nowhere/bin/lattice"))),
312            None
313        );
314        // No exe at all (current_exe failed) + no prefix → None.
315        assert_eq!(core_plugins_dir_from(None, None, None), None);
316    }
317
318    // The dev candidate path contains `..` segments; compare by canonicalized
319    // existence rather than literal equality.
320    fn got_matches(exe: &Path, expected_existing: &Path) -> bool {
321        let got = core_plugins_dir_from(None, None, Some(exe)).unwrap();
322        got.canonicalize().ok() == expected_existing.canonicalize().ok()
323    }
324}