Skip to main content

lean_toolchain/
source_package.rs

1//! Source-package materialization for package-owned Lean payload crates.
2//!
3//! This module owns the cache mechanics that are easy to get subtly wrong in
4//! downstream `build.rs` helpers: digest-keyed directories, per-entry locking,
5//! atomic installation, generated `lean-toolchain` files, provenance sidecars,
6//! and Lake manifest validation. It does not know how to build Lake targets;
7//! callers pass the returned root to [`CargoLeanCapability`] or another build
8//! helper.
9//!
10//! [`CargoLeanCapability`]: crate::CargoLeanCapability
11
12use 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/// Request to materialize a package-owned Lean source payload.
24///
25/// The source digest is the cache identity. The other provenance fields make a
26/// warm entry self-describing and force rematerialization when caller-visible
27/// ownership metadata changes while the payload bytes happen to remain stable.
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct SourcePackageMaterializationRequest {
30    /// Directory containing the packaged source payload.
31    pub source_root: PathBuf,
32    /// Caller-owned cache root. The helper owns the layout below it.
33    pub cache_root: PathBuf,
34    /// Public upstream Lake package name.
35    pub package_name: String,
36    /// Lake package identifier in the materialized root.
37    pub materialized_package_name: String,
38    /// Lean library/root module name.
39    pub library_name: String,
40    /// Digest of the payload source rule chosen by the caller.
41    pub source_digest: String,
42    /// Source revision, vendoring revision, or another stable source identity.
43    pub source_revision: String,
44    /// Rust crate that owns this payload.
45    pub crate_name: String,
46    /// Rust crate version that owns this payload.
47    pub crate_version: String,
48    /// Lean toolchain label to write into the generated `lean-toolchain`.
49    pub toolchain_label: String,
50    /// Source-root-relative files or directories to copy recursively.
51    pub include_paths: Vec<PathBuf>,
52    /// Files written after copying. These can override copied files when the
53    /// caller owns generated Lake metadata.
54    pub generated_files: Vec<GeneratedSourceFile>,
55    /// Files that must exist in a valid warm cache entry.
56    pub sentinel_files: Vec<PathBuf>,
57    /// Validation policy for `lake-manifest.json`.
58    pub manifest_policy: SourcePackageManifestPolicy,
59}
60
61/// Source-root-relative generated file written during materialization.
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct GeneratedSourceFile {
64    /// Destination path relative to the materialized source root.
65    pub relative_path: PathBuf,
66    /// Complete file contents.
67    pub contents: Vec<u8>,
68}
69
70/// Validation policy for the materialized `lake-manifest.json`.
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum SourcePackageManifestPolicy {
73    /// Require `"packages": []`.
74    ZeroPackages,
75    /// Require `packages` to be present and well-formed, but allow entries.
76    AllowPackages,
77}
78
79/// Materialized source package.
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct MaterializedSourcePackage {
82    /// Materialized Lake project root.
83    pub project_root: PathBuf,
84    /// Provenance recorded beside the materialized source root.
85    pub provenance: SourcePackageProvenance,
86}
87
88/// Provenance sidecar for a materialized source package.
89#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
90pub struct SourcePackageProvenance {
91    /// Sidecar schema version.
92    pub schema_version: u32,
93    /// Public upstream Lake package name.
94    pub package_name: String,
95    /// Lake package identifier in the materialized root.
96    pub materialized_package_name: String,
97    /// Lean library/root module name.
98    pub library_name: String,
99    /// Caller-supplied payload digest.
100    pub source_digest: String,
101    /// Caller-supplied source identity.
102    pub source_revision: String,
103    /// Rust crate that owns the payload.
104    pub crate_name: String,
105    /// Rust crate version that owns the payload.
106    pub crate_version: String,
107    /// Requested Lean toolchain label.
108    pub toolchain_label: String,
109    /// Whether the helper generated the materialized `lean-toolchain`.
110    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/// Source package materialization errors.
131#[derive(Debug)]
132pub enum SourcePackageError {
133    /// Filesystem operation failed.
134    Io {
135        /// Operation being attempted.
136        action: &'static str,
137        /// Path involved in the operation.
138        path: PathBuf,
139        /// Underlying filesystem error.
140        source: std::io::Error,
141    },
142    /// JSON sidecar or manifest operation failed.
143    Json {
144        /// Operation being attempted.
145        action: &'static str,
146        /// Path involved in the operation.
147        path: PathBuf,
148        /// Underlying JSON error.
149        source: serde_json::Error,
150    },
151    /// The caller supplied an invalid materialization request or payload.
152    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
179/// Materialize a package-owned Lean source payload into a digest-keyed cache.
180///
181/// # Errors
182///
183/// Returns [`SourcePackageError`] when the request is invalid, the payload is
184/// violates the requested Lake manifest policy, a filesystem operation fails,
185/// or the provenance sidecar cannot be decoded.
186pub 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}