use std::error::Error as StdError;
use std::fmt;
use std::fs::{self, File, OpenOptions};
use std::path::{Component, Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
const CACHE_SCHEMA_VERSION: u32 = 1;
const SIDECAR_FILE_NAME: &str = "source-package.json";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SourcePackageMaterializationRequest {
pub source_root: PathBuf,
pub cache_root: PathBuf,
pub package_name: String,
pub materialized_package_name: String,
pub library_name: String,
pub source_digest: String,
pub source_revision: String,
pub crate_name: String,
pub crate_version: String,
pub toolchain_label: String,
pub include_paths: Vec<PathBuf>,
pub generated_files: Vec<GeneratedSourceFile>,
pub sentinel_files: Vec<PathBuf>,
pub manifest_policy: SourcePackageManifestPolicy,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedSourceFile {
pub relative_path: PathBuf,
pub contents: Vec<u8>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SourcePackageManifestPolicy {
ZeroPackages,
AllowPackages,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MaterializedSourcePackage {
pub project_root: PathBuf,
pub provenance: SourcePackageProvenance,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct SourcePackageProvenance {
pub schema_version: u32,
pub package_name: String,
pub materialized_package_name: String,
pub library_name: String,
pub source_digest: String,
pub source_revision: String,
pub crate_name: String,
pub crate_version: String,
pub toolchain_label: String,
pub generated_toolchain_file: bool,
}
impl SourcePackageProvenance {
fn new(input: &SourcePackageMaterializationRequest) -> Self {
Self {
schema_version: CACHE_SCHEMA_VERSION,
package_name: input.package_name.clone(),
materialized_package_name: input.materialized_package_name.clone(),
library_name: input.library_name.clone(),
source_digest: input.source_digest.clone(),
source_revision: input.source_revision.clone(),
crate_name: input.crate_name.clone(),
crate_version: input.crate_version.clone(),
toolchain_label: input.toolchain_label.clone(),
generated_toolchain_file: true,
}
}
}
#[derive(Debug)]
pub enum SourcePackageError {
Io {
action: &'static str,
path: PathBuf,
source: std::io::Error,
},
Json {
action: &'static str,
path: PathBuf,
source: serde_json::Error,
},
InvalidPayload(String),
}
impl fmt::Display for SourcePackageError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { action, path, source } => {
write!(f, "{action} {}: {source}", path.display())
}
Self::Json { action, path, source } => {
write!(f, "{action} {}: {source}", path.display())
}
Self::InvalidPayload(message) => write!(f, "invalid source package payload: {message}"),
}
}
}
impl StdError for SourcePackageError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Json { source, .. } => Some(source),
Self::InvalidPayload(_) => None,
}
}
}
pub fn materialize_source_package(
input: &SourcePackageMaterializationRequest,
) -> Result<MaterializedSourcePackage, SourcePackageError> {
validate_request(input)?;
let provenance = SourcePackageProvenance::new(input);
let cache = SourcePackageCache::new(input.cache_root.clone(), input.source_digest.clone());
let _lock = cache.lock_entry(&input.toolchain_label)?;
cache.ensure_materialized_locked(input, provenance)
}
struct SourcePackageCache {
cache_root: PathBuf,
source_digest: String,
}
impl SourcePackageCache {
fn new(cache_root: PathBuf, source_digest: String) -> Self {
Self {
cache_root,
source_digest,
}
}
fn digest_root(&self) -> PathBuf {
self.cache_root.join(&self.source_digest)
}
fn entry_root(&self, toolchain_label: &str) -> PathBuf {
self.digest_root().join(sanitize_toolchain_label(toolchain_label))
}
fn lock_entry(&self, toolchain_label: &str) -> Result<EntryLock, SourcePackageError> {
let digest_root = self.digest_root();
create_dir_all(&digest_root, "create source package cache digest directory")?;
let path = digest_root.join(format!("{}.lock", sanitize_toolchain_label(toolchain_label)));
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.map_err(|source| SourcePackageError::Io {
action: "open source package cache lock",
path: path.clone(),
source,
})?;
fs4::FileExt::lock(&file).map_err(|source| SourcePackageError::Io {
action: "lock source package cache entry",
path,
source,
})?;
Ok(EntryLock { _file: file })
}
fn ensure_materialized_locked(
&self,
input: &SourcePackageMaterializationRequest,
provenance: SourcePackageProvenance,
) -> Result<MaterializedSourcePackage, SourcePackageError> {
let root = self.entry_root(&input.toolchain_label);
if entry_matches(&root, input, &provenance)? {
return Ok(MaterializedSourcePackage {
project_root: root,
provenance,
});
}
remove_path_if_exists(&root, "remove stale source package cache entry")?;
clean_stale_temps(&self.digest_root())?;
let temp = self
.digest_root()
.join(format!(".source-package-{}-{}", std::process::id(), unique_nanos()));
remove_path_if_exists(&temp, "remove stale source package temp directory")?;
create_dir_all(&temp, "create source package temp directory")?;
copy_payload(input, &temp)?;
for generated in &input.generated_files {
write_file(
&temp.join(&generated.relative_path),
&generated.contents,
"write generated source package file",
)?;
}
write_file(
&temp.join("lean-toolchain"),
format!("{}\n", input.toolchain_label).as_bytes(),
"write generated source package lean-toolchain",
)?;
write_sidecar(&temp, &provenance)?;
validate_lake_manifest(&temp.join("lake-manifest.json"), input.manifest_policy)?;
for sentinel in &input.sentinel_files {
let path = temp.join(sentinel);
if !path.is_file() {
return Err(SourcePackageError::InvalidPayload(format!(
"materialized source package missing sentinel file {}",
sentinel.display()
)));
}
}
fs::rename(&temp, &root).map_err(|source| {
drop(remove_path_if_exists(
&temp,
"remove failed source package temp directory",
));
SourcePackageError::Io {
action: "install source package cache entry",
path: root.clone(),
source,
}
})?;
Ok(MaterializedSourcePackage {
project_root: root,
provenance,
})
}
}
struct EntryLock {
_file: File,
}
fn validate_request(input: &SourcePackageMaterializationRequest) -> Result<(), SourcePackageError> {
if input.source_digest.is_empty() {
return Err(SourcePackageError::InvalidPayload(
"source digest must be non-empty".to_owned(),
));
}
if !input
.source_digest
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_'))
{
return Err(SourcePackageError::InvalidPayload(
"source digest must be a single cache-safe path component".to_owned(),
));
}
for (field, value) in [
("package name", &input.package_name),
("materialized package name", &input.materialized_package_name),
("library name", &input.library_name),
("source revision", &input.source_revision),
("crate name", &input.crate_name),
("crate version", &input.crate_version),
("toolchain label", &input.toolchain_label),
] {
if value.is_empty() {
return Err(SourcePackageError::InvalidPayload(format!("{field} must be non-empty")));
}
}
if input.include_paths.is_empty() {
return Err(SourcePackageError::InvalidPayload(
"at least one include path is required".to_owned(),
));
}
for relative in input
.include_paths
.iter()
.chain(input.sentinel_files.iter())
.chain(input.generated_files.iter().map(|file| &file.relative_path))
{
validate_relative_path(relative)?;
}
Ok(())
}
fn validate_relative_path(path: &Path) -> Result<(), SourcePackageError> {
if path.as_os_str().is_empty() || path.is_absolute() {
return Err(SourcePackageError::InvalidPayload(format!(
"source package path must be relative: {}",
path.display()
)));
}
for component in path.components() {
if matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
) {
return Err(SourcePackageError::InvalidPayload(format!(
"source package path must stay inside the source root: {}",
path.display()
)));
}
}
Ok(())
}
fn entry_matches(
root: &Path,
input: &SourcePackageMaterializationRequest,
expected: &SourcePackageProvenance,
) -> Result<bool, SourcePackageError> {
if !root.is_dir() {
return Ok(false);
}
for relative in input.sentinel_files.iter().map(PathBuf::as_path).chain([
Path::new("lake-manifest.json"),
Path::new("lean-toolchain"),
Path::new(SIDECAR_FILE_NAME),
]) {
if !root.join(relative).is_file() {
return Ok(false);
}
}
let sidecar_path = root.join(SIDECAR_FILE_NAME);
let provenance: SourcePackageProvenance =
match serde_json::from_slice(&read_file(&sidecar_path, "read source package provenance sidecar")?) {
Ok(provenance) => provenance,
Err(_) => return Ok(false),
};
if &provenance != expected {
return Ok(false);
}
let toolchain = read_to_string(
&root.join("lean-toolchain"),
"read generated source package lean-toolchain",
)?;
if toolchain.trim() != input.toolchain_label {
return Ok(false);
}
for generated in &input.generated_files {
let path = root.join(&generated.relative_path);
let bytes = read_file(&path, "read generated source package file")?;
if bytes != generated.contents {
return Ok(false);
}
}
validate_lake_manifest(&root.join("lake-manifest.json"), input.manifest_policy)?;
Ok(true)
}
fn copy_payload(input: &SourcePackageMaterializationRequest, dest_root: &Path) -> Result<(), SourcePackageError> {
for include in &input.include_paths {
let source = input.source_root.join(include);
if !source.exists() {
return Err(SourcePackageError::InvalidPayload(format!(
"include path {} does not exist under {}",
include.display(),
input.source_root.display()
)));
}
let dest = dest_root.join(include);
copy_path_recursive(&source, &dest)?;
}
Ok(())
}
fn copy_path_recursive(source: &Path, dest: &Path) -> Result<(), SourcePackageError> {
let metadata = fs::symlink_metadata(source).map_err(|source_error| SourcePackageError::Io {
action: "stat source package payload path",
path: source.to_path_buf(),
source: source_error,
})?;
if metadata.is_dir() && !metadata.file_type().is_symlink() {
create_dir_all(dest, "create source package payload directory")?;
let entries = fs::read_dir(source).map_err(|source_error| SourcePackageError::Io {
action: "read source package payload directory",
path: source.to_path_buf(),
source: source_error,
})?;
for entry in entries {
let entry = entry.map_err(|source_error| SourcePackageError::Io {
action: "read source package payload directory entry",
path: source.to_path_buf(),
source: source_error,
})?;
copy_path_recursive(&entry.path(), &dest.join(entry.file_name()))?;
}
} else if metadata.is_file() {
if let Some(parent) = dest.parent() {
create_dir_all(parent, "create source package payload parent directory")?;
}
fs::copy(source, dest).map_err(|source_error| SourcePackageError::Io {
action: "copy source package payload file",
path: dest.to_path_buf(),
source: source_error,
})?;
} else {
return Err(SourcePackageError::InvalidPayload(format!(
"source package payload path is not a regular file or directory: {}",
source.display()
)));
}
Ok(())
}
fn write_sidecar(root: &Path, provenance: &SourcePackageProvenance) -> Result<(), SourcePackageError> {
let path = root.join(SIDECAR_FILE_NAME);
let bytes = serde_json::to_vec_pretty(provenance).map_err(|source| SourcePackageError::Json {
action: "encode source package provenance sidecar",
path: path.clone(),
source,
})?;
write_file(&path, &bytes, "write source package provenance sidecar")
}
fn validate_lake_manifest(path: &Path, policy: SourcePackageManifestPolicy) -> Result<(), SourcePackageError> {
let manifest: serde_json::Value =
serde_json::from_slice(&read_file(path, "read source package lake-manifest.json")?).map_err(|source| {
SourcePackageError::Json {
action: "decode source package lake-manifest.json",
path: path.to_path_buf(),
source,
}
})?;
let packages = manifest
.get("packages")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
SourcePackageError::InvalidPayload("lake-manifest.json must contain an array `packages`".to_owned())
})?;
if policy == SourcePackageManifestPolicy::ZeroPackages && !packages.is_empty() {
return Err(SourcePackageError::InvalidPayload(
"lake-manifest.json must remain zero-dependency (`packages: []`)".to_owned(),
));
}
Ok(())
}
fn clean_stale_temps(parent: &Path) -> Result<(), SourcePackageError> {
if !parent.is_dir() {
return Ok(());
}
let entries = fs::read_dir(parent).map_err(|source| SourcePackageError::Io {
action: "read source package cache directory",
path: parent.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| SourcePackageError::Io {
action: "read source package cache directory entry",
path: parent.to_path_buf(),
source,
})?;
let file_name = entry.file_name();
let Some(name) = file_name.to_str() else {
continue;
};
if name.starts_with(".source-package-") {
remove_path_if_exists(&entry.path(), "remove stale source package temp path")?;
}
}
Ok(())
}
fn remove_path_if_exists(path: &Path, action: &'static str) -> Result<(), SourcePackageError> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {
fs::remove_dir_all(path).map_err(|source| SourcePackageError::Io {
action,
path: path.to_path_buf(),
source,
})
}
Ok(_) => fs::remove_file(path).map_err(|source| SourcePackageError::Io {
action,
path: path.to_path_buf(),
source,
}),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(SourcePackageError::Io {
action,
path: path.to_path_buf(),
source,
}),
}
}
fn create_dir_all(path: &Path, action: &'static str) -> Result<(), SourcePackageError> {
fs::create_dir_all(path).map_err(|source| SourcePackageError::Io {
action,
path: path.to_path_buf(),
source,
})
}
fn read_file(path: &Path, action: &'static str) -> Result<Vec<u8>, SourcePackageError> {
fs::read(path).map_err(|source| SourcePackageError::Io {
action,
path: path.to_path_buf(),
source,
})
}
fn read_to_string(path: &Path, action: &'static str) -> Result<String, SourcePackageError> {
fs::read_to_string(path).map_err(|source| SourcePackageError::Io {
action,
path: path.to_path_buf(),
source,
})
}
fn write_file(path: &Path, bytes: &[u8], action: &'static str) -> Result<(), SourcePackageError> {
if let Some(parent) = path.parent() {
create_dir_all(parent, "create source package output parent directory")?;
}
fs::write(path, bytes).map_err(|source| SourcePackageError::Io {
action,
path: path.to_path_buf(),
source,
})
}
fn sanitize_toolchain_label(label: &str) -> String {
label
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
ch
} else {
'_'
}
})
.collect()
}
fn unique_nanos() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos())
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::{Path, PathBuf};
use std::thread;
use super::{
GeneratedSourceFile, SourcePackageError, SourcePackageMaterializationRequest, materialize_source_package,
remove_path_if_exists,
};
fn temp_root(name: &str) -> Result<PathBuf, String> {
let root = std::env::temp_dir().join(format!(
"lean-toolchain-source-package-{name}-{}-{}",
std::process::id(),
super::unique_nanos()
));
drop(remove_path_if_exists(&root, "remove old source package test temp root"));
fs::create_dir_all(&root).map_err(|error| error.to_string())?;
Ok(root)
}
fn write(path: &Path, contents: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
fs::write(path, contents).map_err(|error| error.to_string())
}
fn source_root(name: &str) -> Result<PathBuf, String> {
let root = temp_root(name)?;
write(
&root.join("lakefile.lean"),
"import Lake\nopen Lake DSL\npackage test_pkg\n",
)?;
write(
&root.join("lake-manifest.json"),
r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[],"name":"test_pkg","lakeDir":".lake"}"#,
)?;
write(&root.join("Test.lean"), "def answer := 42\n")?;
write(&root.join("Test/Extra.lean"), "def extra := 43\n")?;
Ok(root)
}
fn request(source_root: PathBuf, cache_root: PathBuf, digest: &str) -> SourcePackageMaterializationRequest {
SourcePackageMaterializationRequest {
source_root,
cache_root,
package_name: "test-pkg".to_owned(),
materialized_package_name: "test_pkg".to_owned(),
library_name: "Test".to_owned(),
source_digest: digest.to_owned(),
source_revision: "test-revision".to_owned(),
crate_name: "test-crate".to_owned(),
crate_version: "0.0.0".to_owned(),
toolchain_label: "leanprover/lean4:v4.31.0-rc1".to_owned(),
include_paths: vec![
PathBuf::from("lakefile.lean"),
PathBuf::from("lake-manifest.json"),
PathBuf::from("Test.lean"),
PathBuf::from("Test"),
],
generated_files: Vec::new(),
sentinel_files: vec![PathBuf::from("Test/Extra.lean")],
manifest_policy: super::SourcePackageManifestPolicy::ZeroPackages,
}
}
fn read_string(path: &Path) -> Result<String, String> {
fs::read_to_string(path).map_err(|error| error.to_string())
}
#[test]
fn materialization_writes_generated_toolchain_and_provenance() -> Result<(), String> {
let source = source_root("writes")?;
let cache = temp_root("cache-writes")?;
let package =
materialize_source_package(&request(source, cache, "digest-a")).map_err(|error| error.to_string())?;
assert_eq!(
read_string(&package.project_root.join("lean-toolchain"))?.trim(),
"leanprover/lean4:v4.31.0-rc1"
);
assert!(package.project_root.join("Test/Extra.lean").is_file());
assert_eq!(package.provenance.source_digest, "digest-a");
assert_eq!(package.provenance.package_name, "test-pkg");
assert!(package.provenance.generated_toolchain_file);
assert!(package.project_root.join(super::SIDECAR_FILE_NAME).is_file());
drop(remove_path_if_exists(
&package.project_root,
"remove source package test output",
));
Ok(())
}
#[test]
fn cache_path_changes_when_digest_changes() -> Result<(), String> {
let source = source_root("digest")?;
let cache = temp_root("cache-digest")?;
let first = materialize_source_package(&request(source.clone(), cache.clone(), "digest-a"))
.map_err(|error| error.to_string())?;
let second =
materialize_source_package(&request(source, cache, "digest-b")).map_err(|error| error.to_string())?;
assert_ne!(first.project_root, second.project_root);
assert!(
first
.project_root
.components()
.any(|component| component.as_os_str() == "digest-a")
);
assert!(
second
.project_root
.components()
.any(|component| component.as_os_str() == "digest-b")
);
Ok(())
}
#[test]
fn warm_materialization_reuses_valid_entry() -> Result<(), String> {
let source = source_root("warm")?;
let cache = temp_root("cache-warm")?;
let input = request(source, cache, "digest-warm");
let first = materialize_source_package(&input).map_err(|error| error.to_string())?;
let marker = first.project_root.join("warm-marker");
fs::write(&marker, b"warm").map_err(|error| error.to_string())?;
let second = materialize_source_package(&input).map_err(|error| error.to_string())?;
assert_eq!(first.project_root, second.project_root);
assert!(marker.is_file(), "warm cache entry should not be recopied");
Ok(())
}
#[test]
fn stale_sidecar_rematerializes_entry() -> Result<(), String> {
let source = source_root("sidecar")?;
let cache = temp_root("cache-sidecar")?;
let input = request(source, cache, "digest-sidecar");
let first = materialize_source_package(&input).map_err(|error| error.to_string())?;
let marker = first.project_root.join("warm-marker");
fs::write(&marker, b"warm").map_err(|error| error.to_string())?;
fs::write(first.project_root.join(super::SIDECAR_FILE_NAME), b"{}").map_err(|error| error.to_string())?;
let second = materialize_source_package(&input).map_err(|error| error.to_string())?;
assert_eq!(first.project_root, second.project_root);
assert!(!marker.exists(), "stale sidecar should force rematerialization");
Ok(())
}
#[test]
fn mismatched_toolchain_rematerializes_entry() -> Result<(), String> {
let source = source_root("toolchain")?;
let cache = temp_root("cache-toolchain")?;
let input = request(source, cache, "digest-toolchain");
let first = materialize_source_package(&input).map_err(|error| error.to_string())?;
let marker = first.project_root.join("warm-marker");
fs::write(&marker, b"warm").map_err(|error| error.to_string())?;
fs::write(first.project_root.join("lean-toolchain"), b"other-toolchain\n")
.map_err(|error| error.to_string())?;
let second = materialize_source_package(&input).map_err(|error| error.to_string())?;
assert_eq!(first.project_root, second.project_root);
assert!(!marker.exists(), "mismatched toolchain should force rematerialization");
Ok(())
}
#[test]
fn zero_package_manifest_invariant_is_enforced() -> Result<(), String> {
let source = source_root("nonzero-manifest")?;
write(
&source.join("lake-manifest.json"),
r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[{"name":"dep"}],"name":"test_pkg","lakeDir":".lake"}"#,
)?;
let cache = temp_root("cache-nonzero-manifest")?;
let error = match materialize_source_package(&request(source, cache, "digest-nonzero")) {
Ok(package) => {
return Err(format!(
"nonzero Lake packages should fail, materialized {}",
package.project_root.display()
));
}
Err(error) => error,
};
assert!(
error.to_string().contains("zero-dependency"),
"error should explain the zero-package invariant: {error}"
);
Ok(())
}
#[test]
fn allow_packages_manifest_policy_accepts_dependency_manifests() -> Result<(), String> {
let source = source_root("allow-packages")?;
write(
&source.join("lake-manifest.json"),
r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[{"name":"dep"}],"name":"test_pkg","lakeDir":".lake"}"#,
)?;
let cache = temp_root("cache-allow-packages")?;
let mut input = request(source, cache, "digest-allow-packages");
input.manifest_policy = super::SourcePackageManifestPolicy::AllowPackages;
let package = materialize_source_package(&input).map_err(|error| error.to_string())?;
assert!(package.project_root.join("lake-manifest.json").is_file());
Ok(())
}
#[test]
fn concurrent_first_materialization_serializes_same_entry() -> Result<(), String> {
let source = source_root("concurrent")?;
let cache = temp_root("cache-concurrent")?;
let handles = (0..8)
.map(|_| {
let input = request(source.clone(), cache.clone(), "digest-concurrent");
thread::spawn(move || {
materialize_source_package(&input)
.map_err(|error| error.to_string())
.map(|package| package.project_root)
})
})
.collect::<Vec<_>>();
let mut roots = Vec::new();
for handle in handles {
let root = handle
.join()
.map_err(|_| "materialization thread panicked".to_owned())??;
roots.push(root);
}
let first = roots
.first()
.ok_or_else(|| "expected at least one materialized root".to_owned())?;
assert!(roots.iter().all(|root| root == first));
assert!(first.join("Test/Extra.lean").is_file());
assert!(first.join(super::SIDECAR_FILE_NAME).is_file());
Ok(())
}
#[test]
fn generated_files_can_override_lake_metadata() -> Result<(), String> {
let source = source_root("generated")?;
let cache = temp_root("cache-generated")?;
let mut input = request(source, cache, "digest-generated");
input.generated_files = vec![
GeneratedSourceFile {
relative_path: PathBuf::from("lakefile.lean"),
contents: b"import Lake\nopen Lake DSL\npackage generated_pkg\n".to_vec(),
},
GeneratedSourceFile {
relative_path: PathBuf::from("lake-manifest.json"),
contents: br#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[],"name":"generated_pkg","lakeDir":".lake"}"#.to_vec(),
},
];
let package = materialize_source_package(&input).map_err(|error| error.to_string())?;
assert!(read_string(&package.project_root.join("lakefile.lean"))?.contains("generated_pkg"));
Ok(())
}
#[test]
fn generated_file_mismatch_rematerializes_entry() -> Result<(), String> {
let source = source_root("generated-mismatch")?;
let cache = temp_root("cache-generated-mismatch")?;
let mut input = request(source, cache, "digest-generated-mismatch");
input.generated_files = vec![GeneratedSourceFile {
relative_path: PathBuf::from("generated.txt"),
contents: b"expected".to_vec(),
}];
let first = materialize_source_package(&input).map_err(|error| error.to_string())?;
let marker = first.project_root.join("warm-marker");
fs::write(&marker, b"warm").map_err(|error| error.to_string())?;
fs::write(first.project_root.join("generated.txt"), b"stale").map_err(|error| error.to_string())?;
let second = materialize_source_package(&input).map_err(|error| error.to_string())?;
assert_eq!(first.project_root, second.project_root);
assert!(
!marker.exists(),
"generated file mismatch should force rematerialization"
);
assert_eq!(read_string(&second.project_root.join("generated.txt"))?, "expected");
Ok(())
}
#[test]
fn error_messages_include_action_and_path() -> Result<(), String> {
let source = source_root("missing")?;
let cache = temp_root("cache-missing")?;
let mut input = request(source, cache, "digest-missing");
input.include_paths.push(PathBuf::from("Missing.lean"));
let error = match materialize_source_package(&input) {
Ok(package) => {
return Err(format!(
"missing include path should fail, materialized {}",
package.project_root.display()
));
}
Err(error) => error,
};
assert!(
matches!(error, SourcePackageError::InvalidPayload(_)),
"missing include path should be an invalid payload error: {error}"
);
let bad_cache_file = temp_root("cache-file")?.join("not-a-directory");
fs::write(&bad_cache_file, b"not a directory").map_err(|error| error.to_string())?;
let source = source_root("io-path")?;
let error = match materialize_source_package(&request(source, bad_cache_file, "digest-io")) {
Ok(package) => {
return Err(format!(
"cache root file should fail, materialized {}",
package.project_root.display()
));
}
Err(error) => error,
};
let text = error.to_string();
assert!(
text.contains("create source package cache digest directory") && text.contains("not-a-directory"),
"I/O error should include action and path: {text}"
);
Ok(())
}
}