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}