1use std::error::Error as StdError;
13use std::fmt;
14use std::fs::{self, File, OpenOptions};
15use std::path::{Component, Path, PathBuf};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use serde::{Deserialize, Serialize};
19
20const CACHE_SCHEMA_VERSION: u32 = 1;
21const SIDECAR_FILE_NAME: &str = "source-package.json";
22
23#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct SourcePackageMaterializationRequest {
30 pub source_root: PathBuf,
32 pub cache_root: PathBuf,
34 pub package_name: String,
36 pub materialized_package_name: String,
38 pub library_name: String,
40 pub source_digest: String,
42 pub source_revision: String,
44 pub crate_name: String,
46 pub crate_version: String,
48 pub toolchain_label: String,
50 pub include_paths: Vec<PathBuf>,
52 pub generated_files: Vec<GeneratedSourceFile>,
55 pub sentinel_files: Vec<PathBuf>,
57 pub manifest_policy: SourcePackageManifestPolicy,
59}
60
61#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct GeneratedSourceFile {
64 pub relative_path: PathBuf,
66 pub contents: Vec<u8>,
68}
69
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum SourcePackageManifestPolicy {
73 ZeroPackages,
75 AllowPackages,
77}
78
79#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct MaterializedSourcePackage {
82 pub project_root: PathBuf,
84 pub provenance: SourcePackageProvenance,
86}
87
88#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
90pub struct SourcePackageProvenance {
91 pub schema_version: u32,
93 pub package_name: String,
95 pub materialized_package_name: String,
97 pub library_name: String,
99 pub source_digest: String,
101 pub source_revision: String,
103 pub crate_name: String,
105 pub crate_version: String,
107 pub toolchain_label: String,
109 pub generated_toolchain_file: bool,
111}
112
113impl SourcePackageProvenance {
114 fn new(input: &SourcePackageMaterializationRequest) -> Self {
115 Self {
116 schema_version: CACHE_SCHEMA_VERSION,
117 package_name: input.package_name.clone(),
118 materialized_package_name: input.materialized_package_name.clone(),
119 library_name: input.library_name.clone(),
120 source_digest: input.source_digest.clone(),
121 source_revision: input.source_revision.clone(),
122 crate_name: input.crate_name.clone(),
123 crate_version: input.crate_version.clone(),
124 toolchain_label: input.toolchain_label.clone(),
125 generated_toolchain_file: true,
126 }
127 }
128}
129
130#[derive(Debug)]
132pub enum SourcePackageError {
133 Io {
135 action: &'static str,
137 path: PathBuf,
139 source: std::io::Error,
141 },
142 Json {
144 action: &'static str,
146 path: PathBuf,
148 source: serde_json::Error,
150 },
151 InvalidPayload(String),
153}
154
155impl fmt::Display for SourcePackageError {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 match self {
158 Self::Io { action, path, source } => {
159 write!(f, "{action} {}: {source}", path.display())
160 }
161 Self::Json { action, path, source } => {
162 write!(f, "{action} {}: {source}", path.display())
163 }
164 Self::InvalidPayload(message) => write!(f, "invalid source package payload: {message}"),
165 }
166 }
167}
168
169impl StdError for SourcePackageError {
170 fn source(&self) -> Option<&(dyn StdError + 'static)> {
171 match self {
172 Self::Io { source, .. } => Some(source),
173 Self::Json { source, .. } => Some(source),
174 Self::InvalidPayload(_) => None,
175 }
176 }
177}
178
179pub fn materialize_source_package(
187 input: &SourcePackageMaterializationRequest,
188) -> Result<MaterializedSourcePackage, SourcePackageError> {
189 validate_request(input)?;
190 let provenance = SourcePackageProvenance::new(input);
191 let cache = SourcePackageCache::new(input.cache_root.clone(), input.source_digest.clone());
192 let _lock = cache.lock_entry(&input.toolchain_label)?;
193 cache.ensure_materialized_locked(input, provenance)
194}
195
196struct SourcePackageCache {
197 cache_root: PathBuf,
198 source_digest: String,
199}
200
201impl SourcePackageCache {
202 fn new(cache_root: PathBuf, source_digest: String) -> Self {
203 Self {
204 cache_root,
205 source_digest,
206 }
207 }
208
209 fn digest_root(&self) -> PathBuf {
210 self.cache_root.join(&self.source_digest)
211 }
212
213 fn entry_root(&self, toolchain_label: &str) -> PathBuf {
214 self.digest_root().join(sanitize_toolchain_label(toolchain_label))
215 }
216
217 fn lock_entry(&self, toolchain_label: &str) -> Result<EntryLock, SourcePackageError> {
218 let digest_root = self.digest_root();
219 create_dir_all(&digest_root, "create source package cache digest directory")?;
220 let path = digest_root.join(format!("{}.lock", sanitize_toolchain_label(toolchain_label)));
221 let file = OpenOptions::new()
222 .read(true)
223 .write(true)
224 .create(true)
225 .truncate(false)
226 .open(&path)
227 .map_err(|source| SourcePackageError::Io {
228 action: "open source package cache lock",
229 path: path.clone(),
230 source,
231 })?;
232 fs4::FileExt::lock(&file).map_err(|source| SourcePackageError::Io {
233 action: "lock source package cache entry",
234 path,
235 source,
236 })?;
237 Ok(EntryLock { _file: file })
238 }
239
240 fn ensure_materialized_locked(
241 &self,
242 input: &SourcePackageMaterializationRequest,
243 provenance: SourcePackageProvenance,
244 ) -> Result<MaterializedSourcePackage, SourcePackageError> {
245 let root = self.entry_root(&input.toolchain_label);
246 if entry_matches(&root, input, &provenance)? {
247 return Ok(MaterializedSourcePackage {
248 project_root: root,
249 provenance,
250 });
251 }
252
253 remove_path_if_exists(&root, "remove stale source package cache entry")?;
254 clean_stale_temps(&self.digest_root())?;
255
256 let temp = self
257 .digest_root()
258 .join(format!(".source-package-{}-{}", std::process::id(), unique_nanos()));
259 remove_path_if_exists(&temp, "remove stale source package temp directory")?;
260 create_dir_all(&temp, "create source package temp directory")?;
261
262 copy_payload(input, &temp)?;
263 for generated in &input.generated_files {
264 write_file(
265 &temp.join(&generated.relative_path),
266 &generated.contents,
267 "write generated source package file",
268 )?;
269 }
270 write_file(
271 &temp.join("lean-toolchain"),
272 format!("{}\n", input.toolchain_label).as_bytes(),
273 "write generated source package lean-toolchain",
274 )?;
275 write_sidecar(&temp, &provenance)?;
276 validate_lake_manifest(&temp.join("lake-manifest.json"), input.manifest_policy)?;
277 for sentinel in &input.sentinel_files {
278 let path = temp.join(sentinel);
279 if !path.is_file() {
280 return Err(SourcePackageError::InvalidPayload(format!(
281 "materialized source package missing sentinel file {}",
282 sentinel.display()
283 )));
284 }
285 }
286
287 fs::rename(&temp, &root).map_err(|source| {
288 drop(remove_path_if_exists(
289 &temp,
290 "remove failed source package temp directory",
291 ));
292 SourcePackageError::Io {
293 action: "install source package cache entry",
294 path: root.clone(),
295 source,
296 }
297 })?;
298 Ok(MaterializedSourcePackage {
299 project_root: root,
300 provenance,
301 })
302 }
303}
304
305struct EntryLock {
306 _file: File,
307}
308
309fn validate_request(input: &SourcePackageMaterializationRequest) -> Result<(), SourcePackageError> {
310 if input.source_digest.is_empty() {
311 return Err(SourcePackageError::InvalidPayload(
312 "source digest must be non-empty".to_owned(),
313 ));
314 }
315 if !input
316 .source_digest
317 .chars()
318 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_'))
319 {
320 return Err(SourcePackageError::InvalidPayload(
321 "source digest must be a single cache-safe path component".to_owned(),
322 ));
323 }
324 for (field, value) in [
325 ("package name", &input.package_name),
326 ("materialized package name", &input.materialized_package_name),
327 ("library name", &input.library_name),
328 ("source revision", &input.source_revision),
329 ("crate name", &input.crate_name),
330 ("crate version", &input.crate_version),
331 ("toolchain label", &input.toolchain_label),
332 ] {
333 if value.is_empty() {
334 return Err(SourcePackageError::InvalidPayload(format!("{field} must be non-empty")));
335 }
336 }
337 if input.include_paths.is_empty() {
338 return Err(SourcePackageError::InvalidPayload(
339 "at least one include path is required".to_owned(),
340 ));
341 }
342 for relative in input
343 .include_paths
344 .iter()
345 .chain(input.sentinel_files.iter())
346 .chain(input.generated_files.iter().map(|file| &file.relative_path))
347 {
348 validate_relative_path(relative)?;
349 }
350 Ok(())
351}
352
353fn validate_relative_path(path: &Path) -> Result<(), SourcePackageError> {
354 if path.as_os_str().is_empty() || path.is_absolute() {
355 return Err(SourcePackageError::InvalidPayload(format!(
356 "source package path must be relative: {}",
357 path.display()
358 )));
359 }
360 for component in path.components() {
361 if matches!(
362 component,
363 Component::ParentDir | Component::RootDir | Component::Prefix(_)
364 ) {
365 return Err(SourcePackageError::InvalidPayload(format!(
366 "source package path must stay inside the source root: {}",
367 path.display()
368 )));
369 }
370 }
371 Ok(())
372}
373
374fn entry_matches(
375 root: &Path,
376 input: &SourcePackageMaterializationRequest,
377 expected: &SourcePackageProvenance,
378) -> Result<bool, SourcePackageError> {
379 if !root.is_dir() {
380 return Ok(false);
381 }
382 for relative in input.sentinel_files.iter().map(PathBuf::as_path).chain([
383 Path::new("lake-manifest.json"),
384 Path::new("lean-toolchain"),
385 Path::new(SIDECAR_FILE_NAME),
386 ]) {
387 if !root.join(relative).is_file() {
388 return Ok(false);
389 }
390 }
391 let sidecar_path = root.join(SIDECAR_FILE_NAME);
392 let provenance: SourcePackageProvenance =
393 match serde_json::from_slice(&read_file(&sidecar_path, "read source package provenance sidecar")?) {
394 Ok(provenance) => provenance,
395 Err(_) => return Ok(false),
396 };
397 if &provenance != expected {
398 return Ok(false);
399 }
400 let toolchain = read_to_string(
401 &root.join("lean-toolchain"),
402 "read generated source package lean-toolchain",
403 )?;
404 if toolchain.trim() != input.toolchain_label {
405 return Ok(false);
406 }
407 for generated in &input.generated_files {
408 let path = root.join(&generated.relative_path);
409 let bytes = read_file(&path, "read generated source package file")?;
410 if bytes != generated.contents {
411 return Ok(false);
412 }
413 }
414 validate_lake_manifest(&root.join("lake-manifest.json"), input.manifest_policy)?;
415 Ok(true)
416}
417
418fn copy_payload(input: &SourcePackageMaterializationRequest, dest_root: &Path) -> Result<(), SourcePackageError> {
419 for include in &input.include_paths {
420 let source = input.source_root.join(include);
421 if !source.exists() {
422 return Err(SourcePackageError::InvalidPayload(format!(
423 "include path {} does not exist under {}",
424 include.display(),
425 input.source_root.display()
426 )));
427 }
428 let dest = dest_root.join(include);
429 copy_path_recursive(&source, &dest)?;
430 }
431 Ok(())
432}
433
434fn copy_path_recursive(source: &Path, dest: &Path) -> Result<(), SourcePackageError> {
435 let metadata = fs::symlink_metadata(source).map_err(|source_error| SourcePackageError::Io {
436 action: "stat source package payload path",
437 path: source.to_path_buf(),
438 source: source_error,
439 })?;
440 if metadata.is_dir() && !metadata.file_type().is_symlink() {
441 create_dir_all(dest, "create source package payload directory")?;
442 let entries = fs::read_dir(source).map_err(|source_error| SourcePackageError::Io {
443 action: "read source package payload directory",
444 path: source.to_path_buf(),
445 source: source_error,
446 })?;
447 for entry in entries {
448 let entry = entry.map_err(|source_error| SourcePackageError::Io {
449 action: "read source package payload directory entry",
450 path: source.to_path_buf(),
451 source: source_error,
452 })?;
453 copy_path_recursive(&entry.path(), &dest.join(entry.file_name()))?;
454 }
455 } else if metadata.is_file() {
456 if let Some(parent) = dest.parent() {
457 create_dir_all(parent, "create source package payload parent directory")?;
458 }
459 fs::copy(source, dest).map_err(|source_error| SourcePackageError::Io {
460 action: "copy source package payload file",
461 path: dest.to_path_buf(),
462 source: source_error,
463 })?;
464 } else {
465 return Err(SourcePackageError::InvalidPayload(format!(
466 "source package payload path is not a regular file or directory: {}",
467 source.display()
468 )));
469 }
470 Ok(())
471}
472
473fn write_sidecar(root: &Path, provenance: &SourcePackageProvenance) -> Result<(), SourcePackageError> {
474 let path = root.join(SIDECAR_FILE_NAME);
475 let bytes = serde_json::to_vec_pretty(provenance).map_err(|source| SourcePackageError::Json {
476 action: "encode source package provenance sidecar",
477 path: path.clone(),
478 source,
479 })?;
480 write_file(&path, &bytes, "write source package provenance sidecar")
481}
482
483fn validate_lake_manifest(path: &Path, policy: SourcePackageManifestPolicy) -> Result<(), SourcePackageError> {
484 let manifest: serde_json::Value =
485 serde_json::from_slice(&read_file(path, "read source package lake-manifest.json")?).map_err(|source| {
486 SourcePackageError::Json {
487 action: "decode source package lake-manifest.json",
488 path: path.to_path_buf(),
489 source,
490 }
491 })?;
492 let packages = manifest
493 .get("packages")
494 .and_then(serde_json::Value::as_array)
495 .ok_or_else(|| {
496 SourcePackageError::InvalidPayload("lake-manifest.json must contain an array `packages`".to_owned())
497 })?;
498 if policy == SourcePackageManifestPolicy::ZeroPackages && !packages.is_empty() {
499 return Err(SourcePackageError::InvalidPayload(
500 "lake-manifest.json must remain zero-dependency (`packages: []`)".to_owned(),
501 ));
502 }
503 Ok(())
504}
505
506fn clean_stale_temps(parent: &Path) -> Result<(), SourcePackageError> {
507 if !parent.is_dir() {
508 return Ok(());
509 }
510 let entries = fs::read_dir(parent).map_err(|source| SourcePackageError::Io {
511 action: "read source package cache directory",
512 path: parent.to_path_buf(),
513 source,
514 })?;
515 for entry in entries {
516 let entry = entry.map_err(|source| SourcePackageError::Io {
517 action: "read source package cache directory entry",
518 path: parent.to_path_buf(),
519 source,
520 })?;
521 let file_name = entry.file_name();
522 let Some(name) = file_name.to_str() else {
523 continue;
524 };
525 if name.starts_with(".source-package-") {
526 remove_path_if_exists(&entry.path(), "remove stale source package temp path")?;
527 }
528 }
529 Ok(())
530}
531
532fn remove_path_if_exists(path: &Path, action: &'static str) -> Result<(), SourcePackageError> {
533 match fs::symlink_metadata(path) {
534 Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
535 fs::remove_dir_all(path).map_err(|source| SourcePackageError::Io {
536 action,
537 path: path.to_path_buf(),
538 source,
539 })
540 }
541 Ok(_) => fs::remove_file(path).map_err(|source| SourcePackageError::Io {
542 action,
543 path: path.to_path_buf(),
544 source,
545 }),
546 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
547 Err(source) => Err(SourcePackageError::Io {
548 action,
549 path: path.to_path_buf(),
550 source,
551 }),
552 }
553}
554
555fn create_dir_all(path: &Path, action: &'static str) -> Result<(), SourcePackageError> {
556 fs::create_dir_all(path).map_err(|source| SourcePackageError::Io {
557 action,
558 path: path.to_path_buf(),
559 source,
560 })
561}
562
563fn read_file(path: &Path, action: &'static str) -> Result<Vec<u8>, SourcePackageError> {
564 fs::read(path).map_err(|source| SourcePackageError::Io {
565 action,
566 path: path.to_path_buf(),
567 source,
568 })
569}
570
571fn read_to_string(path: &Path, action: &'static str) -> Result<String, SourcePackageError> {
572 fs::read_to_string(path).map_err(|source| SourcePackageError::Io {
573 action,
574 path: path.to_path_buf(),
575 source,
576 })
577}
578
579fn write_file(path: &Path, bytes: &[u8], action: &'static str) -> Result<(), SourcePackageError> {
580 if let Some(parent) = path.parent() {
581 create_dir_all(parent, "create source package output parent directory")?;
582 }
583 fs::write(path, bytes).map_err(|source| SourcePackageError::Io {
584 action,
585 path: path.to_path_buf(),
586 source,
587 })
588}
589
590fn sanitize_toolchain_label(label: &str) -> String {
591 label
592 .chars()
593 .map(|ch| {
594 if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
595 ch
596 } else {
597 '_'
598 }
599 })
600 .collect()
601}
602
603fn unique_nanos() -> u128 {
604 SystemTime::now()
605 .duration_since(UNIX_EPOCH)
606 .map_or(0, |duration| duration.as_nanos())
607}
608
609#[cfg(test)]
610mod tests {
611 use std::fs;
612 use std::path::{Path, PathBuf};
613 use std::thread;
614
615 use super::{
616 GeneratedSourceFile, SourcePackageError, SourcePackageMaterializationRequest, materialize_source_package,
617 remove_path_if_exists,
618 };
619
620 fn temp_root(name: &str) -> Result<PathBuf, String> {
621 let root = std::env::temp_dir().join(format!(
622 "lean-toolchain-source-package-{name}-{}-{}",
623 std::process::id(),
624 super::unique_nanos()
625 ));
626 drop(remove_path_if_exists(&root, "remove old source package test temp root"));
627 fs::create_dir_all(&root).map_err(|error| error.to_string())?;
628 Ok(root)
629 }
630
631 fn write(path: &Path, contents: &str) -> Result<(), String> {
632 if let Some(parent) = path.parent() {
633 fs::create_dir_all(parent).map_err(|error| error.to_string())?;
634 }
635 fs::write(path, contents).map_err(|error| error.to_string())
636 }
637
638 fn source_root(name: &str) -> Result<PathBuf, String> {
639 let root = temp_root(name)?;
640 write(
641 &root.join("lakefile.lean"),
642 "import Lake\nopen Lake DSL\npackage test_pkg\n",
643 )?;
644 write(
645 &root.join("lake-manifest.json"),
646 r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[],"name":"test_pkg","lakeDir":".lake"}"#,
647 )?;
648 write(&root.join("Test.lean"), "def answer := 42\n")?;
649 write(&root.join("Test/Extra.lean"), "def extra := 43\n")?;
650 Ok(root)
651 }
652
653 fn request(source_root: PathBuf, cache_root: PathBuf, digest: &str) -> SourcePackageMaterializationRequest {
654 SourcePackageMaterializationRequest {
655 source_root,
656 cache_root,
657 package_name: "test-pkg".to_owned(),
658 materialized_package_name: "test_pkg".to_owned(),
659 library_name: "Test".to_owned(),
660 source_digest: digest.to_owned(),
661 source_revision: "test-revision".to_owned(),
662 crate_name: "test-crate".to_owned(),
663 crate_version: "0.0.0".to_owned(),
664 toolchain_label: "leanprover/lean4:v4.31.0-rc1".to_owned(),
665 include_paths: vec![
666 PathBuf::from("lakefile.lean"),
667 PathBuf::from("lake-manifest.json"),
668 PathBuf::from("Test.lean"),
669 PathBuf::from("Test"),
670 ],
671 generated_files: Vec::new(),
672 sentinel_files: vec![PathBuf::from("Test/Extra.lean")],
673 manifest_policy: super::SourcePackageManifestPolicy::ZeroPackages,
674 }
675 }
676
677 fn read_string(path: &Path) -> Result<String, String> {
678 fs::read_to_string(path).map_err(|error| error.to_string())
679 }
680
681 #[test]
682 fn materialization_writes_generated_toolchain_and_provenance() -> Result<(), String> {
683 let source = source_root("writes")?;
684 let cache = temp_root("cache-writes")?;
685 let package =
686 materialize_source_package(&request(source, cache, "digest-a")).map_err(|error| error.to_string())?;
687
688 assert_eq!(
689 read_string(&package.project_root.join("lean-toolchain"))?.trim(),
690 "leanprover/lean4:v4.31.0-rc1"
691 );
692 assert!(package.project_root.join("Test/Extra.lean").is_file());
693 assert_eq!(package.provenance.source_digest, "digest-a");
694 assert_eq!(package.provenance.package_name, "test-pkg");
695 assert!(package.provenance.generated_toolchain_file);
696 assert!(package.project_root.join(super::SIDECAR_FILE_NAME).is_file());
697 drop(remove_path_if_exists(
698 &package.project_root,
699 "remove source package test output",
700 ));
701 Ok(())
702 }
703
704 #[test]
705 fn cache_path_changes_when_digest_changes() -> Result<(), String> {
706 let source = source_root("digest")?;
707 let cache = temp_root("cache-digest")?;
708 let first = materialize_source_package(&request(source.clone(), cache.clone(), "digest-a"))
709 .map_err(|error| error.to_string())?;
710 let second =
711 materialize_source_package(&request(source, cache, "digest-b")).map_err(|error| error.to_string())?;
712 assert_ne!(first.project_root, second.project_root);
713 assert!(
714 first
715 .project_root
716 .components()
717 .any(|component| component.as_os_str() == "digest-a")
718 );
719 assert!(
720 second
721 .project_root
722 .components()
723 .any(|component| component.as_os_str() == "digest-b")
724 );
725 Ok(())
726 }
727
728 #[test]
729 fn warm_materialization_reuses_valid_entry() -> Result<(), String> {
730 let source = source_root("warm")?;
731 let cache = temp_root("cache-warm")?;
732 let input = request(source, cache, "digest-warm");
733 let first = materialize_source_package(&input).map_err(|error| error.to_string())?;
734 let marker = first.project_root.join("warm-marker");
735 fs::write(&marker, b"warm").map_err(|error| error.to_string())?;
736
737 let second = materialize_source_package(&input).map_err(|error| error.to_string())?;
738 assert_eq!(first.project_root, second.project_root);
739 assert!(marker.is_file(), "warm cache entry should not be recopied");
740 Ok(())
741 }
742
743 #[test]
744 fn stale_sidecar_rematerializes_entry() -> Result<(), String> {
745 let source = source_root("sidecar")?;
746 let cache = temp_root("cache-sidecar")?;
747 let input = request(source, cache, "digest-sidecar");
748 let first = materialize_source_package(&input).map_err(|error| error.to_string())?;
749 let marker = first.project_root.join("warm-marker");
750 fs::write(&marker, b"warm").map_err(|error| error.to_string())?;
751 fs::write(first.project_root.join(super::SIDECAR_FILE_NAME), b"{}").map_err(|error| error.to_string())?;
752
753 let second = materialize_source_package(&input).map_err(|error| error.to_string())?;
754 assert_eq!(first.project_root, second.project_root);
755 assert!(!marker.exists(), "stale sidecar should force rematerialization");
756 Ok(())
757 }
758
759 #[test]
760 fn mismatched_toolchain_rematerializes_entry() -> Result<(), String> {
761 let source = source_root("toolchain")?;
762 let cache = temp_root("cache-toolchain")?;
763 let input = request(source, cache, "digest-toolchain");
764 let first = materialize_source_package(&input).map_err(|error| error.to_string())?;
765 let marker = first.project_root.join("warm-marker");
766 fs::write(&marker, b"warm").map_err(|error| error.to_string())?;
767 fs::write(first.project_root.join("lean-toolchain"), b"other-toolchain\n")
768 .map_err(|error| error.to_string())?;
769
770 let second = materialize_source_package(&input).map_err(|error| error.to_string())?;
771 assert_eq!(first.project_root, second.project_root);
772 assert!(!marker.exists(), "mismatched toolchain should force rematerialization");
773 Ok(())
774 }
775
776 #[test]
777 fn zero_package_manifest_invariant_is_enforced() -> Result<(), String> {
778 let source = source_root("nonzero-manifest")?;
779 write(
780 &source.join("lake-manifest.json"),
781 r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[{"name":"dep"}],"name":"test_pkg","lakeDir":".lake"}"#,
782 )?;
783 let cache = temp_root("cache-nonzero-manifest")?;
784 let error = match materialize_source_package(&request(source, cache, "digest-nonzero")) {
785 Ok(package) => {
786 return Err(format!(
787 "nonzero Lake packages should fail, materialized {}",
788 package.project_root.display()
789 ));
790 }
791 Err(error) => error,
792 };
793 assert!(
794 error.to_string().contains("zero-dependency"),
795 "error should explain the zero-package invariant: {error}"
796 );
797 Ok(())
798 }
799
800 #[test]
801 fn allow_packages_manifest_policy_accepts_dependency_manifests() -> Result<(), String> {
802 let source = source_root("allow-packages")?;
803 write(
804 &source.join("lake-manifest.json"),
805 r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[{"name":"dep"}],"name":"test_pkg","lakeDir":".lake"}"#,
806 )?;
807 let cache = temp_root("cache-allow-packages")?;
808 let mut input = request(source, cache, "digest-allow-packages");
809 input.manifest_policy = super::SourcePackageManifestPolicy::AllowPackages;
810 let package = materialize_source_package(&input).map_err(|error| error.to_string())?;
811 assert!(package.project_root.join("lake-manifest.json").is_file());
812 Ok(())
813 }
814
815 #[test]
816 fn concurrent_first_materialization_serializes_same_entry() -> Result<(), String> {
817 let source = source_root("concurrent")?;
818 let cache = temp_root("cache-concurrent")?;
819 let handles = (0..8)
820 .map(|_| {
821 let input = request(source.clone(), cache.clone(), "digest-concurrent");
822 thread::spawn(move || {
823 materialize_source_package(&input)
824 .map_err(|error| error.to_string())
825 .map(|package| package.project_root)
826 })
827 })
828 .collect::<Vec<_>>();
829
830 let mut roots = Vec::new();
831 for handle in handles {
832 let root = handle
833 .join()
834 .map_err(|_| "materialization thread panicked".to_owned())??;
835 roots.push(root);
836 }
837 let first = roots
838 .first()
839 .ok_or_else(|| "expected at least one materialized root".to_owned())?;
840 assert!(roots.iter().all(|root| root == first));
841 assert!(first.join("Test/Extra.lean").is_file());
842 assert!(first.join(super::SIDECAR_FILE_NAME).is_file());
843 Ok(())
844 }
845
846 #[test]
847 fn generated_files_can_override_lake_metadata() -> Result<(), String> {
848 let source = source_root("generated")?;
849 let cache = temp_root("cache-generated")?;
850 let mut input = request(source, cache, "digest-generated");
851 input.generated_files = vec![
852 GeneratedSourceFile {
853 relative_path: PathBuf::from("lakefile.lean"),
854 contents: b"import Lake\nopen Lake DSL\npackage generated_pkg\n".to_vec(),
855 },
856 GeneratedSourceFile {
857 relative_path: PathBuf::from("lake-manifest.json"),
858 contents: br#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[],"name":"generated_pkg","lakeDir":".lake"}"#.to_vec(),
859 },
860 ];
861 let package = materialize_source_package(&input).map_err(|error| error.to_string())?;
862 assert!(read_string(&package.project_root.join("lakefile.lean"))?.contains("generated_pkg"));
863 Ok(())
864 }
865
866 #[test]
867 fn generated_file_mismatch_rematerializes_entry() -> Result<(), String> {
868 let source = source_root("generated-mismatch")?;
869 let cache = temp_root("cache-generated-mismatch")?;
870 let mut input = request(source, cache, "digest-generated-mismatch");
871 input.generated_files = vec![GeneratedSourceFile {
872 relative_path: PathBuf::from("generated.txt"),
873 contents: b"expected".to_vec(),
874 }];
875 let first = materialize_source_package(&input).map_err(|error| error.to_string())?;
876 let marker = first.project_root.join("warm-marker");
877 fs::write(&marker, b"warm").map_err(|error| error.to_string())?;
878 fs::write(first.project_root.join("generated.txt"), b"stale").map_err(|error| error.to_string())?;
879
880 let second = materialize_source_package(&input).map_err(|error| error.to_string())?;
881 assert_eq!(first.project_root, second.project_root);
882 assert!(
883 !marker.exists(),
884 "generated file mismatch should force rematerialization"
885 );
886 assert_eq!(read_string(&second.project_root.join("generated.txt"))?, "expected");
887 Ok(())
888 }
889
890 #[test]
891 fn error_messages_include_action_and_path() -> Result<(), String> {
892 let source = source_root("missing")?;
893 let cache = temp_root("cache-missing")?;
894 let mut input = request(source, cache, "digest-missing");
895 input.include_paths.push(PathBuf::from("Missing.lean"));
896 let error = match materialize_source_package(&input) {
897 Ok(package) => {
898 return Err(format!(
899 "missing include path should fail, materialized {}",
900 package.project_root.display()
901 ));
902 }
903 Err(error) => error,
904 };
905 assert!(
906 matches!(error, SourcePackageError::InvalidPayload(_)),
907 "missing include path should be an invalid payload error: {error}"
908 );
909
910 let bad_cache_file = temp_root("cache-file")?.join("not-a-directory");
911 fs::write(&bad_cache_file, b"not a directory").map_err(|error| error.to_string())?;
912 let source = source_root("io-path")?;
913 let error = match materialize_source_package(&request(source, bad_cache_file, "digest-io")) {
914 Ok(package) => {
915 return Err(format!(
916 "cache root file should fail, materialized {}",
917 package.project_root.display()
918 ));
919 }
920 Err(error) => error,
921 };
922 let text = error.to_string();
923 assert!(
924 text.contains("create source package cache digest directory") && text.contains("not-a-directory"),
925 "I/O error should include action and path: {text}"
926 );
927 Ok(())
928 }
929}