Skip to main content

lemma_cli/
install.rs

1//! Shared LemmaBase install for CLI `install` and MCP `install`.
2//!
3//! Install = download + persist into `lemma_deps/`.
4
5use crate::deps::{lemma_deps_dir, relative_dependency_cache_path};
6use crate::workspace::{self, WorkspaceDiskError};
7use lemma::Engine;
8use lemma::{Fetch, Header, HttpResponse, HttpTransport, Install, Registries, TransportFailure};
9use std::collections::HashSet;
10use std::fs;
11use std::io::{self, Write};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, OnceLock};
14use walkdir::WalkDir;
15
16fn registry_runtime() -> &'static tokio::runtime::Runtime {
17    static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
18    RUNTIME.get_or_init(|| {
19        tokio::runtime::Runtime::new().expect("BUG: failed to create registry install runtime")
20    })
21}
22
23/// Drive an async future on the process-wide install runtime.
24pub fn block_on_registry<F: std::future::Future>(future: F) -> F::Output {
25    registry_runtime().block_on(future)
26}
27
28// ---------------------------------------------------------------------------
29// ReqwestTransport — host-owned HTTP for LemmaBase
30// ---------------------------------------------------------------------------
31
32pub struct ReqwestTransport {
33    http: reqwest::Client,
34}
35
36impl ReqwestTransport {
37    pub fn new() -> Self {
38        let http = reqwest::Client::builder()
39            .timeout(std::time::Duration::from_secs(30))
40            .build()
41            .expect("BUG: reqwest client builder must succeed with default TLS");
42        Self { http }
43    }
44}
45
46impl Default for ReqwestTransport {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl HttpTransport for ReqwestTransport {
53    fn get(&self, fetch: &Fetch) -> Result<HttpResponse, TransportFailure> {
54        block_on_registry(async {
55            let mut request = self.http.get(&fetch.url);
56            for header in &fetch.headers {
57                request = request.header(&header.name, &header.value);
58            }
59            let response = request.send().await.map_err(|e| TransportFailure {
60                message: e.to_string(),
61            })?;
62            let status = response.status().as_u16();
63            let mut headers = Vec::new();
64            for (name, value) in response.headers() {
65                match value.to_str() {
66                    Ok(value) => headers.push(Header {
67                        name: name.as_str().to_string(),
68                        value: value.to_string(),
69                    }),
70                    Err(_) => {
71                        return Err(TransportFailure {
72                            message: format!(
73                                "response header '{}' is not valid UTF-8",
74                                name.as_str()
75                            ),
76                        });
77                    }
78                }
79            }
80            let body = response.text().await.map_err(|e| TransportFailure {
81                message: e.to_string(),
82            })?;
83            Ok(HttpResponse {
84                status,
85                headers,
86                body,
87            })
88        })
89    }
90}
91
92// ---------------------------------------------------------------------------
93// InstallOutcome / InstallError
94// ---------------------------------------------------------------------------
95
96/// Result of a successful single-id registry install.
97#[derive(Debug)]
98pub enum InstallOutcome {
99    Written {
100        relative_path: PathBuf,
101        source: String,
102    },
103    AlreadyUpToDate {
104        relative_path: PathBuf,
105        source: String,
106    },
107}
108
109/// Failure installing a LemmaBase repository.
110pub enum InstallError {
111    Registry(lemma::Error),
112    UnparseableRegistry(lemma::Error),
113    Conflict {
114        spec_names: Vec<String>,
115        path: PathBuf,
116    },
117    Io(io::Error),
118    Workspace(WorkspaceDiskError),
119    Plan(lemma::Errors),
120}
121
122impl std::fmt::Debug for InstallError {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        write!(f, "{self}")
125    }
126}
127
128impl std::fmt::Display for InstallError {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        match self {
131            Self::Registry(error) => write!(f, "LemmaBase error: {error}"),
132            Self::UnparseableRegistry(error) => {
133                write!(f, "LemmaBase returned unparseable repository: {error}")
134            }
135            Self::Conflict { spec_names, path } => write!(
136                f,
137                "Repository containing spec(s) {} already exists in {}.\n\
138                 Content has changed on LemmaBase. Re-run with --force to overwrite.",
139                spec_names.join(", "),
140                path.display()
141            ),
142            Self::Io(error) => write!(f, "{error}"),
143            Self::Workspace(error) => write!(f, "{error}"),
144            Self::Plan(errors) => write!(
145                f,
146                "Planning installed repository failed ({} error(s))",
147                errors.errors.len()
148            ),
149        }
150    }
151}
152
153impl From<io::Error> for InstallError {
154    fn from(error: io::Error) -> Self {
155        Self::Io(error)
156    }
157}
158
159impl From<WorkspaceDiskError> for InstallError {
160    fn from(error: WorkspaceDiskError) -> Self {
161        match error {
162            WorkspaceDiskError::EngineLoad(errors) => Self::Plan(errors),
163            other => Self::Workspace(other),
164        }
165    }
166}
167
168/// Download, conflict-scan, plan-probe, and persist one LemmaBase repository.
169pub fn install_registry_dependency<T: HttpTransport>(
170    workdir: &Path,
171    id: &str,
172    force: bool,
173    registries: &Registries,
174    transport: &T,
175) -> Result<InstallOutcome, InstallError> {
176    let result = Install::run(registries, id, transport, lemma::ResourceLimits::default())
177        .map_err(|error| {
178            if error.kind() == lemma::ErrorKind::Parsing {
179                InstallError::UnparseableRegistry(error)
180            } else {
181                InstallError::Registry(error)
182            }
183        })?;
184    persist_registry_dependency(workdir, &result.id, &result.source, force)
185}
186
187fn spec_names_from_source(repository: &str, source: &str) -> Result<HashSet<String>, InstallError> {
188    let limits = lemma::ResourceLimits::default();
189    let specs = lemma::parse(
190        source,
191        lemma::SourceType::Dependency(repository.to_string()),
192        &limits,
193    )
194    .map_err(InstallError::UnparseableRegistry)?
195    .into_flattened_specs();
196    Ok(specs.into_iter().map(|spec| spec.name).collect())
197}
198
199/// Conflict-scan, plan-probe, and persist one LemmaBase repository source.
200///
201/// Used by single-id install after download, and by `install --all` after
202/// `Resolve::run` (no second download).
203///
204/// Order: write tmp → plan probe → on fail remove tmp; on ok remove
205/// `conflict_paths` (when force) then rename tmp → `destination_absolute`.
206pub fn persist_registry_dependency(
207    workdir: &Path,
208    id: &str,
209    source: &str,
210    force: bool,
211) -> Result<InstallOutcome, InstallError> {
212    let spec_names = spec_names_from_source(id, source)?;
213    let scan = scan_lemma_deps(workdir, id, &spec_names)?;
214
215    if scan.destination_absolute.is_file() {
216        let existing = fs::read_to_string(&scan.destination_absolute)?;
217        if existing == source {
218            return Ok(InstallOutcome::AlreadyUpToDate {
219                relative_path: scan.destination_relative,
220                source: source.to_string(),
221            });
222        }
223    }
224
225    if !scan.conflict_paths.is_empty() && !force {
226        let path = scan.conflict_paths[0].clone();
227        let existing_content = fs::read_to_string(&path)?;
228        let limits = lemma::ResourceLimits::default();
229        let existing_specs = lemma::parse(
230            &existing_content,
231            lemma::SourceType::Path(Arc::new(path.clone())),
232            &limits,
233        )
234        .map_err(|error| {
235            InstallError::Io(io::Error::new(
236                io::ErrorKind::InvalidData,
237                format!("unparseable lemma_deps file {}: {error}", path.display()),
238            ))
239        })?
240        .into_flattened_specs();
241        let conflict: Vec<String> = existing_specs
242            .iter()
243            .filter(|spec| spec_names.contains(&spec.name))
244            .map(|spec| spec.name.clone())
245            .collect();
246        return Err(InstallError::Conflict {
247            spec_names: conflict,
248            path,
249        });
250    }
251
252    let tmp = write_tmp(&scan.destination_absolute, source)?;
253
254    let mut exclude: Vec<&Path> = vec![scan.destination_absolute.as_path()];
255    if force {
256        for path in &scan.conflict_paths {
257            exclude.push(path.as_path());
258        }
259    }
260
261    let probe_result = (|| -> Result<(), InstallError> {
262        let mut probe = Engine::new();
263        workspace::load_workspace_excluding(&mut probe, workdir, &exclude)?;
264        probe
265            .load([(
266                lemma::SourceType::Dependency(id.to_string()),
267                source.to_string(),
268            )])
269            .map_err(InstallError::Plan)?;
270        Ok(())
271    })();
272
273    if let Err(error) = probe_result {
274        let _ = fs::remove_file(&tmp);
275        return Err(error);
276    }
277
278    if force {
279        for path in &scan.conflict_paths {
280            if path != &scan.destination_absolute {
281                fs::remove_file(path)?;
282            }
283        }
284    }
285
286    rename_tmp(&tmp, &scan.destination_absolute)?;
287
288    Ok(InstallOutcome::Written {
289        relative_path: scan.destination_relative,
290        source: source.to_string(),
291    })
292}
293
294/// Result of walking `lemma_deps/` against a candidate repository source.
295pub struct LemmaDepsScan {
296    pub destination_absolute: PathBuf,
297    pub destination_relative: PathBuf,
298    /// Paths that define overlapping spec names with the candidate.
299    pub conflict_paths: Vec<PathBuf>,
300}
301
302/// Walk `lemma_deps/` for spec-name conflicts.
303/// Unparseable or unreadable `.lemma` files are errors.
304pub fn scan_lemma_deps(
305    workdir: &Path,
306    repository: &str,
307    new_spec_names: &HashSet<String>,
308) -> Result<LemmaDepsScan, InstallError> {
309    let deps_dir = lemma_deps_dir(workdir);
310    let destination_relative = relative_dependency_cache_path(repository);
311    let destination_absolute = deps_dir.join(&destination_relative);
312    let limits = lemma::ResourceLimits::default();
313
314    let mut conflict_paths = Vec::new();
315
316    if deps_dir.exists() {
317        for entry in WalkDir::new(&deps_dir) {
318            let entry = entry.map_err(|error| InstallError::Io(io::Error::other(error)))?;
319            if entry.path().extension().and_then(|s| s.to_str()) != Some("lemma") {
320                continue;
321            }
322            let path = entry.path();
323            let existing_content = fs::read_to_string(path)?;
324            let existing_specs = lemma::parse(
325                &existing_content,
326                lemma::SourceType::Path(Arc::new(path.to_path_buf())),
327                &limits,
328            )
329            .map_err(|error| {
330                InstallError::Io(io::Error::new(
331                    io::ErrorKind::InvalidData,
332                    format!("unparseable lemma_deps file {}: {error}", path.display()),
333                ))
334            })?
335            .into_flattened_specs();
336            let has_conflict = existing_specs
337                .iter()
338                .any(|spec| new_spec_names.contains(&spec.name));
339            if has_conflict {
340                conflict_paths.push(path.to_path_buf());
341            }
342        }
343    }
344
345    Ok(LemmaDepsScan {
346        destination_absolute,
347        destination_relative,
348        conflict_paths,
349    })
350}
351
352/// Temp path beside `path`: `.{file_name}.tmp` in the same directory.
353fn tmp_path_for(path: &Path) -> io::Result<PathBuf> {
354    let file_name = path.file_name().ok_or_else(|| {
355        io::Error::new(
356            io::ErrorKind::InvalidInput,
357            "persist path must include a file name",
358        )
359    })?;
360    let dir = match path.parent() {
361        Some(p) if !p.as_os_str().is_empty() => p,
362        _ => Path::new("."),
363    };
364    fs::create_dir_all(dir)?;
365    Ok(dir.join(format!(".{}.tmp", file_name.to_string_lossy())))
366}
367
368/// Write `contents` to `.{file}.tmp` beside `path`, fsync. Does not rename.
369pub fn write_tmp(path: &Path, contents: &str) -> io::Result<PathBuf> {
370    let tmp = tmp_path_for(path)?;
371    match (|| -> io::Result<()> {
372        let mut f = fs::File::create(&tmp)?;
373        f.write_all(contents.as_bytes())?;
374        f.sync_all()?;
375        Ok(())
376    })() {
377        Ok(()) => Ok(tmp),
378        Err(e) => {
379            let _ = fs::remove_file(&tmp);
380            Err(e)
381        }
382    }
383}
384
385/// Rename a temp file produced by [`write_tmp`] onto `path`.
386pub fn rename_tmp(tmp: &Path, path: &Path) -> io::Result<()> {
387    fs::rename(tmp, path)
388}
389
390/// Atomic write: [`write_tmp`] then [`rename_tmp`].
391pub fn atomic_write(path: &Path, contents: &str) -> io::Result<()> {
392    let tmp = write_tmp(path, contents)?;
393    match rename_tmp(&tmp, path) {
394        Ok(()) => Ok(()),
395        Err(e) => {
396            let _ = fs::remove_file(&tmp);
397            Err(e)
398        }
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use std::collections::HashSet;
406
407    fn test_transport() -> (Registries, lemma::__test_support::FixtureTransport) {
408        (
409            Registries::default(),
410            lemma::__test_support::FixtureTransport::bundled(),
411        )
412    }
413
414    #[test]
415    fn write_tmp_then_rename_tmp_round_trip() {
416        let dir = tempfile::tempdir().unwrap();
417        let path = dir.path().join("out.lemma");
418        let tmp = write_tmp(&path, "spec x\ndata a: 1\n").unwrap();
419        assert!(tmp.exists(), "tmp must exist before rename");
420        assert!(!path.exists(), "final path must not exist before rename");
421        rename_tmp(&tmp, &path).unwrap();
422        assert_eq!(fs::read_to_string(&path).unwrap(), "spec x\ndata a: 1\n");
423        assert!(!tmp.exists(), "tmp must be gone after rename");
424    }
425
426    #[test]
427    fn atomic_write_round_trip() {
428        let dir = tempfile::tempdir().unwrap();
429        let path = dir.path().join("out.lemma");
430        atomic_write(&path, "spec x\ndata a: 1\n").unwrap();
431        assert_eq!(fs::read_to_string(&path).unwrap(), "spec x\ndata a: 1\n");
432        assert!(
433            !dir.path().join(".out.lemma.tmp").exists(),
434            "temp file must be cleaned up"
435        );
436    }
437
438    #[test]
439    fn scan_lemma_deps_rejects_unparseable_cache_file() {
440        let dir = tempfile::tempdir().unwrap();
441        let junk = dir.path().join("lemma_deps").join("@junk");
442        fs::create_dir_all(&junk).unwrap();
443        fs::write(junk.join("corrupt.lemma"), "{{{not valid lemma").unwrap();
444
445        let mut names = HashSet::new();
446        names.insert("alpha2".to_string());
447
448        let result = scan_lemma_deps(dir.path(), "@iso/countries", &names);
449        assert!(
450            result.is_err(),
451            "unparseable lemma_deps .lemma must hard-error"
452        );
453    }
454
455    #[test]
456    fn install_writes_fixture_dependency() {
457        let dir = tempfile::tempdir().unwrap();
458        let (registries, transport) = test_transport();
459        let outcome = install_registry_dependency(
460            dir.path(),
461            "@iso/countries",
462            false,
463            &registries,
464            &transport,
465        )
466        .unwrap();
467        match outcome {
468            InstallOutcome::Written { relative_path, .. } => {
469                assert_eq!(relative_path, PathBuf::from("@iso").join("countries.lemma"));
470            }
471            InstallOutcome::AlreadyUpToDate { .. } => panic!("expected Written"),
472        }
473        assert!(dir
474            .path()
475            .join("lemma_deps")
476            .join("@iso")
477            .join("countries.lemma")
478            .exists());
479        assert!(
480            !dir.path()
481                .join("lemma_deps")
482                .join("@iso")
483                .join(".countries.lemma.tmp")
484                .exists(),
485            "tmp must not remain after success"
486        );
487    }
488
489    #[test]
490    fn install_destination_match_is_up_to_date() {
491        let dir = tempfile::tempdir().unwrap();
492        let (registries, transport) = test_transport();
493        install_registry_dependency(dir.path(), "@iso/countries", false, &registries, &transport)
494            .unwrap();
495        let second = install_registry_dependency(
496            dir.path(),
497            "@iso/countries",
498            false,
499            &registries,
500            &transport,
501        )
502        .unwrap();
503        assert!(
504            matches!(second, InstallOutcome::AlreadyUpToDate { .. }),
505            "canonical destination match must be up to date"
506        );
507    }
508
509    #[test]
510    fn install_identical_content_elsewhere_conflicts_without_force() {
511        let dir = tempfile::tempdir().unwrap();
512        let (registries, transport) = test_transport();
513        install_registry_dependency(dir.path(), "@iso/countries", false, &registries, &transport)
514            .unwrap();
515        let other = dir
516            .path()
517            .join("lemma_deps")
518            .join("@other")
519            .join("copy.lemma");
520        fs::create_dir_all(other.parent().unwrap()).unwrap();
521        let dest = dir
522            .path()
523            .join("lemma_deps")
524            .join("@iso")
525            .join("countries.lemma");
526        fs::rename(&dest, &other).unwrap();
527
528        let err = install_registry_dependency(
529            dir.path(),
530            "@iso/countries",
531            false,
532            &registries,
533            &transport,
534        )
535        .expect_err("foreign copy with overlapping specs must conflict");
536        assert!(
537            matches!(err, InstallError::Conflict { .. }),
538            "expected Conflict, got: {err}"
539        );
540        assert!(!dest.exists(), "conflict must not write destination");
541        assert!(other.exists(), "foreign copy must remain without force");
542    }
543
544    #[test]
545    fn install_identical_content_elsewhere_force_writes_canonical() {
546        let dir = tempfile::tempdir().unwrap();
547        let (registries, transport) = test_transport();
548        let first = install_registry_dependency(
549            dir.path(),
550            "@iso/countries",
551            false,
552            &registries,
553            &transport,
554        )
555        .unwrap();
556        let source = match first {
557            InstallOutcome::Written { source, .. } => source,
558            InstallOutcome::AlreadyUpToDate { source, .. } => source,
559        };
560        let other = dir
561            .path()
562            .join("lemma_deps")
563            .join("@other")
564            .join("copy.lemma");
565        fs::create_dir_all(other.parent().unwrap()).unwrap();
566        let dest = dir
567            .path()
568            .join("lemma_deps")
569            .join("@iso")
570            .join("countries.lemma");
571        fs::rename(&dest, &other).unwrap();
572
573        let second = install_registry_dependency(
574            dir.path(),
575            "@iso/countries",
576            true,
577            &registries,
578            &transport,
579        )
580        .unwrap();
581        match second {
582            InstallOutcome::Written { relative_path, .. } => {
583                assert_eq!(relative_path, PathBuf::from("@iso").join("countries.lemma"));
584            }
585            InstallOutcome::AlreadyUpToDate { .. } => {
586                panic!("force install must write canonical destination")
587            }
588        }
589        assert!(dest.exists(), "canonical destination must be written");
590        assert_eq!(fs::read_to_string(&dest).unwrap(), source);
591        assert!(
592            !other.exists(),
593            "force must remove conflicting foreign copy"
594        );
595        assert!(
596            !dest
597                .parent()
598                .expect("parent")
599                .join(".countries.lemma.tmp")
600                .exists(),
601            "tmp must not remain after force success"
602        );
603    }
604
605    #[test]
606    fn install_workspace_spec_name_collision_is_plan_error() {
607        let dir = tempfile::tempdir().unwrap();
608        fs::write(
609            dir.path().join("local.lemma"),
610            "repo @iso/countries\nspec alpha2\ndata code: text\n",
611        )
612        .unwrap();
613        let (registries, transport) = test_transport();
614        let err = install_registry_dependency(
615            dir.path(),
616            "@iso/countries",
617            false,
618            &registries,
619            &transport,
620        )
621        .expect_err("workspace name collision must fail plan probe");
622        assert!(
623            matches!(err, InstallError::Plan(_)),
624            "expected Plan, got: {err}"
625        );
626        assert!(
627            !dir.path()
628                .join("lemma_deps")
629                .join("@iso")
630                .join("countries.lemma")
631                .exists(),
632            "plan failure must not write destination"
633        );
634        assert!(
635            !dir.path()
636                .join("lemma_deps")
637                .join("@iso")
638                .join(".countries.lemma.tmp")
639                .exists(),
640            "plan failure must not leave tmp"
641        );
642    }
643
644    #[test]
645    fn install_force_plan_failure_leaves_conflict_paths_and_destination() {
646        let dir = tempfile::tempdir().unwrap();
647        let (registries, transport) = test_transport();
648        let first = install_registry_dependency(
649            dir.path(),
650            "@iso/countries",
651            false,
652            &registries,
653            &transport,
654        )
655        .unwrap();
656        let source = match first {
657            InstallOutcome::Written { source, .. } => source,
658            InstallOutcome::AlreadyUpToDate { source, .. } => source,
659        };
660        let other = dir
661            .path()
662            .join("lemma_deps")
663            .join("@other")
664            .join("copy.lemma");
665        fs::create_dir_all(other.parent().unwrap()).unwrap();
666        let dest = dir
667            .path()
668            .join("lemma_deps")
669            .join("@iso")
670            .join("countries.lemma");
671        fs::rename(&dest, &other).unwrap();
672
673        fs::write(
674            dir.path().join("local.lemma"),
675            "repo @iso/countries\nspec alpha2\ndata code: text\n",
676        )
677        .unwrap();
678
679        let err = install_registry_dependency(
680            dir.path(),
681            "@iso/countries",
682            true,
683            &registries,
684            &transport,
685        )
686        .expect_err("workspace collision must fail even with force");
687        assert!(
688            matches!(err, InstallError::Plan(_)),
689            "expected Plan, got: {err}"
690        );
691        assert!(
692            other.exists(),
693            "force plan failure must leave foreign conflict file"
694        );
695        assert_eq!(fs::read_to_string(&other).unwrap(), source);
696        assert!(!dest.exists(), "plan failure must not create destination");
697        assert!(
698            !dest
699                .parent()
700                .expect("parent")
701                .join(".countries.lemma.tmp")
702                .exists(),
703            "plan failure must not leave tmp"
704        );
705    }
706
707    #[test]
708    fn install_non_at_id_reaches_registry() {
709        let dir = tempfile::tempdir().unwrap();
710        let (registries, transport) = test_transport();
711        let err = install_registry_dependency(
712            dir.path(),
713            "not-a-registry-id",
714            false,
715            &registries,
716            &transport,
717        )
718        .expect_err("non-@ id must fail via registry");
719        assert!(
720            matches!(err, InstallError::Registry(_)),
721            "error must be Registry, got: {err}"
722        );
723    }
724}