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        remove_tmp_after_failure(&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            remove_tmp_after_failure(&tmp);
380            Err(e)
381        }
382    }
383}
384
385fn remove_tmp_after_failure(tmp: &Path) {
386    if let Err(error) = fs::remove_file(tmp) {
387        if error.kind() != io::ErrorKind::NotFound {
388            tracing::warn!("failed to remove temp file {}: {error}", tmp.display());
389        }
390    }
391}
392
393/// Rename a temp file produced by [`write_tmp`] onto `path`.
394pub fn rename_tmp(tmp: &Path, path: &Path) -> io::Result<()> {
395    fs::rename(tmp, path)
396}
397
398/// Atomic write: [`write_tmp`] then [`rename_tmp`].
399pub fn atomic_write(path: &Path, contents: &str) -> io::Result<()> {
400    let tmp = write_tmp(path, contents)?;
401    match rename_tmp(&tmp, path) {
402        Ok(()) => Ok(()),
403        Err(e) => {
404            remove_tmp_after_failure(&tmp);
405            Err(e)
406        }
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use std::collections::HashSet;
414
415    fn test_transport() -> (Registries, lemma::__test_support::FixtureTransport) {
416        (
417            Registries::default(),
418            lemma::__test_support::FixtureTransport::bundled(),
419        )
420    }
421
422    #[test]
423    fn write_tmp_then_rename_tmp_round_trip() {
424        let dir = tempfile::tempdir().unwrap();
425        let path = dir.path().join("out.lemma");
426        let tmp = write_tmp(&path, "spec x\ndata a: 1\n").unwrap();
427        assert!(tmp.exists(), "tmp must exist before rename");
428        assert!(!path.exists(), "final path must not exist before rename");
429        rename_tmp(&tmp, &path).unwrap();
430        assert_eq!(fs::read_to_string(&path).unwrap(), "spec x\ndata a: 1\n");
431        assert!(!tmp.exists(), "tmp must be gone after rename");
432    }
433
434    #[test]
435    fn atomic_write_round_trip() {
436        let dir = tempfile::tempdir().unwrap();
437        let path = dir.path().join("out.lemma");
438        atomic_write(&path, "spec x\ndata a: 1\n").unwrap();
439        assert_eq!(fs::read_to_string(&path).unwrap(), "spec x\ndata a: 1\n");
440        assert!(
441            !dir.path().join(".out.lemma.tmp").exists(),
442            "temp file must be cleaned up"
443        );
444    }
445
446    #[test]
447    fn scan_lemma_deps_rejects_unparseable_cache_file() {
448        let dir = tempfile::tempdir().unwrap();
449        let junk = dir.path().join("lemma_deps").join("@junk");
450        fs::create_dir_all(&junk).unwrap();
451        fs::write(junk.join("corrupt.lemma"), "{{{not valid lemma").unwrap();
452
453        let mut names = HashSet::new();
454        names.insert("alpha2".to_string());
455
456        let result = scan_lemma_deps(dir.path(), "@iso/countries", &names);
457        assert!(
458            result.is_err(),
459            "unparseable lemma_deps .lemma must hard-error"
460        );
461    }
462
463    #[test]
464    fn install_writes_fixture_dependency() {
465        let dir = tempfile::tempdir().unwrap();
466        let (registries, transport) = test_transport();
467        let outcome = install_registry_dependency(
468            dir.path(),
469            "@iso/countries",
470            false,
471            &registries,
472            &transport,
473        )
474        .unwrap();
475        match outcome {
476            InstallOutcome::Written { relative_path, .. } => {
477                assert_eq!(relative_path, PathBuf::from("@iso").join("countries.lemma"));
478            }
479            InstallOutcome::AlreadyUpToDate { .. } => panic!("expected Written"),
480        }
481        assert!(dir
482            .path()
483            .join("lemma_deps")
484            .join("@iso")
485            .join("countries.lemma")
486            .exists());
487        assert!(
488            !dir.path()
489                .join("lemma_deps")
490                .join("@iso")
491                .join(".countries.lemma.tmp")
492                .exists(),
493            "tmp must not remain after success"
494        );
495    }
496
497    #[test]
498    fn install_destination_match_is_up_to_date() {
499        let dir = tempfile::tempdir().unwrap();
500        let (registries, transport) = test_transport();
501        install_registry_dependency(dir.path(), "@iso/countries", false, &registries, &transport)
502            .unwrap();
503        let second = install_registry_dependency(
504            dir.path(),
505            "@iso/countries",
506            false,
507            &registries,
508            &transport,
509        )
510        .unwrap();
511        assert!(
512            matches!(second, InstallOutcome::AlreadyUpToDate { .. }),
513            "canonical destination match must be up to date"
514        );
515    }
516
517    #[test]
518    fn install_identical_content_elsewhere_conflicts_without_force() {
519        let dir = tempfile::tempdir().unwrap();
520        let (registries, transport) = test_transport();
521        install_registry_dependency(dir.path(), "@iso/countries", false, &registries, &transport)
522            .unwrap();
523        let other = dir
524            .path()
525            .join("lemma_deps")
526            .join("@other")
527            .join("copy.lemma");
528        fs::create_dir_all(other.parent().unwrap()).unwrap();
529        let dest = dir
530            .path()
531            .join("lemma_deps")
532            .join("@iso")
533            .join("countries.lemma");
534        fs::rename(&dest, &other).unwrap();
535
536        let err = install_registry_dependency(
537            dir.path(),
538            "@iso/countries",
539            false,
540            &registries,
541            &transport,
542        )
543        .expect_err("foreign copy with overlapping specs must conflict");
544        assert!(
545            matches!(err, InstallError::Conflict { .. }),
546            "expected Conflict, got: {err}"
547        );
548        assert!(!dest.exists(), "conflict must not write destination");
549        assert!(other.exists(), "foreign copy must remain without force");
550    }
551
552    #[test]
553    fn install_identical_content_elsewhere_force_writes_canonical() {
554        let dir = tempfile::tempdir().unwrap();
555        let (registries, transport) = test_transport();
556        let first = install_registry_dependency(
557            dir.path(),
558            "@iso/countries",
559            false,
560            &registries,
561            &transport,
562        )
563        .unwrap();
564        let source = match first {
565            InstallOutcome::Written { source, .. } => source,
566            InstallOutcome::AlreadyUpToDate { source, .. } => source,
567        };
568        let other = dir
569            .path()
570            .join("lemma_deps")
571            .join("@other")
572            .join("copy.lemma");
573        fs::create_dir_all(other.parent().unwrap()).unwrap();
574        let dest = dir
575            .path()
576            .join("lemma_deps")
577            .join("@iso")
578            .join("countries.lemma");
579        fs::rename(&dest, &other).unwrap();
580
581        let second = install_registry_dependency(
582            dir.path(),
583            "@iso/countries",
584            true,
585            &registries,
586            &transport,
587        )
588        .unwrap();
589        match second {
590            InstallOutcome::Written { relative_path, .. } => {
591                assert_eq!(relative_path, PathBuf::from("@iso").join("countries.lemma"));
592            }
593            InstallOutcome::AlreadyUpToDate { .. } => {
594                panic!("force install must write canonical destination")
595            }
596        }
597        assert!(dest.exists(), "canonical destination must be written");
598        assert_eq!(fs::read_to_string(&dest).unwrap(), source);
599        assert!(
600            !other.exists(),
601            "force must remove conflicting foreign copy"
602        );
603        assert!(
604            !dest
605                .parent()
606                .expect("parent")
607                .join(".countries.lemma.tmp")
608                .exists(),
609            "tmp must not remain after force success"
610        );
611    }
612
613    #[test]
614    fn install_workspace_spec_name_collision_is_plan_error() {
615        let dir = tempfile::tempdir().unwrap();
616        fs::write(
617            dir.path().join("local.lemma"),
618            "repo @iso/countries\nspec alpha2\ndata code: text\n",
619        )
620        .unwrap();
621        let (registries, transport) = test_transport();
622        let err = install_registry_dependency(
623            dir.path(),
624            "@iso/countries",
625            false,
626            &registries,
627            &transport,
628        )
629        .expect_err("workspace name collision must fail plan probe");
630        assert!(
631            matches!(err, InstallError::Plan(_)),
632            "expected Plan, got: {err}"
633        );
634        assert!(
635            !dir.path()
636                .join("lemma_deps")
637                .join("@iso")
638                .join("countries.lemma")
639                .exists(),
640            "plan failure must not write destination"
641        );
642        assert!(
643            !dir.path()
644                .join("lemma_deps")
645                .join("@iso")
646                .join(".countries.lemma.tmp")
647                .exists(),
648            "plan failure must not leave tmp"
649        );
650    }
651
652    #[test]
653    fn install_force_plan_failure_leaves_conflict_paths_and_destination() {
654        let dir = tempfile::tempdir().unwrap();
655        let (registries, transport) = test_transport();
656        let first = install_registry_dependency(
657            dir.path(),
658            "@iso/countries",
659            false,
660            &registries,
661            &transport,
662        )
663        .unwrap();
664        let source = match first {
665            InstallOutcome::Written { source, .. } => source,
666            InstallOutcome::AlreadyUpToDate { source, .. } => source,
667        };
668        let other = dir
669            .path()
670            .join("lemma_deps")
671            .join("@other")
672            .join("copy.lemma");
673        fs::create_dir_all(other.parent().unwrap()).unwrap();
674        let dest = dir
675            .path()
676            .join("lemma_deps")
677            .join("@iso")
678            .join("countries.lemma");
679        fs::rename(&dest, &other).unwrap();
680
681        fs::write(
682            dir.path().join("local.lemma"),
683            "repo @iso/countries\nspec alpha2\ndata code: text\n",
684        )
685        .unwrap();
686
687        let err = install_registry_dependency(
688            dir.path(),
689            "@iso/countries",
690            true,
691            &registries,
692            &transport,
693        )
694        .expect_err("workspace collision must fail even with force");
695        assert!(
696            matches!(err, InstallError::Plan(_)),
697            "expected Plan, got: {err}"
698        );
699        assert!(
700            other.exists(),
701            "force plan failure must leave foreign conflict file"
702        );
703        assert_eq!(fs::read_to_string(&other).unwrap(), source);
704        assert!(!dest.exists(), "plan failure must not create destination");
705        assert!(
706            !dest
707                .parent()
708                .expect("parent")
709                .join(".countries.lemma.tmp")
710                .exists(),
711            "plan failure must not leave tmp"
712        );
713    }
714
715    #[test]
716    fn install_non_at_id_reaches_registry() {
717        let dir = tempfile::tempdir().unwrap();
718        let (registries, transport) = test_transport();
719        let err = install_registry_dependency(
720            dir.path(),
721            "not-a-registry-id",
722            false,
723            &registries,
724            &transport,
725        )
726        .expect_err("non-@ id must fail via registry");
727        assert!(
728            matches!(err, InstallError::Registry(_)),
729            "error must be Registry, got: {err}"
730        );
731    }
732}