1use 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#[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
35pub 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
94pub fn block_on_registry<F: std::future::Future>(future: F) -> F::Output {
96 registry_runtime().block_on(future)
97}
98
99pub 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
114pub 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
147pub 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
242pub struct LemmaDepsScan {
244 pub destination_absolute: PathBuf,
245 pub destination_relative: PathBuf,
246 pub conflict_paths: Vec<PathBuf>,
248}
249
250pub 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
300fn 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
316pub 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
333pub fn rename_tmp(tmp: &Path, path: &Path) -> io::Result<()> {
335 fs::rename(tmp, path)
336}
337
338pub 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, ®istry).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, ®istry).unwrap();
429 let second =
430 install_registry_dependency(dir.path(), "@iso/countries", false, ®istry).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, ®istry).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, ®istry)
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, ®istry).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, ®istry).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, ®istry)
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, ®istry).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, ®istry)
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, ®istry)
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}