Skip to main content

lean_toolchain/
build_helpers.rs

1//! Reusable build-script helpers for downstream embedders.
2//!
3//! Inside this workspace `lean-rs-sys`'s `build.rs` is the single source of
4//! `cargo:rustc-link-*` directives—`lean-toolchain` does not call into the
5//! helper from its own build script. The helper exists for **downstream
6//! embedders** whose own `build.rs` would otherwise duplicate the link-policy
7//! probe, the directive set, and the runtime rpath logic.
8//!
9//! Usage in a downstream `build.rs` for a shipped Rust-to-Lean capability:
10//!
11//! ```ignore
12//! fn main() {
13//!     lean_toolchain::CargoLeanCapability::new("lean", "MyCapability")
14//!         .package("my_app")
15//!         .module("MyCapability")
16//!         .build()?;
17//!     Ok::<(), Box<dyn std::error::Error>>(())
18//! }
19//! ```
20//!
21//! That one call covers link-time (the `cargo:rustc-link-search` /
22//! `link-lib` directives), load-time (the rpath into the Lean toolchain's
23//! `lib/lean` directory), and the build artifact manifest consumed by
24//! `lean-rs` at runtime. A consumer binary should not need to construct Lake
25//! output paths or set `DYLD_FALLBACK_LIBRARY_PATH` / `LD_LIBRARY_PATH`.
26
27use std::env;
28use std::fmt::Write as _;
29use std::fs;
30use std::io::{self, Write as _};
31use std::path::{Path, PathBuf};
32use std::process::Command;
33use std::sync::OnceLock;
34use std::time::UNIX_EPOCH;
35
36use sha2::{Digest, Sha256};
37
38use crate::diagnostics::LinkDiagnostics;
39use crate::discover::{DiscoverOptions, ToolchainInfo, discover_toolchain};
40use crate::fingerprint::ToolchainFingerprint;
41use crate::lakefile_toml::parse_lakefile_toml;
42use crate::loader::LeanExportSignature;
43
44/// Current JSON schema version for `CargoLeanCapability` artifact manifests.
45pub const CAPABILITY_MANIFEST_SCHEMA_VERSION: u32 = 2;
46
47/// Set once after a successful link-directive emission to make repeat calls
48/// (e.g. multiple `build.rs` invocations within one process) cheap and
49/// idempotent.
50static EMITTED: OnceLock<()> = OnceLock::new();
51
52/// Emit Lean link-search, link-lib, and runtime rpath directives plus
53/// matching rerun triggers from a downstream `build.rs`.
54///
55/// On the first call this:
56///
57/// 1. Runs [`discover_toolchain`] with [`DiscoverOptions::default()`].
58/// 2. On success, prints `cargo:rustc-link-search=native=<prefix>/lib/lean`
59///    and `<prefix>/lib`, plus `cargo:rustc-link-lib=dylib=leanshared`.
60/// 3. On macOS or Linux build targets, also prints
61///    `cargo:rustc-link-arg=-Wl,-rpath,<prefix>/lib/lean` so the resulting
62///    binary loads `libleanshared` without `DYLD_FALLBACK_LIBRARY_PATH` /
63///    `LD_LIBRARY_PATH`. Other targets get no rpath directive.
64/// 4. On discovery failure, prints one `cargo:warning=` line with the
65///    formatted diagnostic and returns; the caller's build then fails at
66///    link time with a more specific error from rustc.
67/// 5. Emits `cargo:rerun-if-changed=<header>` and the env-var triggers
68///    discovery consults.
69///
70/// Subsequent calls within the same process are no-ops.
71pub fn emit_lean_link_directives() {
72    if let Err(diagnostic) = emit_lean_link_directives_checked() {
73        println!("cargo:warning={diagnostic}");
74    }
75}
76
77/// Emit Lean link-search, link-lib, and runtime rpath directives, returning
78/// typed diagnostics if the active Lean toolchain cannot be resolved.
79///
80/// This is the build-script helper to use when the consumer wants `main() ->
81/// Result<_, LinkDiagnostics>` or wants to map discovery failures into its own
82/// error type. It emits the same `cargo:rustc-link-*`, rpath, and rerun
83/// directives as [`emit_lean_link_directives`]. On failure, it still emits the
84/// environment-variable rerun triggers discovery consulted, then returns the
85/// [`LinkDiagnostics`] value instead of degrading it to `cargo:warning=`.
86///
87/// Subsequent successful calls within the same process are no-ops.
88///
89/// # Errors
90///
91/// Returns the diagnostics from [`discover_toolchain`] when Lean cannot be
92/// found, the discovered prefix is malformed, or the active Lean version is
93/// outside the supported window.
94pub fn emit_lean_link_directives_checked() -> Result<(), LinkDiagnostics> {
95    if EMITTED.get().is_some() {
96        return Ok(());
97    }
98
99    match discover_toolchain(&DiscoverOptions::default()) {
100        Ok(info) => {
101            emit_for(&info);
102            let _ = EMITTED.set(());
103            Ok(())
104        }
105        Err(diagnostic) => {
106            emit_rerun_triggers(None);
107            Err(diagnostic)
108        }
109    }
110}
111
112/// Build a Lake `lean_lib` shared-library target and return the produced dylib path.
113///
114/// `project_root` must be the directory containing the project's lakefile—
115/// either `lakefile.lean` (Lean DSL) or `lakefile.toml`. `target_name` is the
116/// Lake target name to build; the helper invokes `lake build <target_name>:shared` on a
117/// cache miss and returns the supported-window dylib path under
118/// `<project_root>/.lake/build/lib/`.
119///
120/// The cache key is:
121///
122/// - SHA-256 of `lake-manifest.json`, or `missing` until Lake creates one;
123/// - the maximum modification timestamp of `lakefile.lean`, `lakefile.toml`, `lean-toolchain`,
124///   and every `*.lean` file below `project_root` excluding `.lake/`;
125/// - the counted source-set size;
126/// - the target name and Lake package name.
127///
128/// A cache hit skips the Lake command only when the cache key matches and the dylib exists.
129/// The helper always emits `cargo:rerun-if-changed=...` directives for the Lake files and
130/// source files it scans. If `lake-manifest.json` is absent, the helper lets
131/// `lake build` create it. It captures Lake stdout/stderr and never forwards Lake output to
132/// stdout, so stdout remains valid Cargo build-script directives only.
133///
134/// # Errors
135///
136/// Returns [`LinkDiagnostics::LakeTargetMissing`] if `target_name` is not declared as a
137/// `lean_lib` in the project's lakefile (`lakefile.lean` or `lakefile.toml`),
138/// [`LinkDiagnostics::LakeBuildFailed`] if Lake exits
139/// unsuccessfully, and [`LinkDiagnostics::LakeOutputUnresolved`] for unreadable manifests,
140/// source-set traversal failures, cache write failures, or missing built dylibs.
141pub fn build_lake_target(project_root: &Path, target_name: &str) -> Result<PathBuf, LinkDiagnostics> {
142    let mut runner = RealLakeRunner;
143    build_lake_target_with_runner(project_root, target_name, &mut runner, CargoMetadata::Emit)
144}
145
146/// Build a Lake `lean_lib` shared-library target without emitting Cargo build-script directives.
147///
148/// This is the same Lake/cache resolver as [`build_lake_target`], but it writes no
149/// `cargo:rerun-if-changed=...` lines to stdout. Use it from library/runtime code that needs
150/// to materialize a bundled shim on demand. Build scripts should use [`build_lake_target`] so
151/// Cargo sees the relevant rerun triggers.
152///
153/// # Errors
154///
155/// Returns the same [`LinkDiagnostics`] variants as [`build_lake_target`].
156pub fn build_lake_target_quiet(project_root: &Path, target_name: &str) -> Result<PathBuf, LinkDiagnostics> {
157    let mut runner = RealLakeRunner;
158    build_lake_target_with_runner(project_root, target_name, &mut runner, CargoMetadata::Suppress)
159}
160
161/// Build-script helper for shipping a Rust crate with bundled Lean code.
162///
163/// This is the canonical downstream `build.rs` entry point. It composes
164/// [`emit_lean_link_directives_checked`], [`build_lake_target`], and the
165/// `cargo:rustc-env=...` directives that carry a JSON artifact manifest and a
166/// backward-compatible dylib path into Rust code at compile time.
167///
168/// ```ignore
169/// fn main() -> Result<(), Box<dyn std::error::Error>> {
170///     lean_toolchain::CargoLeanCapability::new("lean", "MyCapability")
171///         .package("my_app")
172///         .module("MyCapability")
173///         .build()?;
174///     Ok(())
175/// }
176/// ```
177#[derive(Clone, Debug)]
178pub struct CargoLeanCapability {
179    project_root: PathBuf,
180    target_name: String,
181    package: Option<String>,
182    module: Option<String>,
183    env_var: Option<String>,
184    manifest_env_var: Option<String>,
185    export_signatures: Vec<LeanExportSignature>,
186}
187
188impl CargoLeanCapability {
189    /// Create a build helper for a Lake project and `lean_lib` target.
190    #[must_use]
191    pub fn new(project_root: impl Into<PathBuf>, target_name: impl Into<String>) -> Self {
192        Self {
193            project_root: project_root.into(),
194            target_name: target_name.into(),
195            package: None,
196            module: None,
197            env_var: None,
198            manifest_env_var: None,
199            export_signatures: Vec::new(),
200        }
201    }
202
203    /// Set the Lake package name used by the module initializer.
204    ///
205    /// If omitted, the helper infers the package from `lake-manifest.json` or
206    /// the project's lakefile (`lakefile.lean` or `lakefile.toml`), matching
207    /// [`build_lake_target`].
208    #[must_use]
209    pub fn package(mut self, package: impl Into<String>) -> Self {
210        self.package = Some(package.into());
211        self
212    }
213
214    /// Set the root Lean module name initialized by Rust.
215    ///
216    /// Defaults to the Lake target name.
217    #[must_use]
218    pub fn module(mut self, module: impl Into<String>) -> Self {
219        self.module = Some(module.into());
220        self
221    }
222
223    /// Override the generated Cargo environment variable name.
224    ///
225    /// The default is `LEAN_RS_CAPABILITY_<TARGET>_DYLIB`, with the target
226    /// converted to screaming snake case.
227    #[must_use]
228    pub fn env_var(mut self, env_var: impl Into<String>) -> Self {
229        self.env_var = Some(env_var.into());
230        self
231    }
232
233    /// Override the generated Cargo environment variable name for the artifact
234    /// manifest.
235    ///
236    /// The default is `LEAN_RS_CAPABILITY_<TARGET>_MANIFEST`, with the target
237    /// converted to screaming snake case.
238    #[must_use]
239    pub fn manifest_env_var(mut self, env_var: impl Into<String>) -> Self {
240        self.manifest_env_var = Some(env_var.into());
241        self
242    }
243
244    /// Add trusted ABI metadata for one exported Lean symbol.
245    ///
246    /// The runtime checked-lookup API accepts a Rust call shape only when it
247    /// exactly matches one of these manifest entries.
248    #[must_use]
249    pub fn export_signature(mut self, signature: LeanExportSignature) -> Self {
250        self.export_signatures.push(signature);
251        self
252    }
253
254    /// Emit link directives, build the Lake shared library, write the
255    /// artifact manifest, and emit `cargo:rustc-env` directives for the
256    /// manifest and compatibility dylib path.
257    ///
258    /// # Errors
259    ///
260    /// Returns [`LinkDiagnostics`] if Lean cannot be discovered, Lake cannot
261    /// build the target, or the target output cannot be resolved.
262    pub fn build(self) -> Result<BuiltLeanCapability, LinkDiagnostics> {
263        emit_lean_link_directives_checked()?;
264        let dylib_path = build_lake_target(&self.project_root, &self.target_name)?;
265        self.finish(dylib_path, CargoMetadata::Emit)
266    }
267
268    /// Same as [`Self::build`] without printing Cargo directives.
269    ///
270    /// This exists for tests and internal callers. Downstream `build.rs`
271    /// scripts should use [`Self::build`].
272    ///
273    /// # Errors
274    ///
275    /// Returns [`LinkDiagnostics`] if Lake cannot build the target or the
276    /// target output cannot be resolved.
277    pub fn build_quiet(self) -> Result<BuiltLeanCapability, LinkDiagnostics> {
278        let dylib_path = build_lake_target_quiet(&self.project_root, &self.target_name)?;
279        self.finish(dylib_path, CargoMetadata::Suppress)
280    }
281
282    fn finish(
283        self,
284        dylib_path: PathBuf,
285        cargo_metadata: CargoMetadata,
286    ) -> Result<BuiltLeanCapability, LinkDiagnostics> {
287        let project_root =
288            fs::canonicalize(&self.project_root).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
289                project_root: self.project_root.clone(),
290                target_name: self.target_name.clone(),
291                reason: format!(
292                    "could not canonicalize project root {} ({err})",
293                    self.project_root.display()
294                ),
295            })?;
296        let package = match self.package {
297            Some(package) => package,
298            None => infer_package_name(&project_root, &self.target_name)?,
299        };
300        let module = self.module.unwrap_or_else(|| self.target_name.clone());
301        let env_var = self.env_var.unwrap_or_else(|| capability_env_var(&self.target_name));
302        let manifest_env_var = self
303            .manifest_env_var
304            .unwrap_or_else(|| capability_manifest_env_var(&self.target_name));
305        let manifest_path = write_capability_manifest(
306            &project_root,
307            &self.target_name,
308            &package,
309            &module,
310            &dylib_path,
311            &manifest_env_var,
312            &self.export_signatures,
313        )?;
314        cargo_metadata.println(format_args!("cargo:rustc-env={env_var}={}", dylib_path.display()));
315        cargo_metadata.println(format_args!(
316            "cargo:rustc-env={manifest_env_var}={}",
317            manifest_path.display()
318        ));
319        Ok(BuiltLeanCapability {
320            dylib_path,
321            env_var,
322            manifest_path,
323            manifest_env_var,
324            package,
325            module,
326            target_name: self.target_name,
327            project_root,
328        })
329    }
330}
331
332/// Metadata produced by [`CargoLeanCapability`].
333#[derive(Clone, Debug, Eq, PartialEq)]
334pub struct BuiltLeanCapability {
335    dylib_path: PathBuf,
336    env_var: String,
337    manifest_path: PathBuf,
338    manifest_env_var: String,
339    package: String,
340    module: String,
341    target_name: String,
342    project_root: PathBuf,
343}
344
345impl BuiltLeanCapability {
346    /// Built shared-library path.
347    #[must_use]
348    pub fn dylib_path(&self) -> &Path {
349        &self.dylib_path
350    }
351
352    /// Cargo environment variable that stores the built dylib path.
353    #[must_use]
354    pub fn env_var(&self) -> &str {
355        &self.env_var
356    }
357
358    /// JSON artifact manifest path emitted by the build helper.
359    #[must_use]
360    pub fn manifest_path(&self) -> &Path {
361        &self.manifest_path
362    }
363
364    /// Cargo environment variable that stores the artifact manifest path.
365    #[must_use]
366    pub fn manifest_env_var(&self) -> &str {
367        &self.manifest_env_var
368    }
369
370    /// Lake package name.
371    #[must_use]
372    pub fn package(&self) -> &str {
373        &self.package
374    }
375
376    /// Root Lean module initialized by Rust.
377    #[must_use]
378    pub fn module(&self) -> &str {
379        &self.module
380    }
381
382    /// Lake target name.
383    #[must_use]
384    pub fn target_name(&self) -> &str {
385        &self.target_name
386    }
387
388    /// Canonical Lake project root.
389    #[must_use]
390    pub fn project_root(&self) -> &Path {
391        &self.project_root
392    }
393}
394
395/// Default Cargo environment variable for a Lean capability target.
396#[must_use]
397pub fn capability_env_var(target_name: &str) -> String {
398    format!("LEAN_RS_CAPABILITY_{}_DYLIB", screaming_snake(target_name))
399}
400
401/// Default Cargo environment variable for a Lean capability artifact manifest.
402#[must_use]
403pub fn capability_manifest_env_var(target_name: &str) -> String {
404    format!("LEAN_RS_CAPABILITY_{}_MANIFEST", screaming_snake(target_name))
405}
406
407fn emit_for(info: &ToolchainInfo) {
408    let lib_lean = info.lib_dir.join("lean");
409    println!("cargo:rustc-link-search=native={}", lib_lean.display());
410    println!("cargo:rustc-link-search=native={}", info.lib_dir.display());
411    println!("cargo:rustc-link-lib=dylib=leanshared");
412
413    // Runtime rpath so the consumer binary finds `libleanshared` without
414    // `DYLD_FALLBACK_LIBRARY_PATH` / `LD_LIBRARY_PATH`. Gated on the build
415    // target (not the host) via `CARGO_CFG_TARGET_OS`; `-Wl,-rpath` is a
416    // GNU-ld / lld / Apple-ld flag and is not meaningful on Windows.
417    let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
418    if matches!(target_os.as_str(), "macos" | "linux") {
419        println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_lean.display());
420    }
421
422    emit_rerun_triggers(Some(info));
423}
424
425fn emit_rerun_triggers(info: Option<&ToolchainInfo>) {
426    if let Some(info) = info {
427        println!("cargo:rerun-if-changed={}", info.header_path.display());
428    }
429    println!("cargo:rerun-if-env-changed=LEAN_SYSROOT");
430    println!("cargo:rerun-if-env-changed=ELAN_HOME");
431    println!("cargo:rerun-if-env-changed=PATH");
432}
433
434trait LakeRunner {
435    fn build_shared(&mut self, project_root: &Path, target_name: &str) -> Result<LakeRun, std::io::Error>;
436}
437
438struct RealLakeRunner;
439
440impl LakeRunner for RealLakeRunner {
441    fn build_shared(&mut self, project_root: &Path, target_name: &str) -> Result<LakeRun, std::io::Error> {
442        let output = Command::new("lake")
443            .arg("build")
444            .arg(format!("{target_name}:shared"))
445            .current_dir(project_root)
446            .output()?;
447        Ok(LakeRun {
448            success: output.status.success(),
449            status: output.status.to_string(),
450            stdout: output.stdout,
451            stderr: output.stderr,
452        })
453    }
454}
455
456struct LakeRun {
457    success: bool,
458    status: String,
459    stdout: Vec<u8>,
460    stderr: Vec<u8>,
461}
462
463#[derive(Clone, Copy, Debug, Eq, PartialEq)]
464enum CargoMetadata {
465    Emit,
466    Suppress,
467}
468
469impl CargoMetadata {
470    fn println(self, args: std::fmt::Arguments<'_>) {
471        if matches!(self, Self::Emit) {
472            println!("{args}");
473        }
474    }
475
476    fn trace(self, args: std::fmt::Arguments<'_>) {
477        if matches!(self, Self::Emit) {
478            emit_lake_trace(args);
479        }
480    }
481}
482
483fn build_lake_target_with_runner(
484    project_root: &Path,
485    target_name: &str,
486    runner: &mut impl LakeRunner,
487    cargo_metadata: CargoMetadata,
488) -> Result<PathBuf, LinkDiagnostics> {
489    let project_root = fs::canonicalize(project_root).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
490        project_root: project_root.to_path_buf(),
491        target_name: target_name.to_owned(),
492        reason: format!("could not canonicalize project root {} ({err})", project_root.display()),
493    })?;
494    let lakefile_lean = project_root.join("lakefile.lean");
495    cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", lakefile_lean.display()));
496    let lakefile_toml = project_root.join("lakefile.toml");
497    if lakefile_toml.is_file() {
498        cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", lakefile_toml.display()));
499    }
500    let toolchain_file = project_root.join("lean-toolchain");
501    if toolchain_file.is_file() {
502        cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", toolchain_file.display()));
503    }
504
505    let lakefile = existing_lakefile(&project_root).ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
506        project_root: project_root.clone(),
507        target_name: target_name.to_owned(),
508        reason: format!(
509            "no Lake lakefile found at {} or {}",
510            lakefile_lean.display(),
511            lakefile_toml.display()
512        ),
513    })?;
514
515    if !target_declared_in_lakefile(&lakefile, target_name)? {
516        return Err(LinkDiagnostics::LakeTargetMissing {
517            project_root,
518            target_name: target_name.to_owned(),
519        });
520    }
521
522    let manifest_path = project_root.join("lake-manifest.json");
523    cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", manifest_path.display()));
524    let (manifest_digest, package_name) = match fs::read(&manifest_path) {
525        Ok(manifest_bytes) => (
526            sha256_hex(&manifest_bytes),
527            package_name_from_manifest(&project_root, target_name, &manifest_path, &manifest_bytes)?,
528        ),
529        Err(err) if err.kind() == io::ErrorKind::NotFound => (
530            "missing".to_owned(),
531            package_name_from_lakefile(&project_root, target_name, &lakefile)?,
532        ),
533        Err(err) => {
534            return Err(LinkDiagnostics::LakeOutputUnresolved {
535                project_root: project_root.clone(),
536                target_name: target_name.to_owned(),
537                reason: format!("could not read {} ({err})", manifest_path.display()),
538            });
539        }
540    };
541    let source_set = scan_source_set(&project_root, target_name)?;
542    for path in &source_set.paths {
543        cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", path.display()));
544    }
545
546    let dylib = resolve_dylib_path(&project_root, &package_name, target_name);
547    let initial_cache_key = cache_key(target_name, &package_name, &manifest_digest, &source_set);
548    let cache_path = cache_path(&project_root, target_name);
549    if dylib.is_file() && fs::read_to_string(&cache_path).is_ok_and(|cached| cached == initial_cache_key) {
550        cargo_metadata.trace(format_args!(
551            "lean-toolchain: cache hit for Lake target `{target_name}` in {}; using {}",
552            project_root.display(),
553            dylib.display(),
554        ));
555        return Ok(dylib);
556    }
557    cargo_metadata.trace(format_args!(
558        "lean-toolchain: cache miss for Lake target `{target_name}` in {}; running `lake build {target_name}:shared`",
559        project_root.display(),
560    ));
561
562    let run = runner
563        .build_shared(&project_root, target_name)
564        .map_err(|err| LinkDiagnostics::LakeUnavailable {
565            project_root: project_root.clone(),
566            target_name: target_name.to_owned(),
567            detail: err.to_string(),
568        })?;
569    if !run.success {
570        return Err(LinkDiagnostics::LakeBuildFailed {
571            project_root,
572            target_name: target_name.to_owned(),
573            status: run.status,
574            detail: command_detail(&run.stdout, &run.stderr),
575        });
576    }
577
578    let (final_manifest_digest, final_package_name) = match fs::read(&manifest_path) {
579        Ok(manifest_bytes) => (
580            sha256_hex(&manifest_bytes),
581            package_name_from_manifest(&project_root, target_name, &manifest_path, &manifest_bytes)?,
582        ),
583        Err(_) => (manifest_digest, package_name),
584    };
585    let final_cache_key = cache_key(target_name, &final_package_name, &final_manifest_digest, &source_set);
586
587    let dylib = resolve_dylib_path(&project_root, &final_package_name, target_name);
588    if !dylib.is_file() {
589        return Err(LinkDiagnostics::LakeOutputUnresolved {
590            project_root,
591            target_name: target_name.to_owned(),
592            reason: format!("expected shared library at {}", dylib.display()),
593        });
594    }
595
596    if let Some(parent) = cache_path.parent() {
597        fs::create_dir_all(parent).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
598            project_root: project_root.clone(),
599            target_name: target_name.to_owned(),
600            reason: format!("could not create cache directory {} ({err})", parent.display()),
601        })?;
602    }
603    fs::write(&cache_path, final_cache_key).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
604        project_root,
605        target_name: target_name.to_owned(),
606        reason: format!("could not write cache file {} ({err})", cache_path.display()),
607    })?;
608
609    Ok(dylib)
610}
611
612fn target_declared_in_lakefile(lakefile: &Path, target_name: &str) -> Result<bool, LinkDiagnostics> {
613    let contents = fs::read_to_string(lakefile).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
614        project_root: lakefile.parent().unwrap_or_else(|| Path::new("")).to_path_buf(),
615        target_name: target_name.to_owned(),
616        reason: format!("could not read {} ({err})", lakefile.display()),
617    })?;
618    if lakefile_is_toml(lakefile) {
619        let parsed = parse_lakefile_toml(&contents).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
620            project_root: lakefile.parent().unwrap_or_else(|| Path::new("")).to_path_buf(),
621            target_name: target_name.to_owned(),
622            reason: format!("lakefile {} is not valid TOML ({err})", lakefile.display()),
623        })?;
624        return Ok(parsed.lean_libs.iter().any(|name| name == target_name));
625    }
626    let quoted = format!("lean_lib «{target_name}»");
627    let bare = format!("lean_lib {target_name}");
628    let string = format!("lean_lib \"{target_name}\"");
629    Ok(contents.contains(&quoted) || contents.contains(&bare) || contents.contains(&string))
630}
631
632fn lakefile_is_toml(lakefile: &Path) -> bool {
633    lakefile.file_name().and_then(|name| name.to_str()) == Some("lakefile.toml")
634}
635
636fn existing_lakefile(project_root: &Path) -> Option<PathBuf> {
637    let toml = project_root.join("lakefile.toml");
638    if toml.is_file() {
639        return Some(toml);
640    }
641    let lean = project_root.join("lakefile.lean");
642    lean.is_file().then_some(lean)
643}
644
645fn package_name_from_manifest(
646    project_root: &Path,
647    target_name: &str,
648    manifest_path: &Path,
649    manifest_bytes: &[u8],
650) -> Result<String, LinkDiagnostics> {
651    let manifest: serde_json::Value =
652        serde_json::from_slice(manifest_bytes).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
653            project_root: project_root.to_path_buf(),
654            target_name: target_name.to_owned(),
655            reason: format!("{} is not valid JSON ({err})", manifest_path.display()),
656        })?;
657    manifest
658        .get("name")
659        .and_then(serde_json::Value::as_str)
660        .filter(|name| !name.is_empty())
661        .map(str::to_owned)
662        .ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
663            project_root: project_root.to_path_buf(),
664            target_name: target_name.to_owned(),
665            reason: format!("{} has no string `name` field", manifest_path.display()),
666        })
667}
668
669fn package_name_from_lakefile(
670    project_root: &Path,
671    target_name: &str,
672    lakefile: &Path,
673) -> Result<String, LinkDiagnostics> {
674    let contents = fs::read_to_string(lakefile).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
675        project_root: project_root.to_path_buf(),
676        target_name: target_name.to_owned(),
677        reason: format!("could not read {} ({err})", lakefile.display()),
678    })?;
679    if lakefile_is_toml(lakefile) {
680        let parsed = parse_lakefile_toml(&contents).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
681            project_root: project_root.to_path_buf(),
682            target_name: target_name.to_owned(),
683            reason: format!("lakefile {} is not valid TOML ({err})", lakefile.display()),
684        })?;
685        return parsed
686            .package_name
687            .ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
688                project_root: project_root.to_path_buf(),
689                target_name: target_name.to_owned(),
690                reason: format!("{} has no top-level `name` field", lakefile.display()),
691            });
692    }
693    for line in contents.lines() {
694        let trimmed = line.trim();
695        if let Some(rest) = trimmed.strip_prefix("package ") {
696            return Ok(normalize_lake_identifier(rest));
697        }
698    }
699    Err(LinkDiagnostics::LakeOutputUnresolved {
700        project_root: project_root.to_path_buf(),
701        target_name: target_name.to_owned(),
702        reason: format!("{} has no `package` declaration", lakefile.display()),
703    })
704}
705
706fn infer_package_name(project_root: &Path, target_name: &str) -> Result<String, LinkDiagnostics> {
707    let manifest_path = project_root.join("lake-manifest.json");
708    match fs::read(&manifest_path) {
709        Ok(manifest_bytes) => package_name_from_manifest(project_root, target_name, &manifest_path, &manifest_bytes),
710        Err(err) if err.kind() == io::ErrorKind::NotFound => {
711            let lakefile = existing_lakefile(project_root).ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
712                project_root: project_root.to_path_buf(),
713                target_name: target_name.to_owned(),
714                reason: format!(
715                    "no Lake lakefile found at {} or {}",
716                    project_root.join("lakefile.lean").display(),
717                    project_root.join("lakefile.toml").display()
718                ),
719            })?;
720            package_name_from_lakefile(project_root, target_name, &lakefile)
721        }
722        Err(err) => Err(LinkDiagnostics::LakeOutputUnresolved {
723            project_root: project_root.to_path_buf(),
724            target_name: target_name.to_owned(),
725            reason: format!("could not read {} ({err})", manifest_path.display()),
726        }),
727    }
728}
729
730fn normalize_lake_identifier(raw: &str) -> String {
731    raw.trim()
732        .trim_matches('«')
733        .trim_matches('»')
734        .trim_matches('"')
735        .trim()
736        .to_owned()
737}
738
739struct SourceSet {
740    paths: Vec<PathBuf>,
741    max_mtime_ns: u128,
742}
743
744fn scan_source_set(project_root: &Path, target_name: &str) -> Result<SourceSet, LinkDiagnostics> {
745    let mut paths = Vec::new();
746    collect_lean_sources(project_root, project_root, target_name, &mut paths)?;
747    for file_name in ["lakefile.lean", "lakefile.toml", "lean-toolchain"] {
748        let path = project_root.join(file_name);
749        if path.is_file() {
750            paths.push(path);
751        }
752    }
753    paths.sort();
754    paths.dedup();
755
756    let mut max_mtime_ns = 0;
757    for path in &paths {
758        let metadata = fs::metadata(path).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
759            project_root: project_root.to_path_buf(),
760            target_name: target_name.to_owned(),
761            reason: format!("could not stat {} ({err})", path.display()),
762        })?;
763        let modified = metadata
764            .modified()
765            .map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
766                project_root: project_root.to_path_buf(),
767                target_name: target_name.to_owned(),
768                reason: format!("could not read mtime for {} ({err})", path.display()),
769            })?;
770        let mtime_ns = modified
771            .duration_since(UNIX_EPOCH)
772            .map_or(0, |duration| duration.as_nanos());
773        max_mtime_ns = max_mtime_ns.max(mtime_ns);
774    }
775
776    Ok(SourceSet { paths, max_mtime_ns })
777}
778
779fn collect_lean_sources(
780    project_root: &Path,
781    dir: &Path,
782    target_name: &str,
783    paths: &mut Vec<PathBuf>,
784) -> Result<(), LinkDiagnostics> {
785    for entry in fs::read_dir(dir).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
786        project_root: project_root.to_path_buf(),
787        target_name: target_name.to_owned(),
788        reason: format!("could not read directory {} ({err})", dir.display()),
789    })? {
790        let entry = entry.map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
791            project_root: project_root.to_path_buf(),
792            target_name: target_name.to_owned(),
793            reason: format!("could not read directory entry under {} ({err})", dir.display()),
794        })?;
795        let path = entry.path();
796        if path.file_name().is_some_and(|name| name == ".lake") {
797            continue;
798        }
799        let metadata = entry.metadata().map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
800            project_root: project_root.to_path_buf(),
801            target_name: target_name.to_owned(),
802            reason: format!("could not stat {} ({err})", path.display()),
803        })?;
804        if metadata.is_dir() {
805            collect_lean_sources(project_root, &path, target_name, paths)?;
806        } else if path.extension().is_some_and(|ext| ext == "lean") {
807            paths.push(path);
808        }
809    }
810    Ok(())
811}
812
813fn resolve_dylib_path(project_root: &Path, package_name: &str, target_name: &str) -> PathBuf {
814    let dylib_extension = if cfg!(target_os = "macos") { "dylib" } else { "so" };
815    let lib_dir = project_root.join(".lake").join("build").join("lib");
816    let escaped_package = package_name.replace('_', "__");
817    let new_style = lib_dir.join(format!("lib{escaped_package}_{target_name}.{dylib_extension}"));
818    let old_style = lib_dir.join(format!("lib{target_name}.{dylib_extension}"));
819    if new_style.is_file() {
820        new_style
821    } else if old_style.is_file() {
822        old_style
823    } else {
824        new_style
825    }
826}
827
828fn cache_path(project_root: &Path, target_name: &str) -> PathBuf {
829    project_root
830        .join(".lake")
831        .join("lean-rs-build-cache")
832        .join(format!("{}.cache", sanitize_target_name(target_name)))
833}
834
835fn write_capability_manifest(
836    project_root: &Path,
837    target_name: &str,
838    package: &str,
839    module: &str,
840    dylib_path: &Path,
841    manifest_env_var: &str,
842    export_signatures: &[LeanExportSignature],
843) -> Result<PathBuf, LinkDiagnostics> {
844    let manifest_path = capability_manifest_path(project_root, target_name, export_signatures);
845    let dependencies = capability_dependencies(project_root, target_name)?;
846    let fingerprint = ToolchainFingerprint::current();
847    let search_dirs = capability_search_dirs(project_root, dylib_path);
848    let manifest = serde_json::json!({
849        "schema_version": CAPABILITY_MANIFEST_SCHEMA_VERSION,
850        "target_name": target_name,
851        "package": package,
852        "module": module,
853        "primary_dylib": dylib_path.display().to_string(),
854        "manifest_env_var": manifest_env_var,
855        "lean_version": &fingerprint.lean_version,
856        "resolved_lean_version": &fingerprint.resolved_version,
857        "lean_header_sha256": &fingerprint.header_sha256,
858        "toolchain_fingerprint": {
859            "lean_version": &fingerprint.lean_version,
860            "resolved_version": &fingerprint.resolved_version,
861            "header_sha256": &fingerprint.header_sha256,
862            "fixture_sha256": &fingerprint.fixture_sha256,
863            "host_triple": &fingerprint.host_triple,
864        },
865        "search_dirs": search_dirs
866            .iter()
867            .map(|path| path.display().to_string())
868            .collect::<Vec<_>>(),
869        "dependencies": dependencies,
870        "exports": export_signatures
871            .iter()
872            .map(LeanExportSignature::to_json)
873            .collect::<Vec<_>>(),
874    });
875    let bytes = serde_json::to_vec_pretty(&manifest).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
876        project_root: project_root.to_path_buf(),
877        target_name: target_name.to_owned(),
878        reason: format!("could not encode Lean capability manifest ({err})"),
879    })?;
880    if let Some(parent) = manifest_path.parent() {
881        fs::create_dir_all(parent).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
882            project_root: project_root.to_path_buf(),
883            target_name: target_name.to_owned(),
884            reason: format!("could not create manifest directory {} ({err})", parent.display()),
885        })?;
886    }
887    fs::write(&manifest_path, bytes).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
888        project_root: project_root.to_path_buf(),
889        target_name: target_name.to_owned(),
890        reason: format!(
891            "could not write Lean capability manifest {} ({err})",
892            manifest_path.display()
893        ),
894    })?;
895    Ok(manifest_path)
896}
897
898fn capability_manifest_path(
899    project_root: &Path,
900    target_name: &str,
901    export_signatures: &[LeanExportSignature],
902) -> PathBuf {
903    let manifest_name = capability_manifest_name(target_name, export_signatures);
904    if let Some(out_dir) = env::var_os("OUT_DIR") {
905        PathBuf::from(out_dir).join(manifest_name)
906    } else {
907        project_root
908            .join(".lake")
909            .join("lean-rs-build-cache")
910            .join(manifest_name)
911    }
912}
913
914fn capability_manifest_name(target_name: &str, export_signatures: &[LeanExportSignature]) -> String {
915    let target = sanitize_target_name(target_name);
916    if export_signatures.is_empty() {
917        return format!("{target}.lean-rs-capability.json");
918    }
919    let mut hasher = Sha256::new();
920    for signature in export_signatures {
921        hasher.update(signature.symbol().as_bytes());
922        hasher.update([0]);
923        if let Ok(bytes) = serde_json::to_vec(&signature.to_json()) {
924            hasher.update(bytes);
925        }
926        hasher.update([0xff]);
927    }
928    let digest = hasher.finalize();
929    let mut suffix = String::with_capacity(16);
930    for byte in digest.iter().take(8) {
931        let _ = write!(&mut suffix, "{byte:02x}");
932    }
933    format!("{target}-{suffix}.lean-rs-capability.json")
934}
935
936fn capability_search_dirs(project_root: &Path, dylib_path: &Path) -> Vec<PathBuf> {
937    let mut dirs = Vec::new();
938    if let Some(parent) = dylib_path.parent() {
939        dirs.push(parent.to_path_buf());
940    }
941    dirs.push(project_root.join(".lake").join("build").join("lib"));
942    dirs.sort();
943    dirs.dedup();
944    dirs
945}
946
947fn capability_dependencies(project_root: &Path, target_name: &str) -> Result<Vec<serde_json::Value>, LinkDiagnostics> {
948    let manifest_path = project_root.join("lake-manifest.json");
949    let bytes = match fs::read(&manifest_path) {
950        Ok(bytes) => bytes,
951        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
952        Err(err) => {
953            return Err(LinkDiagnostics::LakeOutputUnresolved {
954                project_root: project_root.to_path_buf(),
955                target_name: target_name.to_owned(),
956                reason: format!("could not read {} ({err})", manifest_path.display()),
957            });
958        }
959    };
960    let manifest: serde_json::Value =
961        serde_json::from_slice(&bytes).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
962            project_root: project_root.to_path_buf(),
963            target_name: target_name.to_owned(),
964            reason: format!("{} is not valid JSON ({err})", manifest_path.display()),
965        })?;
966    let packages = manifest
967        .get("packages")
968        .and_then(serde_json::Value::as_array)
969        .map_or([].as_slice(), Vec::as_slice);
970    let mut dependencies = Vec::new();
971    for package in packages {
972        let Some(name) = package.get("name").and_then(serde_json::Value::as_str) else {
973            continue;
974        };
975        if name != "lean_rs_interop_shims" {
976            continue;
977        }
978        let Some(dir) = package.get("dir").and_then(serde_json::Value::as_str) else {
979            continue;
980        };
981        let dependency_root = project_root.join(dir);
982        let dependency_root =
983            fs::canonicalize(&dependency_root).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
984                project_root: project_root.to_path_buf(),
985                target_name: target_name.to_owned(),
986                reason: format!(
987                    "could not canonicalize dependency root {} ({err})",
988                    dependency_root.display()
989                ),
990            })?;
991        let dylib = resolve_dylib_path(&dependency_root, "lean_rs_interop_shims", "LeanRsInterop");
992        dependencies.push(serde_json::json!({
993            "name": name,
994            "dylib_path": dylib.display().to_string(),
995            "export_symbols_for_dependents": true,
996            "initializer": {
997                "package": "lean_rs_interop_shims",
998                "module": "LeanRsInterop",
999            }
1000        }));
1001    }
1002    Ok(dependencies)
1003}
1004
1005fn sanitize_target_name(target_name: &str) -> String {
1006    target_name
1007        .chars()
1008        .map(|ch| {
1009            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
1010                ch
1011            } else {
1012                '_'
1013            }
1014        })
1015        .collect()
1016}
1017
1018fn screaming_snake(input: &str) -> String {
1019    let mut out = String::new();
1020    let mut prev_was_sep = true;
1021    let mut prev_was_lower_or_digit = false;
1022    for ch in input.chars() {
1023        if ch.is_ascii_alphanumeric() {
1024            if ch.is_ascii_uppercase() && prev_was_lower_or_digit && !prev_was_sep {
1025                out.push('_');
1026            }
1027            out.push(ch.to_ascii_uppercase());
1028            prev_was_sep = false;
1029            prev_was_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
1030        } else {
1031            if !prev_was_sep {
1032                out.push('_');
1033            }
1034            prev_was_sep = true;
1035            prev_was_lower_or_digit = false;
1036        }
1037    }
1038    while out.ends_with('_') {
1039        out.pop();
1040    }
1041    if out.is_empty() { "CAPABILITY".to_owned() } else { out }
1042}
1043
1044fn cache_key(target_name: &str, package_name: &str, manifest_digest: &str, source_set: &SourceSet) -> String {
1045    format!(
1046        "target={target_name}\npackage={package_name}\nmanifest={manifest_digest}\nsource_count={}\nsource_max_mtime_ns={}\n",
1047        source_set.paths.len(),
1048        source_set.max_mtime_ns
1049    )
1050}
1051
1052fn sha256_hex(bytes: &[u8]) -> String {
1053    let mut hasher = Sha256::new();
1054    hasher.update(bytes);
1055    let digest = hasher.finalize();
1056    let mut out = String::with_capacity(digest.len().saturating_mul(2));
1057    for byte in digest {
1058        let _ = write!(out, "{byte:02x}");
1059    }
1060    out
1061}
1062
1063fn command_detail(stdout: &[u8], stderr: &[u8]) -> String {
1064    let mut raw = String::new();
1065    if !stderr.is_empty() {
1066        raw.push_str(&String::from_utf8_lossy(stderr));
1067    }
1068    if !stdout.is_empty() {
1069        if !raw.is_empty() {
1070            raw.push_str(" | ");
1071        }
1072        raw.push_str(&String::from_utf8_lossy(stdout));
1073    }
1074    let collapsed = raw.split_whitespace().collect::<Vec<_>>().join(" ");
1075    if collapsed.is_empty() {
1076        "no output".to_owned()
1077    } else if collapsed.len() > 1024 {
1078        let mut bounded = collapsed.chars().take(1024).collect::<String>();
1079        bounded.push_str("...");
1080        bounded
1081    } else {
1082        collapsed
1083    }
1084}
1085
1086fn emit_lake_trace(args: std::fmt::Arguments<'_>) {
1087    let mut stderr = io::stderr().lock();
1088    drop(stderr.write_fmt(args));
1089    drop(stderr.write_all(b"\n"));
1090}
1091
1092#[cfg(test)]
1093#[allow(clippy::expect_used, clippy::panic, clippy::wildcard_enum_match_arm)]
1094mod tests {
1095    use super::{
1096        CAPABILITY_MANIFEST_SCHEMA_VERSION, CargoLeanCapability, CargoMetadata, LakeRun, LakeRunner,
1097        build_lake_target_with_runner, capability_env_var, capability_manifest_env_var, capability_manifest_name,
1098        command_detail,
1099    };
1100    use crate::LinkDiagnostics;
1101    use crate::{
1102        LeanExportAbiRepr, LeanExportArgAbi, LeanExportOwnership, LeanExportResultConvention, LeanExportReturnAbi,
1103        LeanExportSignature,
1104    };
1105    use std::cell::Cell;
1106    use std::fs;
1107    use std::path::{Path, PathBuf};
1108    use std::rc::Rc;
1109
1110    #[derive(Clone)]
1111    struct FakeLake {
1112        calls: Rc<Cell<usize>>,
1113        mode: FakeMode,
1114    }
1115
1116    #[derive(Clone)]
1117    enum FakeMode {
1118        SuccessModern,
1119        SuccessLegacy,
1120        Failure,
1121        SpawnError,
1122    }
1123
1124    impl FakeLake {
1125        fn new(mode: FakeMode) -> Self {
1126            Self {
1127                calls: Rc::new(Cell::new(0)),
1128                mode,
1129            }
1130        }
1131
1132        fn calls(&self) -> usize {
1133            self.calls.get()
1134        }
1135    }
1136
1137    impl LakeRunner for FakeLake {
1138        fn build_shared(&mut self, project_root: &Path, target_name: &str) -> Result<LakeRun, std::io::Error> {
1139            self.calls.set(self.calls.get().saturating_add(1));
1140            match self.mode {
1141                FakeMode::SuccessModern => {
1142                    let dylib = project_root
1143                        .join(".lake")
1144                        .join("build")
1145                        .join("lib")
1146                        .join(format!("libmy__pkg_{target_name}.{}", dylib_ext()));
1147                    write_file(&dylib, "dylib");
1148                    Ok(success_run())
1149                }
1150                FakeMode::SuccessLegacy => {
1151                    let dylib = project_root
1152                        .join(".lake")
1153                        .join("build")
1154                        .join("lib")
1155                        .join(format!("lib{target_name}.{}", dylib_ext()));
1156                    write_file(&dylib, "dylib");
1157                    Ok(success_run())
1158                }
1159                FakeMode::Failure => Ok(LakeRun {
1160                    success: false,
1161                    status: "exit status: 1".to_owned(),
1162                    stdout: b"stdout detail\n".to_vec(),
1163                    stderr: b"stderr detail\n".to_vec(),
1164                }),
1165                FakeMode::SpawnError => Err(std::io::Error::new(std::io::ErrorKind::NotFound, "lake missing")),
1166            }
1167        }
1168    }
1169
1170    fn success_run() -> LakeRun {
1171        LakeRun {
1172            success: true,
1173            status: "exit status: 0".to_owned(),
1174            stdout: Vec::new(),
1175            stderr: Vec::new(),
1176        }
1177    }
1178
1179    fn dylib_ext() -> &'static str {
1180        if cfg!(target_os = "macos") { "dylib" } else { "so" }
1181    }
1182
1183    fn make_project(name: &str, target: &str) -> PathBuf {
1184        let root = std::env::temp_dir().join(format!("lean-toolchain-lake-{}-{}", std::process::id(), name));
1185        drop(fs::remove_dir_all(&root));
1186        fs::create_dir_all(&root).expect("create temp project");
1187        write_file(
1188            &root.join("lakefile.lean"),
1189            &format!(
1190                "import Lake\nopen Lake DSL\npackage «my_pkg»\n@[default_target]\nlean_lib «{target}» where\n  defaultFacets := #[LeanLib.sharedFacet]\n"
1191            ),
1192        );
1193        write_file(
1194            &root.join("lake-manifest.json"),
1195            r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[],"name":"my_pkg","lakeDir":".lake"}"#,
1196        );
1197        write_file(&root.join("lean-toolchain"), "leanprover/lean4:v4.29.1\n");
1198        write_file(&root.join(format!("{target}.lean")), "def hello : Nat := 1\n");
1199        root
1200    }
1201
1202    fn make_toml_project(name: &str, target: &str) -> PathBuf {
1203        let root = std::env::temp_dir().join(format!("lean-toolchain-lake-{}-{}", std::process::id(), name));
1204        drop(fs::remove_dir_all(&root));
1205        fs::create_dir_all(&root).expect("create temp project");
1206        write_file(
1207            &root.join("lakefile.toml"),
1208            &format!("name = \"my_pkg\"\ndefaultTargets = [\"{target}\"]\n\n[[lean_lib]]\nname = \"{target}\"\n"),
1209        );
1210        write_file(
1211            &root.join("lake-manifest.json"),
1212            r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[],"name":"my_pkg","lakeDir":".lake"}"#,
1213        );
1214        write_file(&root.join("lean-toolchain"), "leanprover/lean4:v4.29.1\n");
1215        write_file(&root.join(format!("{target}.lean")), "def hello : Nat := 1\n");
1216        root
1217    }
1218
1219    fn write_file(path: &Path, contents: &str) {
1220        if let Some(parent) = path.parent() {
1221            fs::create_dir_all(parent).expect("create parent");
1222        }
1223        fs::write(path, contents).expect("write file");
1224    }
1225
1226    #[test]
1227    fn cache_hit_skips_lake_invocation() {
1228        let root = make_project("cache-hit", "MyCapability");
1229        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1230        let first = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1231            .expect("first build");
1232        let second = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1233            .expect("cached build");
1234
1235        assert_eq!(first, second);
1236        assert_eq!(runner.calls(), 1, "second call should use cache");
1237    }
1238
1239    #[test]
1240    fn missing_manifest_lets_lake_create_manifest() {
1241        let root = make_project("missing-manifest", "MyCapability");
1242        fs::remove_file(root.join("lake-manifest.json")).expect("remove manifest");
1243        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1244        let path = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1245            .expect("build without checked-in manifest");
1246
1247        assert!(path.ends_with(format!("libmy__pkg_MyCapability.{}", dylib_ext())));
1248        assert_eq!(runner.calls(), 1);
1249    }
1250
1251    #[test]
1252    fn legacy_output_path_is_supported() {
1253        let root = make_project("legacy", "MyCapability");
1254        let mut runner = FakeLake::new(FakeMode::SuccessLegacy);
1255        let path = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1256            .expect("legacy build");
1257
1258        assert!(path.ends_with(format!("libMyCapability.{}", dylib_ext())));
1259    }
1260
1261    #[test]
1262    fn missing_target_is_typed() {
1263        let root = make_project("missing-target", "MyCapability");
1264        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1265        let err = build_lake_target_with_runner(&root, "OtherTarget", &mut runner, CargoMetadata::Emit)
1266            .expect_err("missing target");
1267
1268        match err {
1269            LinkDiagnostics::LakeTargetMissing { target_name, .. } => assert_eq!(target_name, "OtherTarget"),
1270            other => panic!("expected LakeTargetMissing, got {other:?}"),
1271        }
1272        assert_eq!(runner.calls(), 0);
1273    }
1274
1275    #[test]
1276    fn toml_lakefile_build_succeeds() {
1277        let root = make_toml_project("toml-success", "KanProofs");
1278        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1279        let path = build_lake_target_with_runner(&root, "KanProofs", &mut runner, CargoMetadata::Emit)
1280            .expect("TOML lakefile build");
1281
1282        assert!(path.ends_with(format!("libmy__pkg_KanProofs.{}", dylib_ext())));
1283        assert_eq!(runner.calls(), 1);
1284    }
1285
1286    #[test]
1287    fn toml_lakefile_missing_target_is_typed() {
1288        let root = make_toml_project("toml-missing", "KanProofs");
1289        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1290        let err = build_lake_target_with_runner(&root, "OtherTarget", &mut runner, CargoMetadata::Emit)
1291            .expect_err("missing TOML target");
1292
1293        match err {
1294            LinkDiagnostics::LakeTargetMissing { target_name, .. } => assert_eq!(target_name, "OtherTarget"),
1295            other => panic!("expected LakeTargetMissing, got {other:?}"),
1296        }
1297        assert_eq!(runner.calls(), 0);
1298    }
1299
1300    #[test]
1301    fn toml_lakefile_missing_manifest_resolves_package() {
1302        let root = make_toml_project("toml-no-manifest", "KanProofs");
1303        fs::remove_file(root.join("lake-manifest.json")).expect("remove manifest");
1304        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1305        let path = build_lake_target_with_runner(&root, "KanProofs", &mut runner, CargoMetadata::Emit)
1306            .expect("TOML build without manifest");
1307
1308        assert!(path.ends_with(format!("libmy__pkg_KanProofs.{}", dylib_ext())));
1309        assert_eq!(runner.calls(), 1);
1310    }
1311
1312    #[test]
1313    fn build_failure_is_typed_and_one_line() {
1314        let root = make_project("failure", "MyCapability");
1315        let mut runner = FakeLake::new(FakeMode::Failure);
1316        let err = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1317            .expect_err("failure");
1318        let rendered = format!("{err}");
1319
1320        match err {
1321            LinkDiagnostics::LakeBuildFailed { detail, .. } => {
1322                assert!(detail.contains("stderr detail"));
1323                assert!(detail.contains("stdout detail"));
1324                assert!(!detail.contains('\n'));
1325            }
1326            other => panic!("expected LakeBuildFailed, got {other:?}"),
1327        }
1328        assert!(!rendered.contains('\n'));
1329    }
1330
1331    #[test]
1332    fn missing_lake_is_typed() {
1333        let root = make_project("spawn-error", "MyCapability");
1334        let mut runner = FakeLake::new(FakeMode::SpawnError);
1335        let err = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1336            .expect_err("spawn error");
1337
1338        match err {
1339            LinkDiagnostics::LakeUnavailable {
1340                target_name, detail, ..
1341            } => {
1342                assert_eq!(target_name, "MyCapability");
1343                assert!(detail.contains("lake missing"));
1344            }
1345            other => panic!("expected LakeUnavailable, got {other:?}"),
1346        }
1347        assert_eq!(runner.calls(), 1);
1348    }
1349
1350    #[test]
1351    fn cache_hit_skips_lake_invocation_for_interop_dependency_shape() {
1352        let root = make_project("interop-cache-hit", "InteropConsumer");
1353        write_file(
1354            &root.join("lakefile.lean"),
1355            "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",
1356        );
1357        write_file(
1358            &root.join("lake-manifest.json"),
1359            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"}"#,
1360        );
1361        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1362
1363        let first = build_lake_target_with_runner(&root, "InteropConsumer", &mut runner, CargoMetadata::Emit)
1364            .expect("first build");
1365        let second = build_lake_target_with_runner(&root, "InteropConsumer", &mut runner, CargoMetadata::Emit)
1366            .expect("cached build");
1367
1368        assert_eq!(first, second);
1369        assert_eq!(runner.calls(), 1, "second call should use cache");
1370    }
1371
1372    #[test]
1373    fn command_detail_is_bounded() {
1374        let detail = command_detail(&vec![b'x'; 4096], b"");
1375        assert!(detail.len() <= 1027);
1376        assert!(detail.ends_with("..."));
1377    }
1378
1379    #[test]
1380    fn capability_env_var_is_deterministic() {
1381        assert_eq!(
1382            capability_env_var("MyCapability"),
1383            "LEAN_RS_CAPABILITY_MY_CAPABILITY_DYLIB"
1384        );
1385        assert_eq!(
1386            capability_env_var("lean-dup_index"),
1387            "LEAN_RS_CAPABILITY_LEAN_DUP_INDEX_DYLIB"
1388        );
1389    }
1390
1391    #[test]
1392    fn capability_manifest_env_var_is_deterministic() {
1393        assert_eq!(
1394            capability_manifest_env_var("MyCapability"),
1395            "LEAN_RS_CAPABILITY_MY_CAPABILITY_MANIFEST"
1396        );
1397        assert_eq!(
1398            capability_manifest_env_var("lean-dup_index"),
1399            "LEAN_RS_CAPABILITY_LEAN_DUP_INDEX_MANIFEST"
1400        );
1401    }
1402
1403    #[test]
1404    fn capability_manifest_name_includes_signature_digest_for_non_empty_exports() {
1405        let first = vec![LeanExportSignature::function(
1406            "my_capability_u8_identity",
1407            vec![LeanExportArgAbi::new(LeanExportAbiRepr::U8, LeanExportOwnership::None)],
1408            LeanExportReturnAbi::new(
1409                LeanExportAbiRepr::U8,
1410                LeanExportOwnership::None,
1411                LeanExportResultConvention::Pure,
1412            ),
1413        )];
1414        let second = vec![LeanExportSignature::function(
1415            "my_capability_u16_identity",
1416            vec![LeanExportArgAbi::new(LeanExportAbiRepr::U16, LeanExportOwnership::None)],
1417            LeanExportReturnAbi::new(
1418                LeanExportAbiRepr::U16,
1419                LeanExportOwnership::None,
1420                LeanExportResultConvention::Pure,
1421            ),
1422        )];
1423
1424        assert_eq!(
1425            capability_manifest_name("MyCapability", &[]),
1426            "MyCapability.lean-rs-capability.json"
1427        );
1428        let first_name = capability_manifest_name("MyCapability", &first);
1429        let second_name = capability_manifest_name("MyCapability", &second);
1430        assert_ne!(first_name, second_name);
1431        assert!(first_name.starts_with("MyCapability-"));
1432        assert!(first_name.ends_with(".lean-rs-capability.json"));
1433        assert!(second_name.starts_with("MyCapability-"));
1434        assert!(second_name.ends_with(".lean-rs-capability.json"));
1435    }
1436
1437    #[test]
1438    fn cargo_capability_build_quiet_returns_metadata() {
1439        let root = make_project("cargo-capability", "MyCapability");
1440        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1441        let dylib = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Suppress)
1442            .expect("build target");
1443        let built = CargoLeanCapability::new(&root, "MyCapability")
1444            .package("my_pkg")
1445            .module("MyCapability")
1446            .env_var("MY_CAPABILITY_DYLIB")
1447            .manifest_env_var("MY_CAPABILITY_MANIFEST")
1448            .export_signature(LeanExportSignature::function(
1449                "my_capability_u8_identity",
1450                vec![LeanExportArgAbi::new(LeanExportAbiRepr::U8, LeanExportOwnership::None)],
1451                LeanExportReturnAbi::new(
1452                    LeanExportAbiRepr::U8,
1453                    LeanExportOwnership::None,
1454                    LeanExportResultConvention::Pure,
1455                ),
1456            ))
1457            .build_quiet()
1458            .expect("cargo helper build");
1459
1460        assert_eq!(built.dylib_path(), dylib.as_path());
1461        assert_eq!(built.env_var(), "MY_CAPABILITY_DYLIB");
1462        assert_eq!(built.manifest_env_var(), "MY_CAPABILITY_MANIFEST");
1463        assert!(built.manifest_path().is_file());
1464        assert_eq!(built.package(), "my_pkg");
1465        assert_eq!(built.module(), "MyCapability");
1466        assert_eq!(built.target_name(), "MyCapability");
1467        assert!(built.project_root().is_absolute());
1468
1469        let manifest: serde_json::Value =
1470            serde_json::from_slice(&fs::read(built.manifest_path()).expect("read manifest"))
1471                .expect("manifest is valid JSON");
1472        assert_eq!(
1473            manifest.get("schema_version").and_then(serde_json::Value::as_u64),
1474            Some(u64::from(CAPABILITY_MANIFEST_SCHEMA_VERSION)),
1475        );
1476        assert_eq!(
1477            manifest.get("package").and_then(serde_json::Value::as_str),
1478            Some("my_pkg")
1479        );
1480        assert_eq!(
1481            manifest.get("module").and_then(serde_json::Value::as_str),
1482            Some("MyCapability")
1483        );
1484        assert_eq!(
1485            manifest
1486                .get("primary_dylib")
1487                .and_then(serde_json::Value::as_str)
1488                .map(Path::new),
1489            Some(dylib.as_path()),
1490        );
1491        assert!(manifest.get("toolchain_fingerprint").is_some());
1492        assert_eq!(
1493            manifest
1494                .get("exports")
1495                .and_then(serde_json::Value::as_array)
1496                .and_then(|exports| exports.first())
1497                .and_then(|export| export.get("symbol"))
1498                .and_then(serde_json::Value::as_str),
1499            Some("my_capability_u8_identity"),
1500        );
1501    }
1502}