1use std::path::{Path, PathBuf};
33
34use crate::resolve::PluginSource;
35
36const SOURCE_FILE: &str = ".source";
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum SourceRecord {
42 Bundled,
45 Local(PathBuf),
47 Git { url: String, rev: Option<String> },
49 Prebuilt { url: String },
51 Unknown,
55}
56
57impl SourceRecord {
58 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 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 pub fn is_buildable(&self) -> bool {
78 matches!(self, SourceRecord::Local(_) | SourceRecord::Git { .. })
79 }
80
81 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 _ => SourceRecord::Unknown,
152 }
153 }
154}
155
156pub 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
170pub 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 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 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 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 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 let rec = SourceRecord::Git {
321 url: "u".into(),
322 rev: Some("v1".into()),
323 };
324 assert_eq!(rec.label(), "git@v1");
325 }
326}