Skip to main content

lemma_cli/
install.rs

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