1use std::path::{Path, PathBuf};
28
29use crate::build::{BuildOutcome, ComponentBuilder, build_plugin};
30use crate::resolve::{Fetcher, GitRunner, PluginSource, RefreshPolicy, Resolved, resolve};
31
32#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct RequiredSpec {
41 pub name: String,
42 pub source: PluginSource,
43 pub enable_mode: Option<String>,
46 pub pinned: bool,
47}
48
49pub fn to_required_spec(
55 p: lattice_plugin_host::plugin_manager_host::RequiredPlugin,
56) -> RequiredSpec {
57 use lattice_plugin_host::plugin_manager_host::RequiredSource as Host;
58 RequiredSpec {
59 name: p.name,
60 source: match p.source {
61 Host::Local(path) => {
68 PluginSource::Local(PathBuf::from(lattice_core::home::expand_tilde(&path)))
69 }
70 Host::Git { url, rev } => PluginSource::Git { url, rev },
71 Host::Prebuilt { url } => PluginSource::Prebuilt { url },
72 },
73 enable_mode: p.enable_mode,
74 pinned: p.pinned,
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum Install {
81 Ready {
83 name: String,
84 artifact: PathBuf,
85 enable_mode: Option<String>,
86 stale: Option<String>,
90 },
91 Skipped { name: String, error: String },
93}
94
95impl Install {
96 pub fn name(&self) -> &str {
97 match self {
98 Install::Ready { name, .. } | Install::Skipped { name, .. } => name,
99 }
100 }
101}
102
103pub fn install_required(
107 git: &dyn GitRunner,
108 fetcher: &dyn Fetcher,
109 builder: &dyn ComponentBuilder,
110 spec: &RequiredSpec,
111 cache_root: &Path,
112 user_root: &Path,
113 policy: RefreshPolicy,
114) -> Install {
115 let skip = |error: String| Install::Skipped {
116 name: spec.name.clone(),
117 error,
118 };
119
120 let resolved = match resolve(
121 git,
122 fetcher,
123 &spec.source,
124 &spec.name,
125 cache_root,
126 user_root,
127 policy,
128 ) {
129 Ok(r) => r,
130 Err(e) => {
131 tracing::warn!(plugin = %spec.name, error = %e, "require: resolve failed; skipping");
132 return skip(e);
133 }
134 };
135
136 let source_dir = match resolved {
137 Resolved::Artifact(artifact) => {
140 if let Some(dir) = artifact.parent() {
143 crate::source_record::write(dir, &spec.source);
144 }
145 return Install::Ready {
146 name: spec.name.clone(),
147 artifact,
148 enable_mode: spec.enable_mode.clone(),
149 stale: None,
150 };
151 }
152 Resolved::Source(dir) => dir,
153 };
154
155 let outcome = build_plugin(builder, &source_dir, &spec.name, user_root, spec.pinned);
156 if let Some(artifact) = outcome.artifact()
160 && let Some(dir) = artifact.parent()
161 {
162 crate::source_record::write(dir, &spec.source);
163 }
164 match outcome {
165 BuildOutcome::Cached { artifact } | BuildOutcome::Fresh { artifact } => Install::Ready {
166 name: spec.name.clone(),
167 artifact,
168 enable_mode: spec.enable_mode.clone(),
169 stale: None,
170 },
171 BuildOutcome::StaleKept { artifact, error } => Install::Ready {
172 name: spec.name.clone(),
173 artifact,
174 enable_mode: spec.enable_mode.clone(),
175 stale: Some(error),
176 },
177 BuildOutcome::Failed { error } => {
178 tracing::warn!(plugin = %spec.name, error = %error, "require: build failed; skipping");
179 skip(error)
180 }
181 }
182}
183
184pub fn install_all(
189 git: &dyn GitRunner,
190 fetcher: &dyn Fetcher,
191 builder: &dyn ComponentBuilder,
192 specs: &[RequiredSpec],
193 cache_root: &Path,
194 user_root: &Path,
195 policy: RefreshPolicy,
196) -> Vec<Install> {
197 specs
198 .iter()
199 .map(|spec| install_required(git, fetcher, builder, spec, cache_root, user_root, policy))
200 .collect()
201}
202
203#[cfg(test)]
204mod tests {
205 #![allow(clippy::unwrap_used, clippy::panic)]
206 use super::*;
207
208 #[test]
220 fn a_local_source_expands_a_leading_tilde() {
221 use lattice_plugin_host::plugin_manager_host::{RequiredPlugin, RequiredSource};
222 let spec = to_required_spec(RequiredPlugin {
223 name: "org".into(),
224 source: RequiredSource::Local("~/src/lattice-org-plugin".into()),
225 enable_mode: None,
226 pinned: false,
227 });
228 let PluginSource::Local(path) = spec.source else {
229 panic!("a Local source stays Local");
230 };
231 let home = dirs::home_dir().expect("a home directory");
232 assert_eq!(
233 path,
234 home.join("src/lattice-org-plugin"),
235 "`~` must expand against the user's home, not be taken literally"
236 );
237 }
238
239 #[test]
242 fn a_local_source_leaves_absolute_and_tilde_user_paths_alone() {
243 use lattice_plugin_host::plugin_manager_host::{RequiredPlugin, RequiredSource};
244 for raw in ["/opt/plugins/org", "~someone/org"] {
245 let spec = to_required_spec(RequiredPlugin {
246 name: "org".into(),
247 source: RequiredSource::Local(raw.into()),
248 enable_mode: None,
249 pinned: false,
250 });
251 let PluginSource::Local(path) = spec.source else {
252 panic!("a Local source stays Local");
253 };
254 assert_eq!(
255 path,
256 std::path::PathBuf::from(raw),
257 "{raw} must be verbatim"
258 );
259 }
260 }
261 use std::sync::atomic::{AtomicUsize, Ordering};
262
263 static COUNTER: AtomicUsize = AtomicUsize::new(0);
264
265 fn tempdir(tag: &str) -> PathBuf {
266 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
267 let dir =
268 std::env::temp_dir().join(format!("lattice-pm7-{tag}-{}-{n}", std::process::id()));
269 let _ = std::fs::remove_dir_all(&dir);
270 std::fs::create_dir_all(&dir).unwrap();
271 dir
272 }
273
274 struct NoGit;
275 impl GitRunner for NoGit {
276 fn run(&self, _cwd: &Path, _args: &[&str]) -> Result<String, String> {
277 Err("git unavailable".into())
278 }
279 }
280
281 struct StubFetch;
282 impl Fetcher for StubFetch {
283 fn fetch(&self, _url: &str, dest: &Path) -> Result<(), String> {
284 if let Some(p) = dest.parent() {
285 std::fs::create_dir_all(p).unwrap();
286 }
287 std::fs::write(dest, b"\0asm-prebuilt").unwrap();
288 Ok(())
289 }
290 }
291
292 struct StubBuild {
293 fail: bool,
294 calls: AtomicUsize,
295 }
296 impl StubBuild {
297 fn ok() -> Self {
298 Self {
299 fail: false,
300 calls: AtomicUsize::new(0),
301 }
302 }
303 fn failing() -> Self {
304 Self {
305 fail: true,
306 calls: AtomicUsize::new(0),
307 }
308 }
309 }
310 impl ComponentBuilder for StubBuild {
311 fn build(&self, source_dir: &Path) -> Result<PathBuf, String> {
312 self.calls.fetch_add(1, Ordering::SeqCst);
313 if self.fail {
314 return Err("compile error".into());
315 }
316 let out = source_dir
317 .parent()
318 .unwrap_or(source_dir)
319 .join("stub-out.wasm");
320 std::fs::write(&out, b"\0asm").unwrap();
321 Ok(out)
322 }
323 }
324
325 fn source_tree(root: &Path, name: &str) -> PathBuf {
326 let src = root.join(format!("{name}-src"));
327 std::fs::create_dir_all(src.join("src")).unwrap();
328 std::fs::write(src.join("plugin.toml"), format!("id = \"{name}\"\n")).unwrap();
329 std::fs::write(src.join("src").join("lib.rs"), "// v1").unwrap();
330 src
331 }
332
333 fn local(root: &Path, name: &str) -> RequiredSpec {
334 RequiredSpec {
335 name: name.to_string(),
336 source: PluginSource::Local(source_tree(root, name)),
337 enable_mode: Some(format!("{name}-mode")),
338 pinned: false,
339 }
340 }
341
342 #[test]
343 fn a_local_spec_resolves_builds_and_becomes_ready() {
344 let root = tempdir("ready");
345 let out = install_required(
346 &NoGit,
347 &StubFetch,
348 &StubBuild::ok(),
349 &local(&root, "demo"),
350 &root.join("cache"),
351 &root.join("user"),
352 RefreshPolicy::UseCache,
353 );
354 match out {
355 Install::Ready {
356 name,
357 artifact,
358 enable_mode,
359 stale,
360 } => {
361 assert_eq!(name, "demo");
362 assert!(artifact.is_file());
363 assert_eq!(enable_mode.as_deref(), Some("demo-mode"));
364 assert_eq!(stale, None);
365 }
366 other => panic!("expected Ready, got {other:?}"),
367 }
368 }
369
370 #[test]
371 fn a_prebuilt_spec_never_reaches_the_builder() {
372 let root = tempdir("prebuilt");
375 let builder = StubBuild::ok();
376 let out = install_required(
377 &NoGit,
378 &StubFetch,
379 &builder,
380 &RequiredSpec {
381 name: "pre".into(),
382 source: PluginSource::Prebuilt {
383 url: "https://example.invalid/p.wasm".into(),
384 },
385 enable_mode: None,
386 pinned: false,
387 },
388 &root.join("cache"),
389 &root.join("user"),
390 RefreshPolicy::UseCache,
391 );
392 assert!(matches!(out, Install::Ready { .. }));
393 assert_eq!(
394 builder.calls.load(Ordering::SeqCst),
395 0,
396 "a prebuilt must skip the build entirely"
397 );
398 }
399
400 #[test]
401 fn a_resolve_failure_is_a_skip_not_a_panic() {
402 let root = tempdir("resolve-fail");
403 let out = install_required(
404 &NoGit,
405 &StubFetch,
406 &StubBuild::ok(),
407 &RequiredSpec {
408 name: "gone".into(),
409 source: PluginSource::Local(root.join("does-not-exist")),
410 enable_mode: None,
411 pinned: false,
412 },
413 &root.join("cache"),
414 &root.join("user"),
415 RefreshPolicy::UseCache,
416 );
417 match out {
418 Install::Skipped { name, error } => {
419 assert_eq!(name, "gone");
420 assert!(error.contains("not a directory"), "got: {error}");
421 }
422 other => panic!("expected Skipped, got {other:?}"),
423 }
424 }
425
426 #[test]
427 fn a_build_failure_with_no_prior_artifact_is_a_skip() {
428 let root = tempdir("build-fail");
429 let out = install_required(
430 &NoGit,
431 &StubFetch,
432 &StubBuild::failing(),
433 &local(&root, "broken"),
434 &root.join("cache"),
435 &root.join("user"),
436 RefreshPolicy::UseCache,
437 );
438 assert!(matches!(out, Install::Skipped { .. }));
439 }
440
441 #[test]
442 fn a_stale_rebuild_failure_still_installs_and_reports_the_caveat() {
443 let root = tempdir("stale");
446 let user = root.join("user");
447 let spec = local(&root, "demo");
448 install_required(
449 &NoGit,
450 &StubFetch,
451 &StubBuild::ok(),
452 &spec,
453 &root.join("cache"),
454 &user,
455 RefreshPolicy::UseCache,
456 );
457
458 let PluginSource::Local(ref dir) = spec.source else {
460 unreachable!()
461 };
462 let f = dir.join("src").join("lib.rs");
463 std::fs::write(&f, "// broken").unwrap();
464 let later = std::time::SystemTime::now() + std::time::Duration::from_secs(120);
465 let file = std::fs::OpenOptions::new().write(true).open(&f).unwrap();
466 file.set_times(std::fs::FileTimes::new().set_modified(later))
467 .unwrap();
468
469 let out = install_required(
470 &NoGit,
471 &StubFetch,
472 &StubBuild::failing(),
473 &spec,
474 &root.join("cache"),
475 &user,
476 RefreshPolicy::UseCache,
477 );
478 match out {
479 Install::Ready {
480 stale, artifact, ..
481 } => {
482 assert!(artifact.is_file(), "the previous build still loads");
483 assert!(
484 stale.unwrap().contains("compile error"),
485 "the caveat must be reportable, not silent"
486 );
487 }
488 other => panic!("expected a stale Ready, got {other:?}"),
489 }
490 }
491
492 #[test]
493 fn one_broken_spec_does_not_stop_the_others() {
494 let root = tempdir("install-all");
496 let specs = vec![
497 local(&root, "first"),
498 RequiredSpec {
499 name: "broken".into(),
500 source: PluginSource::Local(root.join("nope")),
501 enable_mode: None,
502 pinned: false,
503 },
504 local(&root, "third"),
505 ];
506 let out = install_all(
507 &NoGit,
508 &StubFetch,
509 &StubBuild::ok(),
510 &specs,
511 &root.join("cache"),
512 &root.join("user"),
513 RefreshPolicy::UseCache,
514 );
515
516 assert_eq!(out.len(), 3, "every spec is attempted");
517 assert!(matches!(out[0], Install::Ready { .. }));
518 assert!(matches!(out[1], Install::Skipped { .. }));
519 assert!(
520 matches!(out[2], Install::Ready { .. }),
521 "a failure must not abort the specs after it"
522 );
523 }
524
525 #[test]
526 fn the_host_to_loader_conversion_preserves_every_field() {
527 use lattice_plugin_host::plugin_manager_host::{RequiredPlugin, RequiredSource};
532 let got = to_required_spec(RequiredPlugin {
533 name: "demo".into(),
534 source: RequiredSource::Git {
535 url: "https://example.invalid/d.git".into(),
536 rev: Some("abc".into()),
537 },
538 enable_mode: Some("demo-mode".into()),
539 pinned: true,
540 });
541 assert_eq!(
542 got,
543 RequiredSpec {
544 name: "demo".into(),
545 source: PluginSource::Git {
546 url: "https://example.invalid/d.git".into(),
547 rev: Some("abc".into()),
548 },
549 enable_mode: Some("demo-mode".into()),
550 pinned: true,
551 }
552 );
553 }
554
555 #[test]
556 fn every_source_kind_survives_the_conversion() {
557 use lattice_plugin_host::plugin_manager_host::{RequiredPlugin, RequiredSource};
558 let spec = |source| RequiredPlugin {
559 name: "d".into(),
560 source,
561 enable_mode: None,
562 pinned: false,
563 };
564 assert_eq!(
565 to_required_spec(spec(RequiredSource::Local("/tmp/x".into()))).source,
566 PluginSource::Local(PathBuf::from("/tmp/x"))
567 );
568 assert_eq!(
569 to_required_spec(spec(RequiredSource::Prebuilt {
570 url: "https://example.invalid/x.wasm".into()
571 }))
572 .source,
573 PluginSource::Prebuilt {
574 url: "https://example.invalid/x.wasm".into()
575 }
576 );
577 }
578
579 #[test]
580 fn declaration_order_is_preserved() {
581 let root = tempdir("order");
584 let specs = vec![
585 local(&root, "aaa"),
586 local(&root, "bbb"),
587 local(&root, "ccc"),
588 ];
589 let out = install_all(
590 &NoGit,
591 &StubFetch,
592 &StubBuild::ok(),
593 &specs,
594 &root.join("cache"),
595 &root.join("user"),
596 RefreshPolicy::UseCache,
597 );
598 let names: Vec<&str> = out.iter().map(|i| i.name()).collect();
599 assert_eq!(names, vec!["aaa", "bbb", "ccc"]);
600 }
601}