use std::env;
use std::fmt::Write as _;
use std::fs;
use std::fs::OpenOptions;
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
use sha2::{Digest, Sha256};
use crate::diagnostics::LinkDiagnostics;
use crate::discover::{DiscoverOptions, ToolchainInfo, discover_toolchain};
use crate::fingerprint::ToolchainFingerprint;
use crate::lakefile_toml::parse_lakefile_toml;
use crate::loader::{LeanExportSignature, LeanLibraryDependency};
pub const CAPABILITY_MANIFEST_SCHEMA_VERSION: u32 = 2;
static EMITTED_TOOLCHAIN_PREFIX: OnceLock<PathBuf> = OnceLock::new();
pub fn emit_lean_link_directives() {
if let Err(diagnostic) = emit_lean_link_directives_checked() {
println!("cargo:warning={diagnostic}");
}
}
pub fn emit_lean_link_directives_checked() -> Result<(), LinkDiagnostics> {
emit_lean_link_directives_checked_with_options(&DiscoverOptions::default()).map(drop)
}
fn emit_lean_link_directives_checked_with_options(opts: &DiscoverOptions) -> Result<ToolchainInfo, LinkDiagnostics> {
let info = match discover_toolchain(opts) {
Ok(info) => info,
Err(diagnostic) => {
emit_rerun_triggers(None);
return Err(diagnostic);
}
};
if EMITTED_TOOLCHAIN_PREFIX
.get()
.is_some_and(|prefix| prefix == &info.prefix)
{
return Ok(info);
}
emit_for(&info);
drop(EMITTED_TOOLCHAIN_PREFIX.set(info.prefix.clone()));
Ok(info)
}
pub fn build_lake_target(project_root: &Path, target_name: &str) -> Result<PathBuf, LinkDiagnostics> {
let mut runner = RealLakeRunner;
build_lake_target_with_runner_and_options(
project_root,
target_name,
&mut runner,
CargoMetadata::Emit,
&LakeBuildOptions::default(),
)
}
pub fn build_lake_target_quiet(project_root: &Path, target_name: &str) -> Result<PathBuf, LinkDiagnostics> {
let mut runner = RealLakeRunner;
build_lake_target_with_runner_and_options(
project_root,
target_name,
&mut runner,
CargoMetadata::Suppress,
&LakeBuildOptions::default(),
)
}
#[derive(Clone, Debug)]
pub struct CargoLeanCapability {
project_root: PathBuf,
target_name: String,
package: Option<String>,
module: Option<String>,
env_var: Option<String>,
manifest_env_var: Option<String>,
lean_sysroot: Option<PathBuf>,
export_signatures: Vec<LeanExportSignature>,
dependencies: Vec<LeanLibraryDependency>,
}
impl CargoLeanCapability {
#[must_use]
pub fn new(project_root: impl Into<PathBuf>, target_name: impl Into<String>) -> Self {
Self {
project_root: project_root.into(),
target_name: target_name.into(),
package: None,
module: None,
env_var: None,
manifest_env_var: None,
lean_sysroot: None,
export_signatures: Vec::new(),
dependencies: Vec::new(),
}
}
#[must_use]
pub fn package(mut self, package: impl Into<String>) -> Self {
self.package = Some(package.into());
self
}
#[must_use]
pub fn module(mut self, module: impl Into<String>) -> Self {
self.module = Some(module.into());
self
}
#[must_use]
pub fn env_var(mut self, env_var: impl Into<String>) -> Self {
self.env_var = Some(env_var.into());
self
}
#[must_use]
pub fn manifest_env_var(mut self, env_var: impl Into<String>) -> Self {
self.manifest_env_var = Some(env_var.into());
self
}
#[must_use]
pub fn lean_sysroot(mut self, sysroot: impl Into<PathBuf>) -> Self {
self.lean_sysroot = Some(sysroot.into());
self
}
#[must_use]
pub fn export_signature(mut self, signature: LeanExportSignature) -> Self {
self.export_signatures.push(signature);
self
}
#[must_use]
pub fn dependency(mut self, dependency: LeanLibraryDependency) -> Self {
self.dependencies.push(dependency);
self
}
pub fn build(self) -> Result<BuiltLeanCapability, LinkDiagnostics> {
self.build_with_runner(&mut RealLakeRunner, CargoMetadata::Emit)
}
pub fn build_quiet(self) -> Result<BuiltLeanCapability, LinkDiagnostics> {
self.build_with_runner(&mut RealLakeRunner, CargoMetadata::Suppress)
}
fn build_with_runner(
self,
runner: &mut impl LakeRunner,
cargo_metadata: CargoMetadata,
) -> Result<BuiltLeanCapability, LinkDiagnostics> {
let discover_options = self.discover_options();
let selected_toolchain = match cargo_metadata {
CargoMetadata::Emit => Some(emit_lean_link_directives_checked_with_options(&discover_options)?),
CargoMetadata::Suppress if self.lean_sysroot.is_some() => Some(discover_toolchain(&discover_options)?),
CargoMetadata::Suppress => None,
};
let lake_options = LakeBuildOptions {
lean_sysroot: self.lean_sysroot.clone(),
};
let dylib_path = build_lake_target_with_runner_and_options(
&self.project_root,
&self.target_name,
runner,
cargo_metadata,
&lake_options,
)?;
self.finish(dylib_path, cargo_metadata, selected_toolchain.as_ref())
}
fn discover_options(&self) -> DiscoverOptions {
let has_explicit_sysroot = self.lean_sysroot.is_some();
DiscoverOptions {
explicit_sysroot: self.lean_sysroot.clone(),
allow_lean_sysroot_env: !has_explicit_sysroot,
allow_path_lookup: !has_explicit_sysroot,
allow_elan: !has_explicit_sysroot,
allow_lake_env: !has_explicit_sysroot,
toolchain_file: None,
}
}
fn finish(
self,
dylib_path: PathBuf,
cargo_metadata: CargoMetadata,
selected_toolchain: Option<&ToolchainInfo>,
) -> Result<BuiltLeanCapability, LinkDiagnostics> {
let project_root =
fs::canonicalize(&self.project_root).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: self.project_root.clone(),
target_name: self.target_name.clone(),
reason: format!(
"could not canonicalize project root {} ({err})",
self.project_root.display()
),
})?;
let package = match self.package {
Some(package) => package,
None => infer_package_name(&project_root, &self.target_name)?,
};
let module = self.module.unwrap_or_else(|| self.target_name.clone());
let env_var = self.env_var.unwrap_or_else(|| capability_env_var(&self.target_name));
let manifest_env_var = self
.manifest_env_var
.unwrap_or_else(|| capability_manifest_env_var(&self.target_name));
let manifest_path = write_capability_manifest(
&project_root,
&self.target_name,
&package,
&module,
&dylib_path,
&manifest_env_var,
&self.export_signatures,
&self.dependencies,
selected_toolchain,
)?;
cargo_metadata.println(format_args!("cargo:rustc-env={env_var}={}", dylib_path.display()));
cargo_metadata.println(format_args!(
"cargo:rustc-env={manifest_env_var}={}",
manifest_path.display()
));
Ok(BuiltLeanCapability {
dylib_path,
env_var,
manifest_path,
manifest_env_var,
package,
module,
target_name: self.target_name,
project_root,
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BuiltLeanCapability {
dylib_path: PathBuf,
env_var: String,
manifest_path: PathBuf,
manifest_env_var: String,
package: String,
module: String,
target_name: String,
project_root: PathBuf,
}
impl BuiltLeanCapability {
#[must_use]
pub fn dylib_path(&self) -> &Path {
&self.dylib_path
}
#[must_use]
pub fn env_var(&self) -> &str {
&self.env_var
}
#[must_use]
pub fn manifest_path(&self) -> &Path {
&self.manifest_path
}
#[must_use]
pub fn manifest_env_var(&self) -> &str {
&self.manifest_env_var
}
#[must_use]
pub fn package(&self) -> &str {
&self.package
}
#[must_use]
pub fn module(&self) -> &str {
&self.module
}
#[must_use]
pub fn target_name(&self) -> &str {
&self.target_name
}
#[must_use]
pub fn project_root(&self) -> &Path {
&self.project_root
}
}
#[must_use]
pub fn capability_env_var(target_name: &str) -> String {
format!("LEAN_RS_CAPABILITY_{}_DYLIB", screaming_snake(target_name))
}
#[must_use]
pub fn capability_manifest_env_var(target_name: &str) -> String {
format!("LEAN_RS_CAPABILITY_{}_MANIFEST", screaming_snake(target_name))
}
fn emit_for(info: &ToolchainInfo) {
let lib_lean = info.lib_dir.join("lean");
println!("cargo:rustc-link-search=native={}", lib_lean.display());
println!("cargo:rustc-link-search=native={}", info.lib_dir.display());
println!("cargo:rustc-link-lib=dylib=leanshared");
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if matches!(target_os.as_str(), "macos" | "linux") {
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_lean.display());
}
emit_rerun_triggers(Some(info));
}
fn emit_rerun_triggers(info: Option<&ToolchainInfo>) {
if let Some(info) = info {
println!("cargo:rerun-if-changed={}", info.header_path.display());
}
println!("cargo:rerun-if-env-changed=LEAN_SYSROOT");
println!("cargo:rerun-if-env-changed=ELAN_HOME");
println!("cargo:rerun-if-env-changed=PATH");
}
trait LakeRunner {
fn build_shared(
&mut self,
project_root: &Path,
target_name: &str,
options: &LakeBuildOptions,
) -> Result<LakeRun, std::io::Error>;
}
struct RealLakeRunner;
impl LakeRunner for RealLakeRunner {
fn build_shared(
&mut self,
project_root: &Path,
target_name: &str,
options: &LakeBuildOptions,
) -> Result<LakeRun, std::io::Error> {
let mut command = if let Some(sysroot) = options.lean_sysroot.as_deref() {
let mut command = Command::new(sysroot.join("bin").join("lake"));
command.env("LEAN_SYSROOT", sysroot);
command
} else {
Command::new("lake")
};
let output = command
.arg("build")
.arg(format!("{target_name}:shared"))
.current_dir(project_root)
.output()?;
Ok(LakeRun {
success: output.status.success(),
status: output.status.to_string(),
stdout: output.stdout,
stderr: output.stderr,
})
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct LakeBuildOptions {
lean_sysroot: Option<PathBuf>,
}
struct LakeRun {
success: bool,
status: String,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CargoMetadata {
Emit,
Suppress,
}
impl CargoMetadata {
fn println(self, args: std::fmt::Arguments<'_>) {
if matches!(self, Self::Emit) {
println!("{args}");
}
}
fn trace(self, args: std::fmt::Arguments<'_>) {
if matches!(self, Self::Emit) {
emit_lake_trace(args);
}
}
}
#[cfg(test)]
fn build_lake_target_with_runner(
project_root: &Path,
target_name: &str,
runner: &mut impl LakeRunner,
cargo_metadata: CargoMetadata,
) -> Result<PathBuf, LinkDiagnostics> {
build_lake_target_with_runner_and_options(
project_root,
target_name,
runner,
cargo_metadata,
&LakeBuildOptions::default(),
)
}
fn build_lake_target_with_runner_and_options(
project_root: &Path,
target_name: &str,
runner: &mut impl LakeRunner,
cargo_metadata: CargoMetadata,
options: &LakeBuildOptions,
) -> Result<PathBuf, LinkDiagnostics> {
let project_root = fs::canonicalize(project_root).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("could not canonicalize project root {} ({err})", project_root.display()),
})?;
let lakefile_lean = project_root.join("lakefile.lean");
cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", lakefile_lean.display()));
let lakefile_toml = project_root.join("lakefile.toml");
if lakefile_toml.is_file() {
cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", lakefile_toml.display()));
}
let toolchain_file = project_root.join("lean-toolchain");
if toolchain_file.is_file() {
cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", toolchain_file.display()));
}
let lakefile = existing_lakefile(&project_root).ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.clone(),
target_name: target_name.to_owned(),
reason: format!(
"no Lake lakefile found at {} or {}",
lakefile_lean.display(),
lakefile_toml.display()
),
})?;
if !target_declared_in_lakefile(&lakefile, target_name)? {
return Err(LinkDiagnostics::LakeTargetMissing {
project_root,
target_name: target_name.to_owned(),
});
}
let manifest_path = project_root.join("lake-manifest.json");
cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", manifest_path.display()));
let (manifest_digest, package_name) = match fs::read(&manifest_path) {
Ok(manifest_bytes) => (
sha256_hex(&manifest_bytes),
package_name_from_manifest(&project_root, target_name, &manifest_path, &manifest_bytes)?,
),
Err(err) if err.kind() == io::ErrorKind::NotFound => (
"missing".to_owned(),
package_name_from_lakefile(&project_root, target_name, &lakefile)?,
),
Err(err) => {
return Err(LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.clone(),
target_name: target_name.to_owned(),
reason: format!("could not read {} ({err})", manifest_path.display()),
});
}
};
let source_set = scan_source_set(&project_root, target_name)?;
for path in &source_set.paths {
cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", path.display()));
}
let dylib = resolve_dylib_path(&project_root, &package_name, target_name);
let initial_cache_key = cache_key(target_name, &package_name, &manifest_digest, &source_set, options);
let cache_path = cache_path(&project_root, target_name);
if dylib.is_file() && fs::read_to_string(&cache_path).is_ok_and(|cached| cached == initial_cache_key) {
cargo_metadata.trace(format_args!(
"lean-toolchain: cache hit for Lake target `{target_name}` in {}; using {}",
project_root.display(),
dylib.display(),
));
return Ok(dylib);
}
cargo_metadata.trace(format_args!(
"lean-toolchain: cache miss for Lake target `{target_name}` in {}; running `lake build {target_name}:shared`",
project_root.display(),
));
let run = runner
.build_shared(&project_root, target_name, options)
.map_err(|err| LinkDiagnostics::LakeUnavailable {
project_root: project_root.clone(),
target_name: target_name.to_owned(),
detail: err.to_string(),
})?;
if !run.success {
return Err(LinkDiagnostics::LakeBuildFailed {
project_root,
target_name: target_name.to_owned(),
status: run.status,
detail: command_detail(&run.stdout, &run.stderr),
});
}
let (final_manifest_digest, final_package_name) = match fs::read(&manifest_path) {
Ok(manifest_bytes) => (
sha256_hex(&manifest_bytes),
package_name_from_manifest(&project_root, target_name, &manifest_path, &manifest_bytes)?,
),
Err(_) => (manifest_digest, package_name),
};
let final_cache_key = cache_key(
target_name,
&final_package_name,
&final_manifest_digest,
&source_set,
options,
);
let dylib = resolve_dylib_path(&project_root, &final_package_name, target_name);
if !dylib.is_file() {
return Err(LinkDiagnostics::LakeOutputUnresolved {
project_root,
target_name: target_name.to_owned(),
reason: format!("expected shared library at {}", dylib.display()),
});
}
if let Some(parent) = cache_path.parent() {
fs::create_dir_all(parent).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.clone(),
target_name: target_name.to_owned(),
reason: format!("could not create cache directory {} ({err})", parent.display()),
})?;
}
fs::write(&cache_path, final_cache_key).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root,
target_name: target_name.to_owned(),
reason: format!("could not write cache file {} ({err})", cache_path.display()),
})?;
Ok(dylib)
}
fn target_declared_in_lakefile(lakefile: &Path, target_name: &str) -> Result<bool, LinkDiagnostics> {
let contents = fs::read_to_string(lakefile).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: lakefile.parent().unwrap_or_else(|| Path::new("")).to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("could not read {} ({err})", lakefile.display()),
})?;
if lakefile_is_toml(lakefile) {
let parsed = parse_lakefile_toml(&contents).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: lakefile.parent().unwrap_or_else(|| Path::new("")).to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("lakefile {} is not valid TOML ({err})", lakefile.display()),
})?;
return Ok(parsed.lean_libs.iter().any(|name| name == target_name));
}
let quoted = format!("lean_lib «{target_name}»");
let bare = format!("lean_lib {target_name}");
let string = format!("lean_lib \"{target_name}\"");
Ok(contents.contains("ed) || contents.contains(&bare) || contents.contains(&string))
}
fn lakefile_is_toml(lakefile: &Path) -> bool {
lakefile.file_name().and_then(|name| name.to_str()) == Some("lakefile.toml")
}
fn existing_lakefile(project_root: &Path) -> Option<PathBuf> {
let toml = project_root.join("lakefile.toml");
if toml.is_file() {
return Some(toml);
}
let lean = project_root.join("lakefile.lean");
lean.is_file().then_some(lean)
}
fn package_name_from_manifest(
project_root: &Path,
target_name: &str,
manifest_path: &Path,
manifest_bytes: &[u8],
) -> Result<String, LinkDiagnostics> {
let manifest: serde_json::Value =
serde_json::from_slice(manifest_bytes).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("{} is not valid JSON ({err})", manifest_path.display()),
})?;
manifest
.get("name")
.and_then(serde_json::Value::as_str)
.filter(|name| !name.is_empty())
.map(str::to_owned)
.ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("{} has no string `name` field", manifest_path.display()),
})
}
fn package_name_from_lakefile(
project_root: &Path,
target_name: &str,
lakefile: &Path,
) -> Result<String, LinkDiagnostics> {
let contents = fs::read_to_string(lakefile).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("could not read {} ({err})", lakefile.display()),
})?;
if lakefile_is_toml(lakefile) {
let parsed = parse_lakefile_toml(&contents).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("lakefile {} is not valid TOML ({err})", lakefile.display()),
})?;
return parsed
.package_name
.ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("{} has no top-level `name` field", lakefile.display()),
});
}
for line in contents.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("package ") {
return Ok(normalize_lake_identifier(rest));
}
}
Err(LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("{} has no `package` declaration", lakefile.display()),
})
}
fn infer_package_name(project_root: &Path, target_name: &str) -> Result<String, LinkDiagnostics> {
let manifest_path = project_root.join("lake-manifest.json");
match fs::read(&manifest_path) {
Ok(manifest_bytes) => package_name_from_manifest(project_root, target_name, &manifest_path, &manifest_bytes),
Err(err) if err.kind() == io::ErrorKind::NotFound => {
let lakefile = existing_lakefile(project_root).ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!(
"no Lake lakefile found at {} or {}",
project_root.join("lakefile.lean").display(),
project_root.join("lakefile.toml").display()
),
})?;
package_name_from_lakefile(project_root, target_name, &lakefile)
}
Err(err) => Err(LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("could not read {} ({err})", manifest_path.display()),
}),
}
}
fn normalize_lake_identifier(raw: &str) -> String {
raw.trim()
.trim_matches('«')
.trim_matches('»')
.trim_matches('"')
.trim()
.to_owned()
}
struct SourceSet {
paths: Vec<PathBuf>,
max_mtime_ns: u128,
}
fn scan_source_set(project_root: &Path, target_name: &str) -> Result<SourceSet, LinkDiagnostics> {
let mut paths = Vec::new();
collect_lean_sources(project_root, project_root, target_name, &mut paths)?;
for file_name in ["lakefile.lean", "lakefile.toml", "lean-toolchain"] {
let path = project_root.join(file_name);
if path.is_file() {
paths.push(path);
}
}
paths.sort();
paths.dedup();
let mut max_mtime_ns = 0;
for path in &paths {
let metadata = fs::metadata(path).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("could not stat {} ({err})", path.display()),
})?;
let modified = metadata
.modified()
.map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("could not read mtime for {} ({err})", path.display()),
})?;
let mtime_ns = modified
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
max_mtime_ns = max_mtime_ns.max(mtime_ns);
}
Ok(SourceSet { paths, max_mtime_ns })
}
fn collect_lean_sources(
project_root: &Path,
dir: &Path,
target_name: &str,
paths: &mut Vec<PathBuf>,
) -> Result<(), LinkDiagnostics> {
for entry in fs::read_dir(dir).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("could not read directory {} ({err})", dir.display()),
})? {
let entry = entry.map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("could not read directory entry under {} ({err})", dir.display()),
})?;
let path = entry.path();
if path.file_name().is_some_and(|name| name == ".lake") {
continue;
}
let metadata = entry.metadata().map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("could not stat {} ({err})", path.display()),
})?;
if metadata.is_dir() {
collect_lean_sources(project_root, &path, target_name, paths)?;
} else if path.extension().is_some_and(|ext| ext == "lean") {
paths.push(path);
}
}
Ok(())
}
fn resolve_dylib_path(project_root: &Path, package_name: &str, target_name: &str) -> PathBuf {
let dylib_extension = if cfg!(target_os = "macos") { "dylib" } else { "so" };
let lib_dir = project_root.join(".lake").join("build").join("lib");
let escaped_package = package_name.replace('_', "__");
let new_style = lib_dir.join(format!("lib{escaped_package}_{target_name}.{dylib_extension}"));
let old_style = lib_dir.join(format!("lib{target_name}.{dylib_extension}"));
if new_style.is_file() {
new_style
} else if old_style.is_file() {
old_style
} else {
new_style
}
}
fn cache_path(project_root: &Path, target_name: &str) -> PathBuf {
project_root
.join(".lake")
.join("lean-rs-build-cache")
.join(format!("{}.cache", sanitize_target_name(target_name)))
}
fn write_capability_manifest(
project_root: &Path,
target_name: &str,
package: &str,
module: &str,
dylib_path: &Path,
manifest_env_var: &str,
export_signatures: &[LeanExportSignature],
explicit_dependencies: &[LeanLibraryDependency],
selected_toolchain: Option<&ToolchainInfo>,
) -> Result<PathBuf, LinkDiagnostics> {
let manifest_path = capability_manifest_path(project_root, target_name, export_signatures);
let mut dependencies = capability_dependencies(project_root, target_name)?;
dependencies.extend(explicit_dependencies.iter().map(lean_library_dependency_to_json));
let fingerprint = ToolchainFingerprint::current();
let search_dirs = capability_search_dirs(project_root, dylib_path);
let build_toolchain = selected_toolchain.map(|info| {
serde_json::json!({
"source": format!("{:?}", info.source),
"sysroot": info.prefix.display().to_string(),
"version": &info.version,
"lean_binary": info.lean_binary.as_ref().map(|path| path.display().to_string()),
})
});
let manifest = serde_json::json!({
"schema_version": CAPABILITY_MANIFEST_SCHEMA_VERSION,
"target_name": target_name,
"package": package,
"module": module,
"primary_dylib": dylib_path.display().to_string(),
"manifest_env_var": manifest_env_var,
"lean_version": &fingerprint.lean_version,
"resolved_lean_version": &fingerprint.resolved_version,
"lean_header_sha256": &fingerprint.header_sha256,
"toolchain_fingerprint": {
"lean_version": &fingerprint.lean_version,
"resolved_version": &fingerprint.resolved_version,
"header_sha256": &fingerprint.header_sha256,
"fixture_sha256": &fingerprint.fixture_sha256,
"host_triple": &fingerprint.host_triple,
},
"build_toolchain": build_toolchain,
"search_dirs": search_dirs
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>(),
"dependencies": dependencies,
"exports": export_signatures
.iter()
.map(LeanExportSignature::to_json)
.collect::<Vec<_>>(),
});
let bytes = serde_json::to_vec_pretty(&manifest).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("could not encode Lean capability manifest ({err})"),
})?;
if let Some(parent) = manifest_path.parent() {
fs::create_dir_all(parent).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("could not create manifest directory {} ({err})", parent.display()),
})?;
}
write_atomic(&manifest_path, &bytes).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!(
"could not atomically write Lean capability manifest {} ({err})",
manifest_path.display()
),
})?;
Ok(manifest_path)
}
fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or("manifest");
for attempt in 0..100_u32 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let tmp_path = parent.join(format!(".{file_name}.{}.{}.{}.tmp", std::process::id(), nanos, attempt));
match OpenOptions::new().write(true).create_new(true).open(&tmp_path) {
Ok(mut file) => {
if let Err(err) = file.write_all(bytes).and_then(|()| file.sync_all()) {
drop(file);
drop(fs::remove_file(&tmp_path));
return Err(err);
}
drop(file);
if let Err(err) = fs::rename(&tmp_path, path) {
drop(fs::remove_file(&tmp_path));
return Err(err);
}
return Ok(());
}
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(err),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("could not allocate temporary path for {}", path.display()),
))
}
fn capability_manifest_path(
project_root: &Path,
target_name: &str,
export_signatures: &[LeanExportSignature],
) -> PathBuf {
let manifest_name = capability_manifest_name(target_name, export_signatures);
if let Some(out_dir) = env::var_os("OUT_DIR") {
PathBuf::from(out_dir).join(manifest_name)
} else {
project_root
.join(".lake")
.join("lean-rs-build-cache")
.join(manifest_name)
}
}
fn capability_manifest_name(target_name: &str, export_signatures: &[LeanExportSignature]) -> String {
let target = sanitize_target_name(target_name);
if export_signatures.is_empty() {
return format!("{target}.lean-rs-capability.json");
}
let mut hasher = Sha256::new();
for signature in export_signatures {
hasher.update(signature.symbol().as_bytes());
hasher.update([0]);
if let Ok(bytes) = serde_json::to_vec(&signature.to_json()) {
hasher.update(bytes);
}
hasher.update([0xff]);
}
let digest = hasher.finalize();
let mut suffix = String::with_capacity(16);
for byte in digest.iter().take(8) {
let _ = write!(&mut suffix, "{byte:02x}");
}
format!("{target}-{suffix}.lean-rs-capability.json")
}
fn capability_search_dirs(project_root: &Path, dylib_path: &Path) -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(parent) = dylib_path.parent() {
dirs.push(parent.to_path_buf());
}
dirs.push(project_root.join(".lake").join("build").join("lib"));
dirs.sort();
dirs.dedup();
dirs
}
fn capability_dependencies(project_root: &Path, target_name: &str) -> Result<Vec<serde_json::Value>, LinkDiagnostics> {
let manifest_path = project_root.join("lake-manifest.json");
let bytes = match fs::read(&manifest_path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => {
return Err(LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("could not read {} ({err})", manifest_path.display()),
});
}
};
let manifest: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!("{} is not valid JSON ({err})", manifest_path.display()),
})?;
let packages = manifest
.get("packages")
.and_then(serde_json::Value::as_array)
.map_or([].as_slice(), Vec::as_slice);
let mut dependencies = Vec::new();
for package in packages {
let Some(name) = package.get("name").and_then(serde_json::Value::as_str) else {
continue;
};
if name != "lean_rs_interop_shims" {
continue;
}
let Some(dir) = package.get("dir").and_then(serde_json::Value::as_str) else {
continue;
};
let dependency_root = project_root.join(dir);
let dependency_root =
fs::canonicalize(&dependency_root).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
project_root: project_root.to_path_buf(),
target_name: target_name.to_owned(),
reason: format!(
"could not canonicalize dependency root {} ({err})",
dependency_root.display()
),
})?;
let dylib = resolve_dylib_path(&dependency_root, "lean_rs_interop_shims", "LeanRsInterop");
dependencies.push(serde_json::json!({
"name": name,
"dylib_path": dylib.display().to_string(),
"export_symbols_for_dependents": true,
"initializer": {
"package": "lean_rs_interop_shims",
"module": "LeanRsInterop",
}
}));
}
Ok(dependencies)
}
fn lean_library_dependency_to_json(dependency: &LeanLibraryDependency) -> serde_json::Value {
let initializer = dependency.module_initializer().map(|initializer| {
serde_json::json!({
"package": initializer.package_name(),
"module": initializer.module_name(),
})
});
let name = dependency.module_initializer().map_or_else(
|| stable_dependency_name(dependency.path_ref()),
|initializer| initializer.package_name().to_owned(),
);
serde_json::json!({
"name": name,
"dylib_path": dependency.path_ref().display().to_string(),
"export_symbols_for_dependents": dependency.exports_symbols_for_dependents(),
"initializer": initializer,
})
}
fn stable_dependency_name(path: &Path) -> String {
path.file_stem()
.and_then(|stem| stem.to_str())
.filter(|stem| !stem.is_empty())
.unwrap_or("lean_dependency")
.to_owned()
}
fn sanitize_target_name(target_name: &str) -> String {
target_name
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
ch
} else {
'_'
}
})
.collect()
}
fn screaming_snake(input: &str) -> String {
let mut out = String::new();
let mut prev_was_sep = true;
let mut prev_was_lower_or_digit = false;
for ch in input.chars() {
if ch.is_ascii_alphanumeric() {
if ch.is_ascii_uppercase() && prev_was_lower_or_digit && !prev_was_sep {
out.push('_');
}
out.push(ch.to_ascii_uppercase());
prev_was_sep = false;
prev_was_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
} else {
if !prev_was_sep {
out.push('_');
}
prev_was_sep = true;
prev_was_lower_or_digit = false;
}
}
while out.ends_with('_') {
out.pop();
}
if out.is_empty() { "CAPABILITY".to_owned() } else { out }
}
fn cache_key(
target_name: &str,
package_name: &str,
manifest_digest: &str,
source_set: &SourceSet,
options: &LakeBuildOptions,
) -> String {
let sysroot = options
.lean_sysroot
.as_deref()
.map_or("ambient", |path| path.to_str().unwrap_or("<non-utf8-sysroot>"));
format!(
"target={target_name}\npackage={package_name}\nmanifest={manifest_digest}\nsource_count={}\nsource_max_mtime_ns={}\nlean_sysroot={sysroot}\n",
source_set.paths.len(),
source_set.max_mtime_ns
)
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
let mut out = String::with_capacity(digest.len().saturating_mul(2));
for byte in digest {
let _ = write!(out, "{byte:02x}");
}
out
}
fn command_detail(stdout: &[u8], stderr: &[u8]) -> String {
let mut raw = String::new();
if !stderr.is_empty() {
raw.push_str(&String::from_utf8_lossy(stderr));
}
if !stdout.is_empty() {
if !raw.is_empty() {
raw.push_str(" | ");
}
raw.push_str(&String::from_utf8_lossy(stdout));
}
let collapsed = raw.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.is_empty() {
"no output".to_owned()
} else if collapsed.len() > 1024 {
let mut bounded = collapsed.chars().take(1024).collect::<String>();
bounded.push_str("...");
bounded
} else {
collapsed
}
}
fn emit_lake_trace(args: std::fmt::Arguments<'_>) {
let mut stderr = io::stderr().lock();
drop(stderr.write_fmt(args));
drop(stderr.write_all(b"\n"));
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, clippy::wildcard_enum_match_arm)]
mod tests {
use super::{
CAPABILITY_MANIFEST_SCHEMA_VERSION, CargoLeanCapability, CargoMetadata, LakeBuildOptions, LakeRun, LakeRunner,
build_lake_target_with_runner, build_lake_target_with_runner_and_options, capability_env_var,
capability_manifest_env_var, capability_manifest_name, command_detail,
};
use crate::LinkDiagnostics;
use crate::{
LeanExportAbiRepr, LeanExportArgAbi, LeanExportOwnership, LeanExportResultConvention, LeanExportReturnAbi,
LeanExportSignature, LeanLibraryDependency,
};
use std::cell::{Cell, RefCell};
use std::fs;
use std::path::{Path, PathBuf};
use std::rc::Rc;
#[derive(Clone)]
struct FakeLake {
calls: Rc<Cell<usize>>,
seen_sysroots: Rc<RefCell<Vec<Option<PathBuf>>>>,
mode: FakeMode,
}
#[derive(Clone)]
enum FakeMode {
SuccessModern,
SuccessLegacy,
Failure,
SpawnError,
}
impl FakeLake {
fn new(mode: FakeMode) -> Self {
Self {
calls: Rc::new(Cell::new(0)),
seen_sysroots: Rc::new(RefCell::new(Vec::new())),
mode,
}
}
fn calls(&self) -> usize {
self.calls.get()
}
fn seen_sysroots(&self) -> Vec<Option<PathBuf>> {
self.seen_sysroots.borrow().clone()
}
}
impl LakeRunner for FakeLake {
fn build_shared(
&mut self,
project_root: &Path,
target_name: &str,
options: &LakeBuildOptions,
) -> Result<LakeRun, std::io::Error> {
self.calls.set(self.calls.get().saturating_add(1));
self.seen_sysroots.borrow_mut().push(options.lean_sysroot.clone());
match self.mode {
FakeMode::SuccessModern => {
let dylib = project_root
.join(".lake")
.join("build")
.join("lib")
.join(format!("libmy__pkg_{target_name}.{}", dylib_ext()));
write_file(&dylib, "dylib");
Ok(success_run())
}
FakeMode::SuccessLegacy => {
let dylib = project_root
.join(".lake")
.join("build")
.join("lib")
.join(format!("lib{target_name}.{}", dylib_ext()));
write_file(&dylib, "dylib");
Ok(success_run())
}
FakeMode::Failure => Ok(LakeRun {
success: false,
status: "exit status: 1".to_owned(),
stdout: b"stdout detail\n".to_vec(),
stderr: b"stderr detail\n".to_vec(),
}),
FakeMode::SpawnError => Err(std::io::Error::new(std::io::ErrorKind::NotFound, "lake missing")),
}
}
}
fn success_run() -> LakeRun {
LakeRun {
success: true,
status: "exit status: 0".to_owned(),
stdout: Vec::new(),
stderr: Vec::new(),
}
}
fn dylib_ext() -> &'static str {
if cfg!(target_os = "macos") { "dylib" } else { "so" }
}
fn make_project(name: &str, target: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("lean-toolchain-lake-{}-{}", std::process::id(), name));
drop(fs::remove_dir_all(&root));
fs::create_dir_all(&root).expect("create temp project");
write_file(
&root.join("lakefile.lean"),
&format!(
"import Lake\nopen Lake DSL\npackage «my_pkg»\n@[default_target]\nlean_lib «{target}» where\n defaultFacets := #[LeanLib.sharedFacet]\n"
),
);
write_file(
&root.join("lake-manifest.json"),
r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[],"name":"my_pkg","lakeDir":".lake"}"#,
);
write_file(&root.join("lean-toolchain"), "leanprover/lean4:v4.29.1\n");
write_file(&root.join(format!("{target}.lean")), "def hello : Nat := 1\n");
root
}
fn make_toml_project(name: &str, target: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("lean-toolchain-lake-{}-{}", std::process::id(), name));
drop(fs::remove_dir_all(&root));
fs::create_dir_all(&root).expect("create temp project");
write_file(
&root.join("lakefile.toml"),
&format!("name = \"my_pkg\"\ndefaultTargets = [\"{target}\"]\n\n[[lean_lib]]\nname = \"{target}\"\n"),
);
write_file(
&root.join("lake-manifest.json"),
r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[],"name":"my_pkg","lakeDir":".lake"}"#,
);
write_file(&root.join("lean-toolchain"), "leanprover/lean4:v4.29.1\n");
write_file(&root.join(format!("{target}.lean")), "def hello : Nat := 1\n");
root
}
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent");
}
fs::write(path, contents).expect("write file");
}
fn make_fake_sysroot(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("lean-toolchain-sysroot-{}-{name}", std::process::id()));
drop(fs::remove_dir_all(&root));
write_file(&root.join("include").join("lean").join("lean.h"), "/* fake lean.h */\n");
root
}
#[test]
fn cache_hit_skips_lake_invocation() {
let root = make_project("cache-hit", "MyCapability");
let mut runner = FakeLake::new(FakeMode::SuccessModern);
let first = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
.expect("first build");
let second = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
.expect("cached build");
assert_eq!(first, second);
assert_eq!(runner.calls(), 1, "second call should use cache");
}
#[test]
fn explicit_lake_sysroot_is_part_of_runner_options_and_cache_key() {
let root = make_project("explicit-sysroot-cache", "MyCapability");
let sysroot = PathBuf::from("/configured/lean/sysroot");
let mut runner = FakeLake::new(FakeMode::SuccessModern);
let options = LakeBuildOptions {
lean_sysroot: Some(sysroot.clone()),
};
let first = build_lake_target_with_runner_and_options(
&root,
"MyCapability",
&mut runner,
CargoMetadata::Emit,
&options,
)
.expect("first explicit build");
let second = build_lake_target_with_runner_and_options(
&root,
"MyCapability",
&mut runner,
CargoMetadata::Emit,
&options,
)
.expect("cached explicit build");
assert_eq!(first, second);
assert_eq!(runner.calls(), 1, "second call should use explicit-sysroot cache");
assert_eq!(runner.seen_sysroots(), vec![Some(sysroot)]);
}
#[test]
fn missing_manifest_lets_lake_create_manifest() {
let root = make_project("missing-manifest", "MyCapability");
fs::remove_file(root.join("lake-manifest.json")).expect("remove manifest");
let mut runner = FakeLake::new(FakeMode::SuccessModern);
let path = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
.expect("build without checked-in manifest");
assert!(path.ends_with(format!("libmy__pkg_MyCapability.{}", dylib_ext())));
assert_eq!(runner.calls(), 1);
}
#[test]
fn legacy_output_path_is_supported() {
let root = make_project("legacy", "MyCapability");
let mut runner = FakeLake::new(FakeMode::SuccessLegacy);
let path = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
.expect("legacy build");
assert!(path.ends_with(format!("libMyCapability.{}", dylib_ext())));
}
#[test]
fn missing_target_is_typed() {
let root = make_project("missing-target", "MyCapability");
let mut runner = FakeLake::new(FakeMode::SuccessModern);
let err = build_lake_target_with_runner(&root, "OtherTarget", &mut runner, CargoMetadata::Emit)
.expect_err("missing target");
match err {
LinkDiagnostics::LakeTargetMissing { target_name, .. } => assert_eq!(target_name, "OtherTarget"),
other => panic!("expected LakeTargetMissing, got {other:?}"),
}
assert_eq!(runner.calls(), 0);
}
#[test]
fn toml_lakefile_build_succeeds() {
let root = make_toml_project("toml-success", "FixtureLib");
let mut runner = FakeLake::new(FakeMode::SuccessModern);
let path = build_lake_target_with_runner(&root, "FixtureLib", &mut runner, CargoMetadata::Emit)
.expect("TOML lakefile build");
assert!(path.ends_with(format!("libmy__pkg_FixtureLib.{}", dylib_ext())));
assert_eq!(runner.calls(), 1);
}
#[test]
fn toml_lakefile_missing_target_is_typed() {
let root = make_toml_project("toml-missing", "FixtureLib");
let mut runner = FakeLake::new(FakeMode::SuccessModern);
let err = build_lake_target_with_runner(&root, "OtherTarget", &mut runner, CargoMetadata::Emit)
.expect_err("missing TOML target");
match err {
LinkDiagnostics::LakeTargetMissing { target_name, .. } => assert_eq!(target_name, "OtherTarget"),
other => panic!("expected LakeTargetMissing, got {other:?}"),
}
assert_eq!(runner.calls(), 0);
}
#[test]
fn toml_lakefile_missing_manifest_resolves_package() {
let root = make_toml_project("toml-no-manifest", "FixtureLib");
fs::remove_file(root.join("lake-manifest.json")).expect("remove manifest");
let mut runner = FakeLake::new(FakeMode::SuccessModern);
let path = build_lake_target_with_runner(&root, "FixtureLib", &mut runner, CargoMetadata::Emit)
.expect("TOML build without manifest");
assert!(path.ends_with(format!("libmy__pkg_FixtureLib.{}", dylib_ext())));
assert_eq!(runner.calls(), 1);
}
#[test]
fn build_failure_is_typed_and_one_line() {
let root = make_project("failure", "MyCapability");
let mut runner = FakeLake::new(FakeMode::Failure);
let err = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
.expect_err("failure");
let rendered = format!("{err}");
match err {
LinkDiagnostics::LakeBuildFailed { detail, .. } => {
assert!(detail.contains("stderr detail"));
assert!(detail.contains("stdout detail"));
assert!(!detail.contains('\n'));
}
other => panic!("expected LakeBuildFailed, got {other:?}"),
}
assert!(!rendered.contains('\n'));
}
#[test]
fn missing_lake_is_typed() {
let root = make_project("spawn-error", "MyCapability");
let mut runner = FakeLake::new(FakeMode::SpawnError);
let err = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
.expect_err("spawn error");
match err {
LinkDiagnostics::LakeUnavailable {
target_name, detail, ..
} => {
assert_eq!(target_name, "MyCapability");
assert!(detail.contains("lake missing"));
}
other => panic!("expected LakeUnavailable, got {other:?}"),
}
assert_eq!(runner.calls(), 1);
}
#[test]
fn cache_hit_skips_lake_invocation_for_interop_dependency_shape() {
let root = make_project("interop-cache-hit", "InteropConsumer");
write_file(
&root.join("lakefile.lean"),
"import Lake\nopen Lake DSL\npackage «my_pkg»\nrequire «lean_rs_interop_shims» from \"../../crates/lean-rs/shims/lean-rs-interop-shims\"\n@[default_target]\nlean_lib «InteropConsumer» where\n defaultFacets := #[LeanLib.sharedFacet]\n",
);
write_file(
&root.join("lake-manifest.json"),
r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[{"type":"path","scope":"","name":"lean_rs_interop_shims","manifestFile":"lake-manifest.json","inherited":false,"dir":"../../crates/lean-rs/shims/lean-rs-interop-shims","configFile":"lakefile.lean"}],"name":"my_pkg","lakeDir":".lake"}"#,
);
let mut runner = FakeLake::new(FakeMode::SuccessModern);
let first = build_lake_target_with_runner(&root, "InteropConsumer", &mut runner, CargoMetadata::Emit)
.expect("first build");
let second = build_lake_target_with_runner(&root, "InteropConsumer", &mut runner, CargoMetadata::Emit)
.expect("cached build");
assert_eq!(first, second);
assert_eq!(runner.calls(), 1, "second call should use cache");
}
#[test]
fn command_detail_is_bounded() {
let detail = command_detail(&vec![b'x'; 4096], b"");
assert!(detail.len() <= 1027);
assert!(detail.ends_with("..."));
}
#[test]
fn capability_env_var_is_deterministic() {
assert_eq!(
capability_env_var("MyCapability"),
"LEAN_RS_CAPABILITY_MY_CAPABILITY_DYLIB"
);
assert_eq!(
capability_env_var("lean-dup_index"),
"LEAN_RS_CAPABILITY_LEAN_DUP_INDEX_DYLIB"
);
}
#[test]
fn capability_manifest_env_var_is_deterministic() {
assert_eq!(
capability_manifest_env_var("MyCapability"),
"LEAN_RS_CAPABILITY_MY_CAPABILITY_MANIFEST"
);
assert_eq!(
capability_manifest_env_var("lean-dup_index"),
"LEAN_RS_CAPABILITY_LEAN_DUP_INDEX_MANIFEST"
);
}
#[test]
fn capability_manifest_name_includes_signature_digest_for_non_empty_exports() {
let first = vec![LeanExportSignature::function(
"my_capability_u8_identity",
vec![LeanExportArgAbi::new(LeanExportAbiRepr::U8, LeanExportOwnership::None)],
LeanExportReturnAbi::new(
LeanExportAbiRepr::U8,
LeanExportOwnership::None,
LeanExportResultConvention::Pure,
),
)];
let second = vec![LeanExportSignature::function(
"my_capability_u16_identity",
vec![LeanExportArgAbi::new(LeanExportAbiRepr::U16, LeanExportOwnership::None)],
LeanExportReturnAbi::new(
LeanExportAbiRepr::U16,
LeanExportOwnership::None,
LeanExportResultConvention::Pure,
),
)];
assert_eq!(
capability_manifest_name("MyCapability", &[]),
"MyCapability.lean-rs-capability.json"
);
let first_name = capability_manifest_name("MyCapability", &first);
let second_name = capability_manifest_name("MyCapability", &second);
assert_ne!(first_name, second_name);
assert!(first_name.starts_with("MyCapability-"));
assert!(first_name.ends_with(".lean-rs-capability.json"));
assert!(second_name.starts_with("MyCapability-"));
assert!(second_name.ends_with(".lean-rs-capability.json"));
}
#[test]
fn cargo_capability_build_quiet_returns_metadata() {
let root = make_project("cargo-capability", "MyCapability");
let mut runner = FakeLake::new(FakeMode::SuccessModern);
let dylib = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Suppress)
.expect("build target");
let built = CargoLeanCapability::new(&root, "MyCapability")
.package("my_pkg")
.module("MyCapability")
.env_var("MY_CAPABILITY_DYLIB")
.manifest_env_var("MY_CAPABILITY_MANIFEST")
.export_signature(LeanExportSignature::function(
"my_capability_u8_identity",
vec![LeanExportArgAbi::new(LeanExportAbiRepr::U8, LeanExportOwnership::None)],
LeanExportReturnAbi::new(
LeanExportAbiRepr::U8,
LeanExportOwnership::None,
LeanExportResultConvention::Pure,
),
))
.build_quiet()
.expect("cargo helper build");
assert_eq!(built.dylib_path(), dylib.as_path());
assert_eq!(built.env_var(), "MY_CAPABILITY_DYLIB");
assert_eq!(built.manifest_env_var(), "MY_CAPABILITY_MANIFEST");
assert!(built.manifest_path().is_file());
assert_eq!(built.package(), "my_pkg");
assert_eq!(built.module(), "MyCapability");
assert_eq!(built.target_name(), "MyCapability");
assert!(built.project_root().is_absolute());
let manifest: serde_json::Value =
serde_json::from_slice(&fs::read(built.manifest_path()).expect("read manifest"))
.expect("manifest is valid JSON");
assert_eq!(
manifest.get("schema_version").and_then(serde_json::Value::as_u64),
Some(u64::from(CAPABILITY_MANIFEST_SCHEMA_VERSION)),
);
assert_eq!(
manifest.get("package").and_then(serde_json::Value::as_str),
Some("my_pkg")
);
assert_eq!(
manifest.get("module").and_then(serde_json::Value::as_str),
Some("MyCapability")
);
assert_eq!(
manifest
.get("primary_dylib")
.and_then(serde_json::Value::as_str)
.map(Path::new),
Some(dylib.as_path()),
);
assert!(manifest.get("toolchain_fingerprint").is_some());
assert_eq!(
manifest
.get("exports")
.and_then(serde_json::Value::as_array)
.and_then(|exports| exports.first())
.and_then(|export| export.get("symbol"))
.and_then(serde_json::Value::as_str),
Some("my_capability_u8_identity"),
);
}
#[test]
fn cargo_capability_manifest_records_explicit_dependencies() {
let root = make_project("cargo-capability-explicit-dependency", "MyCapability");
let dependency = root.join(".lake").join("build").join("lib").join("libdependency.dylib");
write_file(&dependency, "dependency dylib");
let built = CargoLeanCapability::new(&root, "MyCapability")
.package("my_pkg")
.module("MyCapability")
.dependency(
LeanLibraryDependency::path(&dependency)
.export_symbols_for_dependents()
.initializer("dependency_pkg", "Dependency"),
)
.build_quiet()
.expect("cargo helper build");
let manifest: serde_json::Value =
serde_json::from_slice(&fs::read(built.manifest_path()).expect("read manifest"))
.expect("manifest is valid JSON");
let dependencies = manifest
.get("dependencies")
.and_then(serde_json::Value::as_array)
.expect("manifest dependencies array");
assert_eq!(dependencies.len(), 1);
let dependency_json = dependencies.first().expect("one dependency");
assert_eq!(
dependency_json.get("name").and_then(serde_json::Value::as_str),
Some("dependency_pkg")
);
assert_eq!(
dependency_json
.get("dylib_path")
.and_then(serde_json::Value::as_str)
.map(Path::new),
Some(dependency.as_path())
);
assert_eq!(
dependency_json
.get("export_symbols_for_dependents")
.and_then(serde_json::Value::as_bool),
Some(true)
);
assert_eq!(
dependency_json
.get("initializer")
.and_then(|initializer| initializer.get("package"))
.and_then(serde_json::Value::as_str),
Some("dependency_pkg")
);
assert_eq!(
dependency_json
.get("initializer")
.and_then(|initializer| initializer.get("module"))
.and_then(serde_json::Value::as_str),
Some("Dependency")
);
}
#[test]
fn cargo_capability_dependency_without_initializer_uses_stable_file_name() {
let root = make_project("cargo-capability-explicit-dependency-no-init", "MyCapability");
let dependency = root.join(".lake").join("build").join("lib").join("libsupport.dylib");
write_file(&dependency, "dependency dylib");
let built = CargoLeanCapability::new(&root, "MyCapability")
.package("my_pkg")
.module("MyCapability")
.dependency(LeanLibraryDependency::path(&dependency))
.build_quiet()
.expect("cargo helper build");
let manifest: serde_json::Value =
serde_json::from_slice(&fs::read(built.manifest_path()).expect("read manifest"))
.expect("manifest is valid JSON");
let dependency_json = manifest
.get("dependencies")
.and_then(serde_json::Value::as_array)
.and_then(|dependencies| dependencies.first())
.expect("one dependency");
assert_eq!(
dependency_json.get("name").and_then(serde_json::Value::as_str),
Some("libsupport")
);
assert!(
dependency_json
.get("initializer")
.is_some_and(serde_json::Value::is_null),
"dependencies without initializers record null initializer"
);
}
#[test]
fn cargo_capability_build_quiet_passes_explicit_sysroot_to_lake() {
let root = make_project("cargo-capability-explicit-sysroot", "MyCapability");
let sysroot = make_fake_sysroot("cargo-capability");
let mut runner = FakeLake::new(FakeMode::SuccessModern);
let built = CargoLeanCapability::new(&root, "MyCapability")
.package("my_pkg")
.module("MyCapability")
.lean_sysroot(&sysroot)
.build_with_runner(&mut runner, CargoMetadata::Suppress)
.expect("cargo helper build");
assert_eq!(runner.seen_sysroots(), vec![Some(sysroot.clone())]);
let manifest: serde_json::Value =
serde_json::from_slice(&fs::read(built.manifest_path()).expect("read manifest"))
.expect("manifest is valid JSON");
let build_toolchain = manifest
.get("build_toolchain")
.expect("manifest records selected build toolchain");
assert_eq!(
build_toolchain.get("source").and_then(serde_json::Value::as_str),
Some("ExplicitSysroot")
);
assert_eq!(
build_toolchain.get("sysroot").and_then(serde_json::Value::as_str),
Some(sysroot.to_str().expect("test sysroot is UTF-8"))
);
}
#[test]
fn cargo_capability_explicit_sysroot_does_not_fall_back_to_ambient_discovery() {
let root = make_project("cargo-capability-invalid-explicit-sysroot", "MyCapability");
let mut runner = FakeLake::new(FakeMode::SuccessModern);
let err = CargoLeanCapability::new(&root, "MyCapability")
.package("my_pkg")
.module("MyCapability")
.lean_sysroot("/definitely/not/a/lean/sysroot")
.build_with_runner(&mut runner, CargoMetadata::Suppress)
.expect_err("invalid explicit sysroot must not fall back to ambient probes");
match err {
LinkDiagnostics::MissingLean { tried } => {
assert!(
tried.iter().any(|line| line.contains("explicit_sysroot=")),
"diagnostic should name the explicit sysroot probe: {tried:?}",
);
assert!(
tried.iter().any(|line| line == "PATH lookup disabled"),
"ambient PATH probe should be disabled: {tried:?}",
);
}
other => panic!("expected MissingLean, got {other:?}"),
}
assert_eq!(runner.calls(), 0, "Lake must not run after invalid explicit sysroot");
}
}