Skip to main content

lattice_plugin_loader/
source_record.rs

1//! PM.8a: the `.source` marker — where an installed plugin came from,
2//! remembered on disk beside its artifact.
3//!
4//! Design: [`plugin-manager.md`](../../../docs/dev/architecture/plugin-manager.md)
5//! §4, §8.
6//!
7//! ## Why this is persisted rather than derived
8//!
9//! The obvious cheaper thing is to remember a plugin's source in memory for
10//! the boot that installed it. That fails on the two cases that matter:
11//!
12//! - **The next boot.** A plugin cached in the user root loads from the
13//!   on-disk scan. Nothing in that path has ever seen a `require`, so a
14//!   derived source column would read `local` for a plugin that came from git
15//!   — confidently wrong, which is worse than blank.
16//! - **The rebuild chord (PM.8b).** You cannot re-clone a git plugin without
17//!   its URL and rev. A chord that only worked for plugins required earlier in
18//!   *this* session would be a chord that mostly does not work.
19//!
20//! So the source travels with the artifact, next to the `.build-stamp` that
21//! already records what the artifact was built *from*. Together they answer
22//! the two questions the view asks: where did this come from, and is it
23//! current.
24//!
25//! ## Format
26//!
27//! A tiny hand-rolled `key = value` file rather than serde. The record is four
28//! optional scalars and this crate does not otherwise depend on a TOML
29//! deserializer; a malformed or absent file degrades to "unknown source",
30//! never an error — a plugin whose marker got corrupted must still load.
31
32use std::path::{Path, PathBuf};
33
34use crate::resolve::PluginSource;
35
36/// The file, beside `plugin.toml` and `.build-stamp`.
37const SOURCE_FILE: &str = ".source";
38
39/// Where a plugin on disk came from.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum SourceRecord {
42    /// Ships with lattice, discovered from the runtime root (§7). Never built
43    /// by the editor.
44    Bundled,
45    /// Built in place from a directory the user named.
46    Local(PathBuf),
47    /// Cloned from a git remote.
48    Git { url: String, rev: Option<String> },
49    /// Downloaded ready-built.
50    Prebuilt { url: String },
51    /// Present on disk with no marker — hand-installed, or installed by a
52    /// lattice older than PM.8. Explicitly a *state*, not a fallback to
53    /// `Local`: claiming a source we do not know would make the column lie.
54    Unknown,
55}
56
57impl SourceRecord {
58    /// The view's SOURCE cell.
59    pub fn label(&self) -> String {
60        match self {
61            SourceRecord::Bundled => "bundled".to_string(),
62            SourceRecord::Local(_) => "local".to_string(),
63            SourceRecord::Git { rev: Some(rev), .. } => {
64                // Short rev — a full 40-char sha would dominate the row and
65                // the first 7 are what a user recognises.
66                format!("git@{}", &rev[..rev.len().min(7)])
67            }
68            SourceRecord::Git { rev: None, .. } => "git".to_string(),
69            SourceRecord::Prebuilt { .. } => "prebuilt".to_string(),
70            SourceRecord::Unknown => "—".to_string(),
71        }
72    }
73
74    /// Can the editor rebuild this from source? `Prebuilt` and `Bundled`
75    /// cannot — there is nothing to build — and `Unknown` has nowhere to
76    /// build from.
77    pub fn is_buildable(&self) -> bool {
78        matches!(self, SourceRecord::Local(_) | SourceRecord::Git { .. })
79    }
80
81    /// The resolver input this record describes, for a rebuild (PM.8b).
82    pub fn as_plugin_source(&self) -> Option<PluginSource> {
83        match self {
84            SourceRecord::Local(path) => Some(PluginSource::Local(path.clone())),
85            SourceRecord::Git { url, rev } => Some(PluginSource::Git {
86                url: url.clone(),
87                rev: rev.clone(),
88            }),
89            SourceRecord::Prebuilt { url } => Some(PluginSource::Prebuilt { url: url.clone() }),
90            SourceRecord::Bundled | SourceRecord::Unknown => None,
91        }
92    }
93
94    fn from_plugin_source(source: &PluginSource) -> Self {
95        match source {
96            PluginSource::Local(p) => SourceRecord::Local(p.clone()),
97            PluginSource::Git { url, rev } => SourceRecord::Git {
98                url: url.clone(),
99                rev: rev.clone(),
100            },
101            PluginSource::Prebuilt { url } => SourceRecord::Prebuilt { url: url.clone() },
102        }
103    }
104
105    fn to_file(&self) -> String {
106        match self {
107            SourceRecord::Bundled => "kind = bundled\n".to_string(),
108            SourceRecord::Local(p) => format!("kind = local\npath = {}\n", p.display()),
109            SourceRecord::Git { url, rev } => {
110                let mut s = format!("kind = git\nurl = {url}\n");
111                if let Some(rev) = rev {
112                    s.push_str(&format!("rev = {rev}\n"));
113                }
114                s
115            }
116            SourceRecord::Prebuilt { url } => format!("kind = prebuilt\nurl = {url}\n"),
117            SourceRecord::Unknown => "kind = unknown\n".to_string(),
118        }
119    }
120
121    fn parse(text: &str) -> Self {
122        let mut kind = "";
123        let mut path = "";
124        let mut url = "";
125        let mut rev: Option<String> = None;
126        for line in text.lines() {
127            let Some((k, v)) = line.split_once('=') else {
128                continue;
129            };
130            let (k, v) = (k.trim(), v.trim());
131            match k {
132                "kind" => kind = v,
133                "path" => path = v,
134                "url" => url = v,
135                "rev" if !v.is_empty() => rev = Some(v.to_string()),
136                _ => {}
137            }
138        }
139        match kind {
140            "bundled" => SourceRecord::Bundled,
141            "local" if !path.is_empty() => SourceRecord::Local(PathBuf::from(path)),
142            "git" if !url.is_empty() => SourceRecord::Git {
143                url: url.to_string(),
144                rev,
145            },
146            "prebuilt" if !url.is_empty() => SourceRecord::Prebuilt {
147                url: url.to_string(),
148            },
149            // A `kind` we do not know, or one missing the field that makes it
150            // usable, is unknown rather than a guess.
151            _ => SourceRecord::Unknown,
152        }
153    }
154}
155
156/// Write `plugin_dir`'s source marker. Best-effort: a failure is logged and
157/// the install still counts — losing the marker costs a column cell and a
158/// rebuild, not the plugin.
159pub fn write(plugin_dir: &Path, source: &PluginSource) {
160    let record = SourceRecord::from_plugin_source(source);
161    if let Err(e) = std::fs::write(plugin_dir.join(SOURCE_FILE), record.to_file()) {
162        tracing::debug!(
163            dir = %plugin_dir.display(),
164            error = %e,
165            "could not write the plugin source marker"
166        );
167    }
168}
169
170/// Read `plugin_dir`'s source marker, or [`SourceRecord::Unknown`].
171pub fn read(plugin_dir: &Path) -> SourceRecord {
172    match std::fs::read_to_string(plugin_dir.join(SOURCE_FILE)) {
173        Ok(text) => SourceRecord::parse(&text),
174        Err(_) => SourceRecord::Unknown,
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    #![allow(clippy::unwrap_used)]
181    use super::*;
182    use std::sync::atomic::{AtomicUsize, Ordering};
183
184    static COUNTER: AtomicUsize = AtomicUsize::new(0);
185
186    fn tempdir(tag: &str) -> PathBuf {
187        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
188        let dir =
189            std::env::temp_dir().join(format!("lattice-pm8-{tag}-{}-{n}", std::process::id()));
190        let _ = std::fs::remove_dir_all(&dir);
191        std::fs::create_dir_all(&dir).unwrap();
192        dir
193    }
194
195    fn roundtrip(source: PluginSource) -> SourceRecord {
196        let dir = tempdir("rt");
197        write(&dir, &source);
198        read(&dir)
199    }
200
201    #[test]
202    fn a_local_source_round_trips_with_its_path() {
203        // The path is what makes a rebuild possible; losing it would leave a
204        // chord that knows the plugin is local but not where from.
205        let got = roundtrip(PluginSource::Local(PathBuf::from("/home/u/dev/p")));
206        assert_eq!(got, SourceRecord::Local(PathBuf::from("/home/u/dev/p")));
207        assert_eq!(got.label(), "local");
208    }
209
210    #[test]
211    fn a_git_source_round_trips_with_url_and_rev() {
212        let got = roundtrip(PluginSource::Git {
213            url: "https://example.invalid/p.git".into(),
214            rev: Some("abc1234def".into()),
215        });
216        assert_eq!(
217            got,
218            SourceRecord::Git {
219                url: "https://example.invalid/p.git".into(),
220                rev: Some("abc1234def".into()),
221            }
222        );
223        assert_eq!(
224            got.label(),
225            "git@abc1234",
226            "the rev is shortened for the column"
227        );
228    }
229
230    #[test]
231    fn an_unpinned_git_source_round_trips_without_a_rev() {
232        let got = roundtrip(PluginSource::Git {
233            url: "https://example.invalid/p.git".into(),
234            rev: None,
235        });
236        assert_eq!(got.label(), "git");
237        assert!(matches!(got, SourceRecord::Git { rev: None, .. }));
238    }
239
240    #[test]
241    fn a_prebuilt_source_round_trips_with_its_url() {
242        let got = roundtrip(PluginSource::Prebuilt {
243            url: "https://example.invalid/p.wasm".into(),
244        });
245        assert_eq!(got.label(), "prebuilt");
246        assert!(
247            !got.is_buildable(),
248            "there is nothing to build for a prebuilt"
249        );
250    }
251
252    #[test]
253    fn a_directory_with_no_marker_reads_as_unknown_not_as_local() {
254        // The distinction matters: claiming `local` for a hand-installed
255        // plugin would put a wrong path in the column and offer a rebuild
256        // that cannot work.
257        let dir = tempdir("bare");
258        assert_eq!(read(&dir), SourceRecord::Unknown);
259        assert_eq!(read(&dir).label(), "—");
260        assert!(!read(&dir).is_buildable());
261    }
262
263    #[test]
264    fn a_corrupt_marker_degrades_to_unknown() {
265        let dir = tempdir("corrupt");
266        std::fs::write(dir.join(SOURCE_FILE), "!!! not a record @@@").unwrap();
267        assert_eq!(read(&dir), SourceRecord::Unknown);
268    }
269
270    #[test]
271    fn a_marker_missing_the_field_that_makes_it_usable_is_unknown() {
272        // `kind = git` with no url is not a git source we can do anything
273        // with; reporting it as one would offer a rebuild that fails.
274        let dir = tempdir("partial");
275        std::fs::write(dir.join(SOURCE_FILE), "kind = git\n").unwrap();
276        assert_eq!(read(&dir), SourceRecord::Unknown);
277    }
278
279    #[test]
280    fn only_buildable_sources_report_as_buildable() {
281        assert!(SourceRecord::Local(PathBuf::from("/x")).is_buildable());
282        assert!(
283            SourceRecord::Git {
284                url: "u".into(),
285                rev: None
286            }
287            .is_buildable()
288        );
289        assert!(!SourceRecord::Bundled.is_buildable());
290        assert!(!SourceRecord::Unknown.is_buildable());
291        assert!(
292            !SourceRecord::Prebuilt { url: "u".into() }.is_buildable(),
293            "a prebuilt is re-downloaded, not rebuilt"
294        );
295    }
296
297    #[test]
298    fn a_buildable_record_converts_back_to_a_resolver_input() {
299        // The rebuild chord's whole path: read the marker, hand it to the
300        // resolver. A conversion that dropped the rev would silently rebuild
301        // the wrong revision.
302        let rec = SourceRecord::Git {
303            url: "https://example.invalid/p.git".into(),
304            rev: Some("abc".into()),
305        };
306        assert_eq!(
307            rec.as_plugin_source(),
308            Some(PluginSource::Git {
309                url: "https://example.invalid/p.git".into(),
310                rev: Some("abc".into()),
311            })
312        );
313        assert_eq!(SourceRecord::Bundled.as_plugin_source(), None);
314    }
315
316    #[test]
317    fn a_short_rev_is_not_truncated_past_its_length() {
318        // Guards the slice: `&rev[..7]` on a 3-char rev would panic, and a
319        // user pinning a tag or a short sha is ordinary.
320        let rec = SourceRecord::Git {
321            url: "u".into(),
322            rev: Some("v1".into()),
323        };
324        assert_eq!(rec.label(), "git@v1");
325    }
326}