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` owns runtime link directives, while
4//! `lean-rs-abi` owns link-free metadata. `lean-toolchain` does not call into
5//! this 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::fs::OpenOptions;
31use std::io::{self, Write as _};
32use std::path::{Path, PathBuf};
33use std::process::Command;
34use std::sync::OnceLock;
35use std::time::{SystemTime, UNIX_EPOCH};
36
37use sha2::{Digest, Sha256};
38
39use crate::diagnostics::LinkDiagnostics;
40use crate::discover::{DiscoverOptions, ToolchainInfo, discover_toolchain};
41use crate::fingerprint::ToolchainFingerprint;
42use crate::lakefile_toml::parse_lakefile_toml;
43use crate::loader::{LeanExportSignature, LeanLibraryDependency};
44
45/// Current JSON schema version for `CargoLeanCapability` artifact manifests.
46pub const CAPABILITY_MANIFEST_SCHEMA_VERSION: u32 = 2;
47
48/// Set once after a successful link-directive emission to make repeat calls
49/// for the same toolchain cheap and idempotent.
50static EMITTED_TOOLCHAIN_PREFIX: OnceLock<PathBuf> = 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    emit_lean_link_directives_checked_with_options(&DiscoverOptions::default()).map(drop)
96}
97
98fn emit_lean_link_directives_checked_with_options(opts: &DiscoverOptions) -> Result<ToolchainInfo, LinkDiagnostics> {
99    let info = match discover_toolchain(opts) {
100        Ok(info) => info,
101        Err(diagnostic) => {
102            emit_rerun_triggers(None);
103            return Err(diagnostic);
104        }
105    };
106
107    if EMITTED_TOOLCHAIN_PREFIX
108        .get()
109        .is_some_and(|prefix| prefix == &info.prefix)
110    {
111        return Ok(info);
112    }
113
114    emit_for(&info);
115    drop(EMITTED_TOOLCHAIN_PREFIX.set(info.prefix.clone()));
116    Ok(info)
117}
118
119/// Build a Lake `lean_lib` shared-library target and return the produced dylib path.
120///
121/// `project_root` must be the directory containing the project's lakefile—
122/// either `lakefile.lean` (Lean DSL) or `lakefile.toml`. `target_name` is the
123/// Lake target name to build; the helper invokes `lake build <target_name>:shared` on a
124/// cache miss and returns the supported-window dylib path under
125/// `<project_root>/.lake/build/lib/`.
126///
127/// The cache key is:
128///
129/// - SHA-256 of `lake-manifest.json`, or `missing` until Lake creates one;
130/// - the maximum modification timestamp of `lakefile.lean`, `lakefile.toml`, `lean-toolchain`,
131///   and every `*.lean` file below `project_root` excluding `.lake/`;
132/// - the counted source-set size;
133/// - the target name and Lake package name.
134///
135/// A cache hit skips the Lake command only when the cache key matches and the dylib exists.
136/// The helper always emits `cargo:rerun-if-changed=...` directives for the Lake files and
137/// source files it scans. If `lake-manifest.json` is absent, the helper lets
138/// `lake build` create it. It captures Lake stdout/stderr and never forwards Lake output to
139/// stdout, so stdout remains valid Cargo build-script directives only.
140///
141/// # Errors
142///
143/// Returns [`LinkDiagnostics::LakeTargetMissing`] if `target_name` is not declared as a
144/// `lean_lib` in the project's lakefile (`lakefile.lean` or `lakefile.toml`),
145/// [`LinkDiagnostics::LakeBuildFailed`] if Lake exits
146/// unsuccessfully, and [`LinkDiagnostics::LakeOutputUnresolved`] for unreadable manifests,
147/// source-set traversal failures, cache write failures, or missing built dylibs.
148pub fn build_lake_target(project_root: &Path, target_name: &str) -> Result<PathBuf, LinkDiagnostics> {
149    let mut runner = RealLakeRunner;
150    build_lake_target_with_runner_and_options(
151        project_root,
152        target_name,
153        &mut runner,
154        CargoMetadata::Emit,
155        &LakeBuildOptions::default(),
156    )
157}
158
159/// Build a Lake `lean_lib` shared-library target without emitting Cargo build-script directives.
160///
161/// This is the same Lake/cache resolver as [`build_lake_target`], but it writes no
162/// `cargo:rerun-if-changed=...` lines to stdout. Use it from library/runtime code that needs
163/// to materialize a bundled shim on demand. Build scripts should use [`build_lake_target`] so
164/// Cargo sees the relevant rerun triggers.
165///
166/// # Errors
167///
168/// Returns the same [`LinkDiagnostics`] variants as [`build_lake_target`].
169pub fn build_lake_target_quiet(project_root: &Path, target_name: &str) -> Result<PathBuf, LinkDiagnostics> {
170    let mut runner = RealLakeRunner;
171    build_lake_target_with_runner_and_options(
172        project_root,
173        target_name,
174        &mut runner,
175        CargoMetadata::Suppress,
176        &LakeBuildOptions::default(),
177    )
178}
179
180/// Build-script helper for shipping a Rust crate with bundled Lean code.
181///
182/// This is the canonical downstream `build.rs` entry point. It composes
183/// [`emit_lean_link_directives_checked`], [`build_lake_target`], and the
184/// `cargo:rustc-env=...` directives that carry a JSON artifact manifest and a
185/// backward-compatible dylib path into Rust code at compile time.
186///
187/// ```ignore
188/// fn main() -> Result<(), Box<dyn std::error::Error>> {
189///     lean_toolchain::CargoLeanCapability::new("lean", "MyCapability")
190///         .package("my_app")
191///         .module("MyCapability")
192///         .build()?;
193///     Ok(())
194/// }
195/// ```
196#[derive(Clone, Debug)]
197pub struct CargoLeanCapability {
198    project_root: PathBuf,
199    target_name: String,
200    package: Option<String>,
201    module: Option<String>,
202    env_var: Option<String>,
203    manifest_env_var: Option<String>,
204    lean_sysroot: Option<PathBuf>,
205    lean_num_threads: Option<u32>,
206    export_signatures: Vec<LeanExportSignature>,
207    dependencies: Vec<LeanLibraryDependency>,
208}
209
210impl CargoLeanCapability {
211    /// Create a build helper for a Lake project and `lean_lib` target.
212    #[must_use]
213    pub fn new(project_root: impl Into<PathBuf>, target_name: impl Into<String>) -> Self {
214        Self {
215            project_root: project_root.into(),
216            target_name: target_name.into(),
217            package: None,
218            module: None,
219            env_var: None,
220            manifest_env_var: None,
221            lean_sysroot: None,
222            lean_num_threads: None,
223            export_signatures: Vec::new(),
224            dependencies: Vec::new(),
225        }
226    }
227
228    /// Set the Lake package name used by the module initializer.
229    ///
230    /// If omitted, the helper infers the package from `lake-manifest.json` or
231    /// the project's lakefile (`lakefile.lean` or `lakefile.toml`), matching
232    /// [`build_lake_target`].
233    #[must_use]
234    pub fn package(mut self, package: impl Into<String>) -> Self {
235        self.package = Some(package.into());
236        self
237    }
238
239    /// Set the root Lean module name initialized by Rust.
240    ///
241    /// Defaults to the Lake target name.
242    #[must_use]
243    pub fn module(mut self, module: impl Into<String>) -> Self {
244        self.module = Some(module.into());
245        self
246    }
247
248    /// Override the generated Cargo environment variable name.
249    ///
250    /// The default is `LEAN_RS_CAPABILITY_<TARGET>_DYLIB`, with the target
251    /// converted to screaming snake case.
252    #[must_use]
253    pub fn env_var(mut self, env_var: impl Into<String>) -> Self {
254        self.env_var = Some(env_var.into());
255        self
256    }
257
258    /// Override the generated Cargo environment variable name for the artifact
259    /// manifest.
260    ///
261    /// The default is `LEAN_RS_CAPABILITY_<TARGET>_MANIFEST`, with the target
262    /// converted to screaming snake case.
263    #[must_use]
264    pub fn manifest_env_var(mut self, env_var: impl Into<String>) -> Self {
265        self.manifest_env_var = Some(env_var.into());
266        self
267    }
268
269    /// Build and link against a specific Lean sysroot.
270    ///
271    /// `sysroot` is the Lean prefix containing `include/lean/lean.h` and,
272    /// for real Lake builds, `bin/lake`. [`Self::build`] uses this sysroot
273    /// for link-directive discovery. Both [`Self::build`] and
274    /// [`Self::build_quiet`] pass it only to the spawned Lake command as
275    /// `LEAN_SYSROOT` and run `<sysroot>/bin/lake`; they do not mutate the
276    /// parent process environment.
277    #[must_use]
278    pub fn lean_sysroot(mut self, sysroot: impl Into<PathBuf>) -> Self {
279        self.lean_sysroot = Some(sysroot.into());
280        self
281    }
282
283    /// Cap the parallelism of the spawned `lake build` by setting `LEAN_NUM_THREADS`.
284    ///
285    /// Lake 5.x has no `--jobs` flag; it derives both the number of concurrent build jobs and
286    /// each spawned `lean`'s task-manager thread count from `LEAN_NUM_THREADS`. Leaving this
287    /// unset uses Lake's hardware-concurrency default, which fans out to one multi-GB `lean`
288    /// process per core and can exhaust memory on large machines. Both [`Self::build`] and
289    /// [`Self::build_quiet`] pass this only to the spawned Lake command; they do not mutate the
290    /// parent process environment.
291    #[must_use]
292    pub fn lean_num_threads(mut self, threads: u32) -> Self {
293        self.lean_num_threads = Some(threads);
294        self
295    }
296
297    /// Add trusted ABI metadata for one exported Lean symbol.
298    ///
299    /// The runtime checked-lookup API accepts a Rust call shape only when it
300    /// exactly matches one of these manifest entries.
301    #[must_use]
302    pub fn export_signature(mut self, signature: LeanExportSignature) -> Self {
303        self.export_signatures.push(signature);
304        self
305    }
306
307    /// Add a dependent Lean dylib that must be loaded before this capability.
308    ///
309    /// Use this when the capability imports another shipped Lake package whose
310    /// shared library was built separately. The dependency is recorded in the
311    /// same artifact manifest consumed by `lean-rs` and the worker parent, so
312    /// callers do not need to edit manifest JSON after the build.
313    #[must_use]
314    pub fn dependency(mut self, dependency: LeanLibraryDependency) -> Self {
315        self.dependencies.push(dependency);
316        self
317    }
318
319    /// Emit link directives, build the Lake shared library, write the
320    /// artifact manifest, and emit `cargo:rustc-env` directives for the
321    /// manifest and compatibility dylib path.
322    ///
323    /// # Errors
324    ///
325    /// Returns [`LinkDiagnostics`] if Lean cannot be discovered, Lake cannot
326    /// build the target, or the target output cannot be resolved.
327    pub fn build(self) -> Result<BuiltLeanCapability, LinkDiagnostics> {
328        self.build_with_runner(&mut RealLakeRunner, CargoMetadata::Emit)
329    }
330
331    /// Same as [`Self::build`] without printing Cargo directives.
332    ///
333    /// This exists for tests and internal callers. Downstream `build.rs`
334    /// scripts should use [`Self::build`].
335    ///
336    /// # Errors
337    ///
338    /// Returns [`LinkDiagnostics`] if Lake cannot build the target or the
339    /// target output cannot be resolved.
340    pub fn build_quiet(self) -> Result<BuiltLeanCapability, LinkDiagnostics> {
341        self.build_with_runner(&mut RealLakeRunner, CargoMetadata::Suppress)
342    }
343
344    fn build_with_runner(
345        self,
346        runner: &mut impl LakeRunner,
347        cargo_metadata: CargoMetadata,
348    ) -> Result<BuiltLeanCapability, LinkDiagnostics> {
349        let discover_options = self.discover_options();
350        let selected_toolchain = match cargo_metadata {
351            CargoMetadata::Emit => Some(emit_lean_link_directives_checked_with_options(&discover_options)?),
352            CargoMetadata::Suppress if self.lean_sysroot.is_some() => Some(discover_toolchain(&discover_options)?),
353            CargoMetadata::Suppress => None,
354        };
355        let lake_options = LakeBuildOptions {
356            lean_sysroot: self.lean_sysroot.clone(),
357            lean_num_threads: self.lean_num_threads,
358        };
359        let dylib_path = build_lake_target_with_runner_and_options(
360            &self.project_root,
361            &self.target_name,
362            runner,
363            cargo_metadata,
364            &lake_options,
365        )?;
366        self.finish(dylib_path, cargo_metadata, selected_toolchain.as_ref())
367    }
368
369    fn discover_options(&self) -> DiscoverOptions {
370        let has_explicit_sysroot = self.lean_sysroot.is_some();
371        DiscoverOptions {
372            explicit_sysroot: self.lean_sysroot.clone(),
373            allow_lean_sysroot_env: !has_explicit_sysroot,
374            allow_path_lookup: !has_explicit_sysroot,
375            allow_elan: !has_explicit_sysroot,
376            allow_lake_env: !has_explicit_sysroot,
377            toolchain_file: None,
378        }
379    }
380
381    fn finish(
382        self,
383        dylib_path: PathBuf,
384        cargo_metadata: CargoMetadata,
385        selected_toolchain: Option<&ToolchainInfo>,
386    ) -> Result<BuiltLeanCapability, LinkDiagnostics> {
387        let project_root =
388            fs::canonicalize(&self.project_root).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
389                project_root: self.project_root.clone(),
390                target_name: self.target_name.clone(),
391                reason: format!(
392                    "could not canonicalize project root {} ({err})",
393                    self.project_root.display()
394                ),
395            })?;
396        let package = match self.package {
397            Some(package) => package,
398            None => infer_package_name(&project_root, &self.target_name)?,
399        };
400        let module = self.module.unwrap_or_else(|| self.target_name.clone());
401        let env_var = self.env_var.unwrap_or_else(|| capability_env_var(&self.target_name));
402        let manifest_env_var = self
403            .manifest_env_var
404            .unwrap_or_else(|| capability_manifest_env_var(&self.target_name));
405        let manifest_path = write_capability_manifest(
406            &project_root,
407            &self.target_name,
408            &package,
409            &module,
410            &dylib_path,
411            &manifest_env_var,
412            &self.export_signatures,
413            &self.dependencies,
414            selected_toolchain,
415        )?;
416        cargo_metadata.println(format_args!("cargo:rustc-env={env_var}={}", dylib_path.display()));
417        cargo_metadata.println(format_args!(
418            "cargo:rustc-env={manifest_env_var}={}",
419            manifest_path.display()
420        ));
421        Ok(BuiltLeanCapability {
422            dylib_path,
423            env_var,
424            manifest_path,
425            manifest_env_var,
426            package,
427            module,
428            target_name: self.target_name,
429            project_root,
430        })
431    }
432}
433
434/// Metadata produced by [`CargoLeanCapability`].
435#[derive(Clone, Debug, Eq, PartialEq)]
436pub struct BuiltLeanCapability {
437    dylib_path: PathBuf,
438    env_var: String,
439    manifest_path: PathBuf,
440    manifest_env_var: String,
441    package: String,
442    module: String,
443    target_name: String,
444    project_root: PathBuf,
445}
446
447impl BuiltLeanCapability {
448    /// Built shared-library path.
449    #[must_use]
450    pub fn dylib_path(&self) -> &Path {
451        &self.dylib_path
452    }
453
454    /// Cargo environment variable that stores the built dylib path.
455    #[must_use]
456    pub fn env_var(&self) -> &str {
457        &self.env_var
458    }
459
460    /// JSON artifact manifest path emitted by the build helper.
461    #[must_use]
462    pub fn manifest_path(&self) -> &Path {
463        &self.manifest_path
464    }
465
466    /// Cargo environment variable that stores the artifact manifest path.
467    #[must_use]
468    pub fn manifest_env_var(&self) -> &str {
469        &self.manifest_env_var
470    }
471
472    /// Lake package name.
473    #[must_use]
474    pub fn package(&self) -> &str {
475        &self.package
476    }
477
478    /// Root Lean module initialized by Rust.
479    #[must_use]
480    pub fn module(&self) -> &str {
481        &self.module
482    }
483
484    /// Lake target name.
485    #[must_use]
486    pub fn target_name(&self) -> &str {
487        &self.target_name
488    }
489
490    /// Canonical Lake project root.
491    #[must_use]
492    pub fn project_root(&self) -> &Path {
493        &self.project_root
494    }
495}
496
497/// Default Cargo environment variable for a Lean capability target.
498#[must_use]
499pub fn capability_env_var(target_name: &str) -> String {
500    format!("LEAN_RS_CAPABILITY_{}_DYLIB", screaming_snake(target_name))
501}
502
503/// Default Cargo environment variable for a Lean capability artifact manifest.
504#[must_use]
505pub fn capability_manifest_env_var(target_name: &str) -> String {
506    format!("LEAN_RS_CAPABILITY_{}_MANIFEST", screaming_snake(target_name))
507}
508
509fn emit_for(info: &ToolchainInfo) {
510    let lib_lean = info.lib_dir.join("lean");
511    println!("cargo:rustc-link-search=native={}", lib_lean.display());
512    println!("cargo:rustc-link-search=native={}", info.lib_dir.display());
513    println!("cargo:rustc-link-lib=dylib=leanshared");
514
515    // Runtime rpath so the consumer binary finds `libleanshared` without
516    // `DYLD_FALLBACK_LIBRARY_PATH` / `LD_LIBRARY_PATH`. Gated on the build
517    // target (not the host) via `CARGO_CFG_TARGET_OS`; `-Wl,-rpath` is a
518    // GNU-ld / lld / Apple-ld flag and is not meaningful on Windows.
519    let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
520    if matches!(target_os.as_str(), "macos" | "linux") {
521        println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_lean.display());
522    }
523
524    emit_rerun_triggers(Some(info));
525}
526
527fn emit_rerun_triggers(info: Option<&ToolchainInfo>) {
528    if let Some(info) = info {
529        println!("cargo:rerun-if-changed={}", info.header_path.display());
530    }
531    println!("cargo:rerun-if-env-changed=LEAN_SYSROOT");
532    println!("cargo:rerun-if-env-changed=ELAN_HOME");
533    println!("cargo:rerun-if-env-changed=PATH");
534}
535
536trait LakeRunner {
537    fn build_shared(
538        &mut self,
539        project_root: &Path,
540        target_name: &str,
541        options: &LakeBuildOptions,
542    ) -> Result<LakeRun, std::io::Error>;
543}
544
545struct RealLakeRunner;
546
547impl LakeRunner for RealLakeRunner {
548    fn build_shared(
549        &mut self,
550        project_root: &Path,
551        target_name: &str,
552        options: &LakeBuildOptions,
553    ) -> Result<LakeRun, std::io::Error> {
554        let mut command = if let Some(sysroot) = options.lean_sysroot.as_deref() {
555            let mut command = Command::new(sysroot.join("bin").join("lake"));
556            command.env("LEAN_SYSROOT", sysroot);
557            command
558        } else {
559            Command::new("lake")
560        };
561        // Lake 5.x has no `--jobs` flag; it derives both its build-job parallelism and each
562        // spawned `lean`'s task-manager thread count from `LEAN_NUM_THREADS`. Set it explicitly
563        // (rather than mutating the parent environment) so callers can bound a `lake build`
564        // fan-out that would otherwise spawn one multi-GB `lean` per core.
565        if let Some(threads) = options.lean_num_threads {
566            command.env("LEAN_NUM_THREADS", threads.to_string());
567        }
568        let output = command
569            .arg("build")
570            .arg(format!("{target_name}:shared"))
571            .current_dir(project_root)
572            .output()?;
573        Ok(LakeRun {
574            success: output.status.success(),
575            status: output.status.to_string(),
576            stdout: output.stdout,
577            stderr: output.stderr,
578        })
579    }
580}
581
582#[derive(Clone, Debug, Default, Eq, PartialEq)]
583struct LakeBuildOptions {
584    lean_sysroot: Option<PathBuf>,
585    /// Value for `LEAN_NUM_THREADS` on the spawned `lake` command. `None` leaves Lake's default
586    /// (hardware-concurrency) parallelism, which can fork-storm a machine; `Some(n)` caps it.
587    lean_num_threads: Option<u32>,
588}
589
590struct LakeRun {
591    success: bool,
592    status: String,
593    stdout: Vec<u8>,
594    stderr: Vec<u8>,
595}
596
597#[derive(Clone, Copy, Debug, Eq, PartialEq)]
598enum CargoMetadata {
599    Emit,
600    Suppress,
601}
602
603impl CargoMetadata {
604    fn println(self, args: std::fmt::Arguments<'_>) {
605        if matches!(self, Self::Emit) {
606            println!("{args}");
607        }
608    }
609
610    fn trace(self, args: std::fmt::Arguments<'_>) {
611        if matches!(self, Self::Emit) {
612            emit_lake_trace(args);
613        }
614    }
615}
616
617#[cfg(test)]
618fn build_lake_target_with_runner(
619    project_root: &Path,
620    target_name: &str,
621    runner: &mut impl LakeRunner,
622    cargo_metadata: CargoMetadata,
623) -> Result<PathBuf, LinkDiagnostics> {
624    build_lake_target_with_runner_and_options(
625        project_root,
626        target_name,
627        runner,
628        cargo_metadata,
629        &LakeBuildOptions::default(),
630    )
631}
632
633fn build_lake_target_with_runner_and_options(
634    project_root: &Path,
635    target_name: &str,
636    runner: &mut impl LakeRunner,
637    cargo_metadata: CargoMetadata,
638    options: &LakeBuildOptions,
639) -> Result<PathBuf, LinkDiagnostics> {
640    let project_root = fs::canonicalize(project_root).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
641        project_root: project_root.to_path_buf(),
642        target_name: target_name.to_owned(),
643        reason: format!("could not canonicalize project root {} ({err})", project_root.display()),
644    })?;
645    let lakefile_lean = project_root.join("lakefile.lean");
646    cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", lakefile_lean.display()));
647    let lakefile_toml = project_root.join("lakefile.toml");
648    if lakefile_toml.is_file() {
649        cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", lakefile_toml.display()));
650    }
651    let toolchain_file = project_root.join("lean-toolchain");
652    if toolchain_file.is_file() {
653        cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", toolchain_file.display()));
654    }
655
656    let lakefile = existing_lakefile(&project_root).ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
657        project_root: project_root.clone(),
658        target_name: target_name.to_owned(),
659        reason: format!(
660            "no Lake lakefile found at {} or {}",
661            lakefile_lean.display(),
662            lakefile_toml.display()
663        ),
664    })?;
665
666    if !target_declared_in_lakefile(&lakefile, target_name)? {
667        return Err(LinkDiagnostics::LakeTargetMissing {
668            project_root,
669            target_name: target_name.to_owned(),
670        });
671    }
672
673    let manifest_path = project_root.join("lake-manifest.json");
674    cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", manifest_path.display()));
675    let (manifest_digest, package_name) = match fs::read(&manifest_path) {
676        Ok(manifest_bytes) => (
677            sha256_hex(&manifest_bytes),
678            package_name_from_manifest(&project_root, target_name, &manifest_path, &manifest_bytes)?,
679        ),
680        Err(err) if err.kind() == io::ErrorKind::NotFound => (
681            "missing".to_owned(),
682            package_name_from_lakefile(&project_root, target_name, &lakefile)?,
683        ),
684        Err(err) => {
685            return Err(LinkDiagnostics::LakeOutputUnresolved {
686                project_root: project_root.clone(),
687                target_name: target_name.to_owned(),
688                reason: format!("could not read {} ({err})", manifest_path.display()),
689            });
690        }
691    };
692    let source_set = scan_source_set(&project_root, target_name)?;
693    for path in &source_set.paths {
694        cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", path.display()));
695    }
696
697    let dylib = resolve_dylib_path(&project_root, &package_name, target_name);
698    let initial_cache_key = cache_key(target_name, &package_name, &manifest_digest, &source_set, options);
699    let cache_path = cache_path(&project_root, target_name);
700    if dylib.is_file() && fs::read_to_string(&cache_path).is_ok_and(|cached| cached == initial_cache_key) {
701        cargo_metadata.trace(format_args!(
702            "lean-toolchain: cache hit for Lake target `{target_name}` in {}; using {}",
703            project_root.display(),
704            dylib.display(),
705        ));
706        return Ok(dylib);
707    }
708    cargo_metadata.trace(format_args!(
709        "lean-toolchain: cache miss for Lake target `{target_name}` in {}; running `lake build {target_name}:shared`",
710        project_root.display(),
711    ));
712
713    let run = runner
714        .build_shared(&project_root, target_name, options)
715        .map_err(|err| LinkDiagnostics::LakeUnavailable {
716            project_root: project_root.clone(),
717            target_name: target_name.to_owned(),
718            detail: err.to_string(),
719        })?;
720    if !run.success {
721        return Err(LinkDiagnostics::LakeBuildFailed {
722            project_root,
723            target_name: target_name.to_owned(),
724            status: run.status,
725            detail: command_detail(&run.stdout, &run.stderr),
726        });
727    }
728
729    let (final_manifest_digest, final_package_name) = match fs::read(&manifest_path) {
730        Ok(manifest_bytes) => (
731            sha256_hex(&manifest_bytes),
732            package_name_from_manifest(&project_root, target_name, &manifest_path, &manifest_bytes)?,
733        ),
734        Err(_) => (manifest_digest, package_name),
735    };
736    let final_cache_key = cache_key(
737        target_name,
738        &final_package_name,
739        &final_manifest_digest,
740        &source_set,
741        options,
742    );
743
744    let dylib = resolve_dylib_path(&project_root, &final_package_name, target_name);
745    if !dylib.is_file() {
746        return Err(LinkDiagnostics::LakeOutputUnresolved {
747            project_root,
748            target_name: target_name.to_owned(),
749            reason: format!("expected shared library at {}", dylib.display()),
750        });
751    }
752
753    if let Some(parent) = cache_path.parent() {
754        fs::create_dir_all(parent).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
755            project_root: project_root.clone(),
756            target_name: target_name.to_owned(),
757            reason: format!("could not create cache directory {} ({err})", parent.display()),
758        })?;
759    }
760    fs::write(&cache_path, final_cache_key).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
761        project_root,
762        target_name: target_name.to_owned(),
763        reason: format!("could not write cache file {} ({err})", cache_path.display()),
764    })?;
765
766    Ok(dylib)
767}
768
769fn target_declared_in_lakefile(lakefile: &Path, target_name: &str) -> Result<bool, LinkDiagnostics> {
770    let contents = fs::read_to_string(lakefile).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
771        project_root: lakefile.parent().unwrap_or_else(|| Path::new("")).to_path_buf(),
772        target_name: target_name.to_owned(),
773        reason: format!("could not read {} ({err})", lakefile.display()),
774    })?;
775    if lakefile_is_toml(lakefile) {
776        let parsed = parse_lakefile_toml(&contents).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
777            project_root: lakefile.parent().unwrap_or_else(|| Path::new("")).to_path_buf(),
778            target_name: target_name.to_owned(),
779            reason: format!("lakefile {} is not valid TOML ({err})", lakefile.display()),
780        })?;
781        return Ok(parsed.lean_libs.iter().any(|name| name == target_name));
782    }
783    let quoted = format!("lean_lib «{target_name}»");
784    let bare = format!("lean_lib {target_name}");
785    let string = format!("lean_lib \"{target_name}\"");
786    Ok(contents.contains(&quoted) || contents.contains(&bare) || contents.contains(&string))
787}
788
789fn lakefile_is_toml(lakefile: &Path) -> bool {
790    lakefile.file_name().and_then(|name| name.to_str()) == Some("lakefile.toml")
791}
792
793fn existing_lakefile(project_root: &Path) -> Option<PathBuf> {
794    let toml = project_root.join("lakefile.toml");
795    if toml.is_file() {
796        return Some(toml);
797    }
798    let lean = project_root.join("lakefile.lean");
799    lean.is_file().then_some(lean)
800}
801
802fn package_name_from_manifest(
803    project_root: &Path,
804    target_name: &str,
805    manifest_path: &Path,
806    manifest_bytes: &[u8],
807) -> Result<String, LinkDiagnostics> {
808    let manifest: serde_json::Value =
809        serde_json::from_slice(manifest_bytes).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
810            project_root: project_root.to_path_buf(),
811            target_name: target_name.to_owned(),
812            reason: format!("{} is not valid JSON ({err})", manifest_path.display()),
813        })?;
814    manifest
815        .get("name")
816        .and_then(serde_json::Value::as_str)
817        .filter(|name| !name.is_empty())
818        .map(str::to_owned)
819        .ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
820            project_root: project_root.to_path_buf(),
821            target_name: target_name.to_owned(),
822            reason: format!("{} has no string `name` field", manifest_path.display()),
823        })
824}
825
826fn package_name_from_lakefile(
827    project_root: &Path,
828    target_name: &str,
829    lakefile: &Path,
830) -> Result<String, LinkDiagnostics> {
831    let contents = fs::read_to_string(lakefile).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
832        project_root: project_root.to_path_buf(),
833        target_name: target_name.to_owned(),
834        reason: format!("could not read {} ({err})", lakefile.display()),
835    })?;
836    if lakefile_is_toml(lakefile) {
837        let parsed = parse_lakefile_toml(&contents).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
838            project_root: project_root.to_path_buf(),
839            target_name: target_name.to_owned(),
840            reason: format!("lakefile {} is not valid TOML ({err})", lakefile.display()),
841        })?;
842        return parsed
843            .package_name
844            .ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
845                project_root: project_root.to_path_buf(),
846                target_name: target_name.to_owned(),
847                reason: format!("{} has no top-level `name` field", lakefile.display()),
848            });
849    }
850    for line in contents.lines() {
851        let trimmed = line.trim();
852        if let Some(rest) = trimmed.strip_prefix("package ") {
853            return Ok(normalize_lake_identifier(rest));
854        }
855    }
856    Err(LinkDiagnostics::LakeOutputUnresolved {
857        project_root: project_root.to_path_buf(),
858        target_name: target_name.to_owned(),
859        reason: format!("{} has no `package` declaration", lakefile.display()),
860    })
861}
862
863fn infer_package_name(project_root: &Path, target_name: &str) -> Result<String, LinkDiagnostics> {
864    let manifest_path = project_root.join("lake-manifest.json");
865    match fs::read(&manifest_path) {
866        Ok(manifest_bytes) => package_name_from_manifest(project_root, target_name, &manifest_path, &manifest_bytes),
867        Err(err) if err.kind() == io::ErrorKind::NotFound => {
868            let lakefile = existing_lakefile(project_root).ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
869                project_root: project_root.to_path_buf(),
870                target_name: target_name.to_owned(),
871                reason: format!(
872                    "no Lake lakefile found at {} or {}",
873                    project_root.join("lakefile.lean").display(),
874                    project_root.join("lakefile.toml").display()
875                ),
876            })?;
877            package_name_from_lakefile(project_root, target_name, &lakefile)
878        }
879        Err(err) => Err(LinkDiagnostics::LakeOutputUnresolved {
880            project_root: project_root.to_path_buf(),
881            target_name: target_name.to_owned(),
882            reason: format!("could not read {} ({err})", manifest_path.display()),
883        }),
884    }
885}
886
887fn normalize_lake_identifier(raw: &str) -> String {
888    raw.trim()
889        .trim_matches('«')
890        .trim_matches('»')
891        .trim_matches('"')
892        .trim()
893        .to_owned()
894}
895
896struct SourceSet {
897    paths: Vec<PathBuf>,
898    max_mtime_ns: u128,
899}
900
901fn scan_source_set(project_root: &Path, target_name: &str) -> Result<SourceSet, LinkDiagnostics> {
902    let mut paths = Vec::new();
903    collect_lean_sources(project_root, project_root, target_name, &mut paths)?;
904    for file_name in ["lakefile.lean", "lakefile.toml", "lean-toolchain"] {
905        let path = project_root.join(file_name);
906        if path.is_file() {
907            paths.push(path);
908        }
909    }
910    paths.sort();
911    paths.dedup();
912
913    let mut max_mtime_ns = 0;
914    for path in &paths {
915        let metadata = fs::metadata(path).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
916            project_root: project_root.to_path_buf(),
917            target_name: target_name.to_owned(),
918            reason: format!("could not stat {} ({err})", path.display()),
919        })?;
920        let modified = metadata
921            .modified()
922            .map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
923                project_root: project_root.to_path_buf(),
924                target_name: target_name.to_owned(),
925                reason: format!("could not read mtime for {} ({err})", path.display()),
926            })?;
927        let mtime_ns = modified
928            .duration_since(UNIX_EPOCH)
929            .map_or(0, |duration| duration.as_nanos());
930        max_mtime_ns = max_mtime_ns.max(mtime_ns);
931    }
932
933    Ok(SourceSet { paths, max_mtime_ns })
934}
935
936fn collect_lean_sources(
937    project_root: &Path,
938    dir: &Path,
939    target_name: &str,
940    paths: &mut Vec<PathBuf>,
941) -> Result<(), LinkDiagnostics> {
942    for entry in fs::read_dir(dir).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
943        project_root: project_root.to_path_buf(),
944        target_name: target_name.to_owned(),
945        reason: format!("could not read directory {} ({err})", dir.display()),
946    })? {
947        let entry = entry.map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
948            project_root: project_root.to_path_buf(),
949            target_name: target_name.to_owned(),
950            reason: format!("could not read directory entry under {} ({err})", dir.display()),
951        })?;
952        let path = entry.path();
953        if path.file_name().is_some_and(|name| name == ".lake") {
954            continue;
955        }
956        let metadata = entry.metadata().map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
957            project_root: project_root.to_path_buf(),
958            target_name: target_name.to_owned(),
959            reason: format!("could not stat {} ({err})", path.display()),
960        })?;
961        if metadata.is_dir() {
962            collect_lean_sources(project_root, &path, target_name, paths)?;
963        } else if path.extension().is_some_and(|ext| ext == "lean") {
964            paths.push(path);
965        }
966    }
967    Ok(())
968}
969
970fn resolve_dylib_path(project_root: &Path, package_name: &str, target_name: &str) -> PathBuf {
971    let dylib_extension = if cfg!(target_os = "macos") { "dylib" } else { "so" };
972    let lib_dir = project_root.join(".lake").join("build").join("lib");
973    let escaped_package = package_name.replace('_', "__");
974    let new_style = lib_dir.join(format!("lib{escaped_package}_{target_name}.{dylib_extension}"));
975    let old_style = lib_dir.join(format!("lib{target_name}.{dylib_extension}"));
976    if new_style.is_file() {
977        new_style
978    } else if old_style.is_file() {
979        old_style
980    } else {
981        new_style
982    }
983}
984
985fn cache_path(project_root: &Path, target_name: &str) -> PathBuf {
986    project_root
987        .join(".lake")
988        .join("lean-rs-build-cache")
989        .join(format!("{}.cache", sanitize_target_name(target_name)))
990}
991
992fn write_capability_manifest(
993    project_root: &Path,
994    target_name: &str,
995    package: &str,
996    module: &str,
997    dylib_path: &Path,
998    manifest_env_var: &str,
999    export_signatures: &[LeanExportSignature],
1000    explicit_dependencies: &[LeanLibraryDependency],
1001    selected_toolchain: Option<&ToolchainInfo>,
1002) -> Result<PathBuf, LinkDiagnostics> {
1003    let manifest_path = capability_manifest_path(project_root, target_name, export_signatures);
1004    let mut dependencies = capability_dependencies(project_root, target_name)?;
1005    dependencies.extend(explicit_dependencies.iter().map(lean_library_dependency_to_json));
1006    let fingerprint = manifest_toolchain_fingerprint(project_root, target_name, selected_toolchain)?;
1007    let search_dirs = capability_search_dirs(project_root, dylib_path);
1008    let build_toolchain = selected_toolchain.map(|info| {
1009        serde_json::json!({
1010            "source": format!("{:?}", info.source),
1011            "sysroot": info.prefix.display().to_string(),
1012            "version": &info.version,
1013            "lean_binary": info.lean_binary.as_ref().map(|path| path.display().to_string()),
1014        })
1015    });
1016    let manifest = serde_json::json!({
1017        "schema_version": CAPABILITY_MANIFEST_SCHEMA_VERSION,
1018        "target_name": target_name,
1019        "package": package,
1020        "module": module,
1021        "primary_dylib": dylib_path.display().to_string(),
1022        "manifest_env_var": manifest_env_var,
1023        "lean_version": &fingerprint.lean_version,
1024        "resolved_lean_version": &fingerprint.resolved_version,
1025        "lean_header_sha256": &fingerprint.header_sha256,
1026        "toolchain_fingerprint": {
1027            "lean_version": &fingerprint.lean_version,
1028            "resolved_version": &fingerprint.resolved_version,
1029            "header_sha256": &fingerprint.header_sha256,
1030            "fixture_sha256": fingerprint.fixture_sha256,
1031            "host_triple": fingerprint.host_triple,
1032        },
1033        "build_toolchain": build_toolchain,
1034        "search_dirs": search_dirs
1035            .iter()
1036            .map(|path| path.display().to_string())
1037            .collect::<Vec<_>>(),
1038        "dependencies": dependencies,
1039        "exports": export_signatures
1040            .iter()
1041            .map(LeanExportSignature::to_json)
1042            .collect::<Vec<_>>(),
1043    });
1044    let bytes = serde_json::to_vec_pretty(&manifest).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
1045        project_root: project_root.to_path_buf(),
1046        target_name: target_name.to_owned(),
1047        reason: format!("could not encode Lean capability manifest ({err})"),
1048    })?;
1049    if let Some(parent) = manifest_path.parent() {
1050        fs::create_dir_all(parent).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
1051            project_root: project_root.to_path_buf(),
1052            target_name: target_name.to_owned(),
1053            reason: format!("could not create manifest directory {} ({err})", parent.display()),
1054        })?;
1055    }
1056    write_atomic(&manifest_path, &bytes).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
1057        project_root: project_root.to_path_buf(),
1058        target_name: target_name.to_owned(),
1059        reason: format!(
1060            "could not atomically write Lean capability manifest {} ({err})",
1061            manifest_path.display()
1062        ),
1063    })?;
1064    Ok(manifest_path)
1065}
1066
1067struct ManifestToolchainFingerprint {
1068    lean_version: String,
1069    resolved_version: String,
1070    header_sha256: String,
1071    fixture_sha256: &'static str,
1072    host_triple: &'static str,
1073}
1074
1075fn manifest_toolchain_fingerprint(
1076    project_root: &Path,
1077    target_name: &str,
1078    selected_toolchain: Option<&ToolchainInfo>,
1079) -> Result<ManifestToolchainFingerprint, LinkDiagnostics> {
1080    let Some(info) = selected_toolchain else {
1081        let current = ToolchainFingerprint::current();
1082        return Ok(ManifestToolchainFingerprint {
1083            lean_version: current.lean_version.to_owned(),
1084            resolved_version: current.resolved_version.to_owned(),
1085            header_sha256: current.header_sha256.to_owned(),
1086            fixture_sha256: current.fixture_sha256,
1087            host_triple: current.host_triple,
1088        });
1089    };
1090    let header_sha256 = sha256_manifest_input(project_root, target_name, &info.header_path)?;
1091    let Some(entry) = lean_rs_abi::supported_by_digest(&header_sha256) else {
1092        return Err(LinkDiagnostics::UnsupportedToolchain {
1093            active: info.version.clone(),
1094            supported_window: supported_window(),
1095        });
1096    };
1097    let resolved_version = if entry.versions.contains(&info.version.as_str()) {
1098        info.version.clone()
1099    } else {
1100        entry.versions.first().copied().unwrap_or("unknown").to_owned()
1101    };
1102    Ok(ManifestToolchainFingerprint {
1103        lean_version: info.version.clone(),
1104        resolved_version,
1105        header_sha256,
1106        fixture_sha256: ToolchainFingerprint::current().fixture_sha256,
1107        host_triple: ToolchainFingerprint::current().host_triple,
1108    })
1109}
1110
1111fn sha256_manifest_input(project_root: &Path, target_name: &str, path: &Path) -> Result<String, LinkDiagnostics> {
1112    let bytes = fs::read(path).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
1113        project_root: project_root.to_path_buf(),
1114        target_name: target_name.to_owned(),
1115        reason: format!("could not read selected Lean header {} ({err})", path.display()),
1116    })?;
1117    let mut hasher = Sha256::new();
1118    hasher.update(&bytes);
1119    Ok(hex(&hasher.finalize()))
1120}
1121
1122fn hex(bytes: &[u8]) -> String {
1123    let mut out = String::with_capacity(bytes.len().saturating_mul(2));
1124    for byte in bytes {
1125        let _ = write!(out, "{byte:02x}");
1126    }
1127    out
1128}
1129
1130fn supported_window() -> String {
1131    lean_rs_abi::SUPPORTED_TOOLCHAINS
1132        .iter()
1133        .map(|toolchain| format!("{:?}", toolchain.versions))
1134        .collect::<Vec<_>>()
1135        .join(", ")
1136}
1137
1138fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
1139    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1140    let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or("manifest");
1141    for attempt in 0..100_u32 {
1142        let nanos = SystemTime::now()
1143            .duration_since(UNIX_EPOCH)
1144            .unwrap_or_default()
1145            .as_nanos();
1146        let tmp_path = parent.join(format!(".{file_name}.{}.{}.{}.tmp", std::process::id(), nanos, attempt));
1147        match OpenOptions::new().write(true).create_new(true).open(&tmp_path) {
1148            Ok(mut file) => {
1149                if let Err(err) = file.write_all(bytes).and_then(|()| file.sync_all()) {
1150                    drop(file);
1151                    drop(fs::remove_file(&tmp_path));
1152                    return Err(err);
1153                }
1154                drop(file);
1155                if let Err(err) = fs::rename(&tmp_path, path) {
1156                    drop(fs::remove_file(&tmp_path));
1157                    return Err(err);
1158                }
1159                return Ok(());
1160            }
1161            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
1162            Err(err) => return Err(err),
1163        }
1164    }
1165    Err(io::Error::new(
1166        io::ErrorKind::AlreadyExists,
1167        format!("could not allocate temporary path for {}", path.display()),
1168    ))
1169}
1170
1171fn capability_manifest_path(
1172    project_root: &Path,
1173    target_name: &str,
1174    export_signatures: &[LeanExportSignature],
1175) -> PathBuf {
1176    let manifest_name = capability_manifest_name(target_name, export_signatures);
1177    if let Some(out_dir) = env::var_os("OUT_DIR") {
1178        PathBuf::from(out_dir).join(manifest_name)
1179    } else {
1180        project_root
1181            .join(".lake")
1182            .join("lean-rs-build-cache")
1183            .join(manifest_name)
1184    }
1185}
1186
1187fn capability_manifest_name(target_name: &str, export_signatures: &[LeanExportSignature]) -> String {
1188    let target = sanitize_target_name(target_name);
1189    if export_signatures.is_empty() {
1190        return format!("{target}.lean-rs-capability.json");
1191    }
1192    let mut hasher = Sha256::new();
1193    for signature in export_signatures {
1194        hasher.update(signature.symbol().as_bytes());
1195        hasher.update([0]);
1196        if let Ok(bytes) = serde_json::to_vec(&signature.to_json()) {
1197            hasher.update(bytes);
1198        }
1199        hasher.update([0xff]);
1200    }
1201    let digest = hasher.finalize();
1202    let mut suffix = String::with_capacity(16);
1203    for byte in digest.iter().take(8) {
1204        let _ = write!(&mut suffix, "{byte:02x}");
1205    }
1206    format!("{target}-{suffix}.lean-rs-capability.json")
1207}
1208
1209fn capability_search_dirs(project_root: &Path, dylib_path: &Path) -> Vec<PathBuf> {
1210    let mut dirs = Vec::new();
1211    if let Some(parent) = dylib_path.parent() {
1212        dirs.push(parent.to_path_buf());
1213    }
1214    dirs.push(project_root.join(".lake").join("build").join("lib"));
1215    dirs.sort();
1216    dirs.dedup();
1217    dirs
1218}
1219
1220fn capability_dependencies(project_root: &Path, target_name: &str) -> Result<Vec<serde_json::Value>, LinkDiagnostics> {
1221    let manifest_path = project_root.join("lake-manifest.json");
1222    let bytes = match fs::read(&manifest_path) {
1223        Ok(bytes) => bytes,
1224        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
1225        Err(err) => {
1226            return Err(LinkDiagnostics::LakeOutputUnresolved {
1227                project_root: project_root.to_path_buf(),
1228                target_name: target_name.to_owned(),
1229                reason: format!("could not read {} ({err})", manifest_path.display()),
1230            });
1231        }
1232    };
1233    let manifest: serde_json::Value =
1234        serde_json::from_slice(&bytes).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
1235            project_root: project_root.to_path_buf(),
1236            target_name: target_name.to_owned(),
1237            reason: format!("{} is not valid JSON ({err})", manifest_path.display()),
1238        })?;
1239    let packages = manifest
1240        .get("packages")
1241        .and_then(serde_json::Value::as_array)
1242        .map_or([].as_slice(), Vec::as_slice);
1243    let mut dependencies = Vec::new();
1244    for package in packages {
1245        let Some(name) = package.get("name").and_then(serde_json::Value::as_str) else {
1246            continue;
1247        };
1248        if name != "lean_rs_interop_shims" {
1249            continue;
1250        }
1251        let Some(dir) = package.get("dir").and_then(serde_json::Value::as_str) else {
1252            continue;
1253        };
1254        let dependency_root = project_root.join(dir);
1255        let dependency_root =
1256            fs::canonicalize(&dependency_root).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
1257                project_root: project_root.to_path_buf(),
1258                target_name: target_name.to_owned(),
1259                reason: format!(
1260                    "could not canonicalize dependency root {} ({err})",
1261                    dependency_root.display()
1262                ),
1263            })?;
1264        let dylib = resolve_dylib_path(&dependency_root, "lean_rs_interop_shims", "LeanRsInterop");
1265        dependencies.push(serde_json::json!({
1266            "name": name,
1267            "dylib_path": dylib.display().to_string(),
1268            "export_symbols_for_dependents": true,
1269            "initializer": {
1270                "package": "lean_rs_interop_shims",
1271                "module": "LeanRsInterop",
1272            }
1273        }));
1274    }
1275    Ok(dependencies)
1276}
1277
1278fn lean_library_dependency_to_json(dependency: &LeanLibraryDependency) -> serde_json::Value {
1279    let initializer = dependency.module_initializer().map(|initializer| {
1280        serde_json::json!({
1281            "package": initializer.package_name(),
1282            "module": initializer.module_name(),
1283        })
1284    });
1285    let name = dependency.module_initializer().map_or_else(
1286        || stable_dependency_name(dependency.path_ref()),
1287        |initializer| initializer.package_name().to_owned(),
1288    );
1289    serde_json::json!({
1290        "name": name,
1291        "dylib_path": dependency.path_ref().display().to_string(),
1292        "export_symbols_for_dependents": dependency.exports_symbols_for_dependents(),
1293        "initializer": initializer,
1294    })
1295}
1296
1297fn stable_dependency_name(path: &Path) -> String {
1298    path.file_stem()
1299        .and_then(|stem| stem.to_str())
1300        .filter(|stem| !stem.is_empty())
1301        .unwrap_or("lean_dependency")
1302        .to_owned()
1303}
1304
1305fn sanitize_target_name(target_name: &str) -> String {
1306    target_name
1307        .chars()
1308        .map(|ch| {
1309            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
1310                ch
1311            } else {
1312                '_'
1313            }
1314        })
1315        .collect()
1316}
1317
1318fn screaming_snake(input: &str) -> String {
1319    let mut out = String::new();
1320    let mut prev_was_sep = true;
1321    let mut prev_was_lower_or_digit = false;
1322    for ch in input.chars() {
1323        if ch.is_ascii_alphanumeric() {
1324            if ch.is_ascii_uppercase() && prev_was_lower_or_digit && !prev_was_sep {
1325                out.push('_');
1326            }
1327            out.push(ch.to_ascii_uppercase());
1328            prev_was_sep = false;
1329            prev_was_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
1330        } else {
1331            if !prev_was_sep {
1332                out.push('_');
1333            }
1334            prev_was_sep = true;
1335            prev_was_lower_or_digit = false;
1336        }
1337    }
1338    while out.ends_with('_') {
1339        out.pop();
1340    }
1341    if out.is_empty() { "CAPABILITY".to_owned() } else { out }
1342}
1343
1344fn cache_key(
1345    target_name: &str,
1346    package_name: &str,
1347    manifest_digest: &str,
1348    source_set: &SourceSet,
1349    options: &LakeBuildOptions,
1350) -> String {
1351    let sysroot = options
1352        .lean_sysroot
1353        .as_deref()
1354        .map_or("ambient", |path| path.to_str().unwrap_or("<non-utf8-sysroot>"));
1355    format!(
1356        "target={target_name}\npackage={package_name}\nmanifest={manifest_digest}\nsource_count={}\nsource_max_mtime_ns={}\nlean_sysroot={sysroot}\n",
1357        source_set.paths.len(),
1358        source_set.max_mtime_ns
1359    )
1360}
1361
1362fn sha256_hex(bytes: &[u8]) -> String {
1363    let mut hasher = Sha256::new();
1364    hasher.update(bytes);
1365    let digest = hasher.finalize();
1366    let mut out = String::with_capacity(digest.len().saturating_mul(2));
1367    for byte in digest {
1368        let _ = write!(out, "{byte:02x}");
1369    }
1370    out
1371}
1372
1373fn command_detail(stdout: &[u8], stderr: &[u8]) -> String {
1374    let mut raw = String::new();
1375    if !stderr.is_empty() {
1376        raw.push_str(&String::from_utf8_lossy(stderr));
1377    }
1378    if !stdout.is_empty() {
1379        if !raw.is_empty() {
1380            raw.push_str(" | ");
1381        }
1382        raw.push_str(&String::from_utf8_lossy(stdout));
1383    }
1384    let collapsed = raw.split_whitespace().collect::<Vec<_>>().join(" ");
1385    if collapsed.is_empty() {
1386        "no output".to_owned()
1387    } else if collapsed.len() > 1024 {
1388        let mut bounded = collapsed.chars().take(1024).collect::<String>();
1389        bounded.push_str("...");
1390        bounded
1391    } else {
1392        collapsed
1393    }
1394}
1395
1396fn emit_lake_trace(args: std::fmt::Arguments<'_>) {
1397    let mut stderr = io::stderr().lock();
1398    drop(stderr.write_fmt(args));
1399    drop(stderr.write_all(b"\n"));
1400}
1401
1402#[cfg(test)]
1403#[allow(clippy::expect_used, clippy::panic, clippy::wildcard_enum_match_arm)]
1404mod tests {
1405    use super::{
1406        CAPABILITY_MANIFEST_SCHEMA_VERSION, CargoLeanCapability, CargoMetadata, LakeBuildOptions, LakeRun, LakeRunner,
1407        build_lake_target_with_runner, build_lake_target_with_runner_and_options, capability_env_var,
1408        capability_manifest_env_var, capability_manifest_name, command_detail,
1409    };
1410    use crate::LinkDiagnostics;
1411    use crate::{
1412        LeanExportAbiRepr, LeanExportArgAbi, LeanExportOwnership, LeanExportResultConvention, LeanExportReturnAbi,
1413        LeanExportSignature, LeanLibraryDependency,
1414    };
1415    use std::cell::{Cell, RefCell};
1416    use std::fs;
1417    use std::path::{Path, PathBuf};
1418    use std::rc::Rc;
1419
1420    #[derive(Clone)]
1421    struct FakeLake {
1422        calls: Rc<Cell<usize>>,
1423        seen_sysroots: Rc<RefCell<Vec<Option<PathBuf>>>>,
1424        seen_threads: Rc<RefCell<Vec<Option<u32>>>>,
1425        mode: FakeMode,
1426    }
1427
1428    #[derive(Clone)]
1429    enum FakeMode {
1430        SuccessModern,
1431        SuccessLegacy,
1432        Failure,
1433        SpawnError,
1434    }
1435
1436    impl FakeLake {
1437        fn new(mode: FakeMode) -> Self {
1438            Self {
1439                calls: Rc::new(Cell::new(0)),
1440                seen_sysroots: Rc::new(RefCell::new(Vec::new())),
1441                seen_threads: Rc::new(RefCell::new(Vec::new())),
1442                mode,
1443            }
1444        }
1445
1446        fn calls(&self) -> usize {
1447            self.calls.get()
1448        }
1449
1450        fn seen_sysroots(&self) -> Vec<Option<PathBuf>> {
1451            self.seen_sysroots.borrow().clone()
1452        }
1453
1454        fn seen_threads(&self) -> Vec<Option<u32>> {
1455            self.seen_threads.borrow().clone()
1456        }
1457    }
1458
1459    impl LakeRunner for FakeLake {
1460        fn build_shared(
1461            &mut self,
1462            project_root: &Path,
1463            target_name: &str,
1464            options: &LakeBuildOptions,
1465        ) -> Result<LakeRun, std::io::Error> {
1466            self.calls.set(self.calls.get().saturating_add(1));
1467            self.seen_sysroots.borrow_mut().push(options.lean_sysroot.clone());
1468            self.seen_threads.borrow_mut().push(options.lean_num_threads);
1469            match self.mode {
1470                FakeMode::SuccessModern => {
1471                    let dylib = project_root
1472                        .join(".lake")
1473                        .join("build")
1474                        .join("lib")
1475                        .join(format!("libmy__pkg_{target_name}.{}", dylib_ext()));
1476                    write_file(&dylib, "dylib");
1477                    Ok(success_run())
1478                }
1479                FakeMode::SuccessLegacy => {
1480                    let dylib = project_root
1481                        .join(".lake")
1482                        .join("build")
1483                        .join("lib")
1484                        .join(format!("lib{target_name}.{}", dylib_ext()));
1485                    write_file(&dylib, "dylib");
1486                    Ok(success_run())
1487                }
1488                FakeMode::Failure => Ok(LakeRun {
1489                    success: false,
1490                    status: "exit status: 1".to_owned(),
1491                    stdout: b"stdout detail\n".to_vec(),
1492                    stderr: b"stderr detail\n".to_vec(),
1493                }),
1494                FakeMode::SpawnError => Err(std::io::Error::new(std::io::ErrorKind::NotFound, "lake missing")),
1495            }
1496        }
1497    }
1498
1499    fn success_run() -> LakeRun {
1500        LakeRun {
1501            success: true,
1502            status: "exit status: 0".to_owned(),
1503            stdout: Vec::new(),
1504            stderr: Vec::new(),
1505        }
1506    }
1507
1508    fn dylib_ext() -> &'static str {
1509        if cfg!(target_os = "macos") { "dylib" } else { "so" }
1510    }
1511
1512    fn make_project(name: &str, target: &str) -> PathBuf {
1513        let root = std::env::temp_dir().join(format!("lean-toolchain-lake-{}-{}", std::process::id(), name));
1514        drop(fs::remove_dir_all(&root));
1515        fs::create_dir_all(&root).expect("create temp project");
1516        write_file(
1517            &root.join("lakefile.lean"),
1518            &format!(
1519                "import Lake\nopen Lake DSL\npackage «my_pkg»\n@[default_target]\nlean_lib «{target}» where\n  defaultFacets := #[LeanLib.sharedFacet]\n"
1520            ),
1521        );
1522        write_file(
1523            &root.join("lake-manifest.json"),
1524            r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[],"name":"my_pkg","lakeDir":".lake"}"#,
1525        );
1526        write_file(&root.join("lean-toolchain"), "leanprover/lean4:v4.29.1\n");
1527        write_file(&root.join(format!("{target}.lean")), "def hello : Nat := 1\n");
1528        root
1529    }
1530
1531    fn make_toml_project(name: &str, target: &str) -> PathBuf {
1532        let root = std::env::temp_dir().join(format!("lean-toolchain-lake-{}-{}", std::process::id(), name));
1533        drop(fs::remove_dir_all(&root));
1534        fs::create_dir_all(&root).expect("create temp project");
1535        write_file(
1536            &root.join("lakefile.toml"),
1537            &format!("name = \"my_pkg\"\ndefaultTargets = [\"{target}\"]\n\n[[lean_lib]]\nname = \"{target}\"\n"),
1538        );
1539        write_file(
1540            &root.join("lake-manifest.json"),
1541            r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[],"name":"my_pkg","lakeDir":".lake"}"#,
1542        );
1543        write_file(&root.join("lean-toolchain"), "leanprover/lean4:v4.29.1\n");
1544        write_file(&root.join(format!("{target}.lean")), "def hello : Nat := 1\n");
1545        root
1546    }
1547
1548    fn write_file(path: &Path, contents: &str) {
1549        if let Some(parent) = path.parent() {
1550            fs::create_dir_all(parent).expect("create parent");
1551        }
1552        fs::write(path, contents).expect("write file");
1553    }
1554
1555    fn make_fake_sysroot(name: &str) -> PathBuf {
1556        make_fake_sysroot_with_version(name, crate::LEAN_VERSION)
1557    }
1558
1559    fn make_fake_sysroot_with_version(name: &str, version: &str) -> PathBuf {
1560        let root = std::env::temp_dir().join(format!("lean-toolchain-sysroot-{}-{name}", std::process::id()));
1561        drop(fs::remove_dir_all(&root));
1562        let include = root.join("include").join("lean");
1563        fs::create_dir_all(&include).expect("create fake include dir");
1564        fs::copy(crate::LEAN_HEADER_PATH, include.join("lean.h")).expect("copy supported lean.h");
1565        write_file(
1566            &include.join("version.h"),
1567            &format!("#define LEAN_VERSION_STRING \"{version}\"\n"),
1568        );
1569        root
1570    }
1571
1572    fn header_identical_alias() -> Option<&'static str> {
1573        match crate::LEAN_VERSION {
1574            "4.31.0-rc1" => Some("4.31.0-rc2"),
1575            "4.31.0-rc2" => Some("4.31.0-rc1"),
1576            _ => None,
1577        }
1578    }
1579
1580    #[test]
1581    fn cache_hit_skips_lake_invocation() {
1582        let root = make_project("cache-hit", "MyCapability");
1583        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1584        let first = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1585            .expect("first build");
1586        let second = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1587            .expect("cached build");
1588
1589        assert_eq!(first, second);
1590        assert_eq!(runner.calls(), 1, "second call should use cache");
1591    }
1592
1593    #[test]
1594    fn explicit_lake_sysroot_is_part_of_runner_options_and_cache_key() {
1595        let root = make_project("explicit-sysroot-cache", "MyCapability");
1596        let sysroot = PathBuf::from("/configured/lean/sysroot");
1597        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1598        let options = LakeBuildOptions {
1599            lean_sysroot: Some(sysroot.clone()),
1600            ..LakeBuildOptions::default()
1601        };
1602        let first = build_lake_target_with_runner_and_options(
1603            &root,
1604            "MyCapability",
1605            &mut runner,
1606            CargoMetadata::Emit,
1607            &options,
1608        )
1609        .expect("first explicit build");
1610        let second = build_lake_target_with_runner_and_options(
1611            &root,
1612            "MyCapability",
1613            &mut runner,
1614            CargoMetadata::Emit,
1615            &options,
1616        )
1617        .expect("cached explicit build");
1618
1619        assert_eq!(first, second);
1620        assert_eq!(runner.calls(), 1, "second call should use explicit-sysroot cache");
1621        assert_eq!(runner.seen_sysroots(), vec![Some(sysroot)]);
1622    }
1623
1624    #[test]
1625    fn missing_manifest_lets_lake_create_manifest() {
1626        let root = make_project("missing-manifest", "MyCapability");
1627        fs::remove_file(root.join("lake-manifest.json")).expect("remove manifest");
1628        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1629        let path = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1630            .expect("build without checked-in manifest");
1631
1632        assert!(path.ends_with(format!("libmy__pkg_MyCapability.{}", dylib_ext())));
1633        assert_eq!(runner.calls(), 1);
1634    }
1635
1636    #[test]
1637    fn legacy_output_path_is_supported() {
1638        let root = make_project("legacy", "MyCapability");
1639        let mut runner = FakeLake::new(FakeMode::SuccessLegacy);
1640        let path = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1641            .expect("legacy build");
1642
1643        assert!(path.ends_with(format!("libMyCapability.{}", dylib_ext())));
1644    }
1645
1646    #[test]
1647    fn missing_target_is_typed() {
1648        let root = make_project("missing-target", "MyCapability");
1649        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1650        let err = build_lake_target_with_runner(&root, "OtherTarget", &mut runner, CargoMetadata::Emit)
1651            .expect_err("missing target");
1652
1653        match err {
1654            LinkDiagnostics::LakeTargetMissing { target_name, .. } => assert_eq!(target_name, "OtherTarget"),
1655            other => panic!("expected LakeTargetMissing, got {other:?}"),
1656        }
1657        assert_eq!(runner.calls(), 0);
1658    }
1659
1660    #[test]
1661    fn toml_lakefile_build_succeeds() {
1662        let root = make_toml_project("toml-success", "FixtureLib");
1663        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1664        let path = build_lake_target_with_runner(&root, "FixtureLib", &mut runner, CargoMetadata::Emit)
1665            .expect("TOML lakefile build");
1666
1667        assert!(path.ends_with(format!("libmy__pkg_FixtureLib.{}", dylib_ext())));
1668        assert_eq!(runner.calls(), 1);
1669    }
1670
1671    #[test]
1672    fn toml_lakefile_missing_target_is_typed() {
1673        let root = make_toml_project("toml-missing", "FixtureLib");
1674        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1675        let err = build_lake_target_with_runner(&root, "OtherTarget", &mut runner, CargoMetadata::Emit)
1676            .expect_err("missing TOML target");
1677
1678        match err {
1679            LinkDiagnostics::LakeTargetMissing { target_name, .. } => assert_eq!(target_name, "OtherTarget"),
1680            other => panic!("expected LakeTargetMissing, got {other:?}"),
1681        }
1682        assert_eq!(runner.calls(), 0);
1683    }
1684
1685    #[test]
1686    fn toml_lakefile_missing_manifest_resolves_package() {
1687        let root = make_toml_project("toml-no-manifest", "FixtureLib");
1688        fs::remove_file(root.join("lake-manifest.json")).expect("remove manifest");
1689        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1690        let path = build_lake_target_with_runner(&root, "FixtureLib", &mut runner, CargoMetadata::Emit)
1691            .expect("TOML build without manifest");
1692
1693        assert!(path.ends_with(format!("libmy__pkg_FixtureLib.{}", dylib_ext())));
1694        assert_eq!(runner.calls(), 1);
1695    }
1696
1697    #[test]
1698    fn build_failure_is_typed_and_one_line() {
1699        let root = make_project("failure", "MyCapability");
1700        let mut runner = FakeLake::new(FakeMode::Failure);
1701        let err = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1702            .expect_err("failure");
1703        let rendered = format!("{err}");
1704
1705        match err {
1706            LinkDiagnostics::LakeBuildFailed { detail, .. } => {
1707                assert!(detail.contains("stderr detail"));
1708                assert!(detail.contains("stdout detail"));
1709                assert!(!detail.contains('\n'));
1710            }
1711            other => panic!("expected LakeBuildFailed, got {other:?}"),
1712        }
1713        assert!(!rendered.contains('\n'));
1714    }
1715
1716    #[test]
1717    fn missing_lake_is_typed() {
1718        let root = make_project("spawn-error", "MyCapability");
1719        let mut runner = FakeLake::new(FakeMode::SpawnError);
1720        let err = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1721            .expect_err("spawn error");
1722
1723        match err {
1724            LinkDiagnostics::LakeUnavailable {
1725                target_name, detail, ..
1726            } => {
1727                assert_eq!(target_name, "MyCapability");
1728                assert!(detail.contains("lake missing"));
1729            }
1730            other => panic!("expected LakeUnavailable, got {other:?}"),
1731        }
1732        assert_eq!(runner.calls(), 1);
1733    }
1734
1735    #[test]
1736    fn cache_hit_skips_lake_invocation_for_interop_dependency_shape() {
1737        let root = make_project("interop-cache-hit", "InteropConsumer");
1738        write_file(
1739            &root.join("lakefile.lean"),
1740            "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",
1741        );
1742        write_file(
1743            &root.join("lake-manifest.json"),
1744            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"}"#,
1745        );
1746        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1747
1748        let first = build_lake_target_with_runner(&root, "InteropConsumer", &mut runner, CargoMetadata::Emit)
1749            .expect("first build");
1750        let second = build_lake_target_with_runner(&root, "InteropConsumer", &mut runner, CargoMetadata::Emit)
1751            .expect("cached build");
1752
1753        assert_eq!(first, second);
1754        assert_eq!(runner.calls(), 1, "second call should use cache");
1755    }
1756
1757    #[test]
1758    fn command_detail_is_bounded() {
1759        let detail = command_detail(&vec![b'x'; 4096], b"");
1760        assert!(detail.len() <= 1027);
1761        assert!(detail.ends_with("..."));
1762    }
1763
1764    #[test]
1765    fn capability_env_var_is_deterministic() {
1766        assert_eq!(
1767            capability_env_var("MyCapability"),
1768            "LEAN_RS_CAPABILITY_MY_CAPABILITY_DYLIB"
1769        );
1770        assert_eq!(
1771            capability_env_var("lean-dup_index"),
1772            "LEAN_RS_CAPABILITY_LEAN_DUP_INDEX_DYLIB"
1773        );
1774    }
1775
1776    #[test]
1777    fn capability_manifest_env_var_is_deterministic() {
1778        assert_eq!(
1779            capability_manifest_env_var("MyCapability"),
1780            "LEAN_RS_CAPABILITY_MY_CAPABILITY_MANIFEST"
1781        );
1782        assert_eq!(
1783            capability_manifest_env_var("lean-dup_index"),
1784            "LEAN_RS_CAPABILITY_LEAN_DUP_INDEX_MANIFEST"
1785        );
1786    }
1787
1788    #[test]
1789    fn capability_manifest_name_includes_signature_digest_for_non_empty_exports() {
1790        let first = vec![LeanExportSignature::function(
1791            "my_capability_u8_identity",
1792            vec![LeanExportArgAbi::new(LeanExportAbiRepr::U8, LeanExportOwnership::None)],
1793            LeanExportReturnAbi::new(
1794                LeanExportAbiRepr::U8,
1795                LeanExportOwnership::None,
1796                LeanExportResultConvention::Pure,
1797            ),
1798        )];
1799        let second = vec![LeanExportSignature::function(
1800            "my_capability_u16_identity",
1801            vec![LeanExportArgAbi::new(LeanExportAbiRepr::U16, LeanExportOwnership::None)],
1802            LeanExportReturnAbi::new(
1803                LeanExportAbiRepr::U16,
1804                LeanExportOwnership::None,
1805                LeanExportResultConvention::Pure,
1806            ),
1807        )];
1808
1809        assert_eq!(
1810            capability_manifest_name("MyCapability", &[]),
1811            "MyCapability.lean-rs-capability.json"
1812        );
1813        let first_name = capability_manifest_name("MyCapability", &first);
1814        let second_name = capability_manifest_name("MyCapability", &second);
1815        assert_ne!(first_name, second_name);
1816        assert!(first_name.starts_with("MyCapability-"));
1817        assert!(first_name.ends_with(".lean-rs-capability.json"));
1818        assert!(second_name.starts_with("MyCapability-"));
1819        assert!(second_name.ends_with(".lean-rs-capability.json"));
1820    }
1821
1822    #[test]
1823    fn cargo_capability_build_quiet_returns_metadata() {
1824        let root = make_project("cargo-capability", "MyCapability");
1825        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1826        let dylib = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Suppress)
1827            .expect("build target");
1828        let built = CargoLeanCapability::new(&root, "MyCapability")
1829            .package("my_pkg")
1830            .module("MyCapability")
1831            .env_var("MY_CAPABILITY_DYLIB")
1832            .manifest_env_var("MY_CAPABILITY_MANIFEST")
1833            .export_signature(LeanExportSignature::function(
1834                "my_capability_u8_identity",
1835                vec![LeanExportArgAbi::new(LeanExportAbiRepr::U8, LeanExportOwnership::None)],
1836                LeanExportReturnAbi::new(
1837                    LeanExportAbiRepr::U8,
1838                    LeanExportOwnership::None,
1839                    LeanExportResultConvention::Pure,
1840                ),
1841            ))
1842            .build_quiet()
1843            .expect("cargo helper build");
1844
1845        assert_eq!(built.dylib_path(), dylib.as_path());
1846        assert_eq!(built.env_var(), "MY_CAPABILITY_DYLIB");
1847        assert_eq!(built.manifest_env_var(), "MY_CAPABILITY_MANIFEST");
1848        assert!(built.manifest_path().is_file());
1849        assert_eq!(built.package(), "my_pkg");
1850        assert_eq!(built.module(), "MyCapability");
1851        assert_eq!(built.target_name(), "MyCapability");
1852        assert!(built.project_root().is_absolute());
1853
1854        let manifest: serde_json::Value =
1855            serde_json::from_slice(&fs::read(built.manifest_path()).expect("read manifest"))
1856                .expect("manifest is valid JSON");
1857        assert_eq!(
1858            manifest.get("schema_version").and_then(serde_json::Value::as_u64),
1859            Some(u64::from(CAPABILITY_MANIFEST_SCHEMA_VERSION)),
1860        );
1861        assert_eq!(
1862            manifest.get("package").and_then(serde_json::Value::as_str),
1863            Some("my_pkg")
1864        );
1865        assert_eq!(
1866            manifest.get("module").and_then(serde_json::Value::as_str),
1867            Some("MyCapability")
1868        );
1869        assert_eq!(
1870            manifest
1871                .get("primary_dylib")
1872                .and_then(serde_json::Value::as_str)
1873                .map(Path::new),
1874            Some(dylib.as_path()),
1875        );
1876        assert!(manifest.get("toolchain_fingerprint").is_some());
1877        assert_eq!(
1878            manifest
1879                .get("exports")
1880                .and_then(serde_json::Value::as_array)
1881                .and_then(|exports| exports.first())
1882                .and_then(|export| export.get("symbol"))
1883                .and_then(serde_json::Value::as_str),
1884            Some("my_capability_u8_identity"),
1885        );
1886    }
1887
1888    #[test]
1889    fn cargo_capability_manifest_records_explicit_dependencies() {
1890        let root = make_project("cargo-capability-explicit-dependency", "MyCapability");
1891        let dependency = root.join(".lake").join("build").join("lib").join("libdependency.dylib");
1892        write_file(&dependency, "dependency dylib");
1893
1894        let built = CargoLeanCapability::new(&root, "MyCapability")
1895            .package("my_pkg")
1896            .module("MyCapability")
1897            .dependency(
1898                LeanLibraryDependency::path(&dependency)
1899                    .export_symbols_for_dependents()
1900                    .initializer("dependency_pkg", "Dependency"),
1901            )
1902            .build_quiet()
1903            .expect("cargo helper build");
1904
1905        let manifest: serde_json::Value =
1906            serde_json::from_slice(&fs::read(built.manifest_path()).expect("read manifest"))
1907                .expect("manifest is valid JSON");
1908        let dependencies = manifest
1909            .get("dependencies")
1910            .and_then(serde_json::Value::as_array)
1911            .expect("manifest dependencies array");
1912        assert_eq!(dependencies.len(), 1);
1913        let dependency_json = dependencies.first().expect("one dependency");
1914        assert_eq!(
1915            dependency_json.get("name").and_then(serde_json::Value::as_str),
1916            Some("dependency_pkg")
1917        );
1918        assert_eq!(
1919            dependency_json
1920                .get("dylib_path")
1921                .and_then(serde_json::Value::as_str)
1922                .map(Path::new),
1923            Some(dependency.as_path())
1924        );
1925        assert_eq!(
1926            dependency_json
1927                .get("export_symbols_for_dependents")
1928                .and_then(serde_json::Value::as_bool),
1929            Some(true)
1930        );
1931        assert_eq!(
1932            dependency_json
1933                .get("initializer")
1934                .and_then(|initializer| initializer.get("package"))
1935                .and_then(serde_json::Value::as_str),
1936            Some("dependency_pkg")
1937        );
1938        assert_eq!(
1939            dependency_json
1940                .get("initializer")
1941                .and_then(|initializer| initializer.get("module"))
1942                .and_then(serde_json::Value::as_str),
1943            Some("Dependency")
1944        );
1945    }
1946
1947    #[test]
1948    fn cargo_capability_dependency_without_initializer_uses_stable_file_name() {
1949        let root = make_project("cargo-capability-explicit-dependency-no-init", "MyCapability");
1950        let dependency = root.join(".lake").join("build").join("lib").join("libsupport.dylib");
1951        write_file(&dependency, "dependency dylib");
1952
1953        let built = CargoLeanCapability::new(&root, "MyCapability")
1954            .package("my_pkg")
1955            .module("MyCapability")
1956            .dependency(LeanLibraryDependency::path(&dependency))
1957            .build_quiet()
1958            .expect("cargo helper build");
1959
1960        let manifest: serde_json::Value =
1961            serde_json::from_slice(&fs::read(built.manifest_path()).expect("read manifest"))
1962                .expect("manifest is valid JSON");
1963        let dependency_json = manifest
1964            .get("dependencies")
1965            .and_then(serde_json::Value::as_array)
1966            .and_then(|dependencies| dependencies.first())
1967            .expect("one dependency");
1968        assert_eq!(
1969            dependency_json.get("name").and_then(serde_json::Value::as_str),
1970            Some("libsupport")
1971        );
1972        assert!(
1973            dependency_json
1974                .get("initializer")
1975                .is_some_and(serde_json::Value::is_null),
1976            "dependencies without initializers record null initializer"
1977        );
1978    }
1979
1980    #[test]
1981    fn cargo_capability_build_quiet_passes_explicit_sysroot_to_lake() {
1982        let root = make_project("cargo-capability-explicit-sysroot", "MyCapability");
1983        let sysroot = make_fake_sysroot("cargo-capability");
1984        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1985
1986        let built = CargoLeanCapability::new(&root, "MyCapability")
1987            .package("my_pkg")
1988            .module("MyCapability")
1989            .lean_sysroot(&sysroot)
1990            .build_with_runner(&mut runner, CargoMetadata::Suppress)
1991            .expect("cargo helper build");
1992
1993        assert_eq!(runner.seen_sysroots(), vec![Some(sysroot.clone())]);
1994        assert_eq!(runner.seen_threads(), vec![None], "no thread cap set by default");
1995
1996        let manifest: serde_json::Value =
1997            serde_json::from_slice(&fs::read(built.manifest_path()).expect("read manifest"))
1998                .expect("manifest is valid JSON");
1999        let build_toolchain = manifest
2000            .get("build_toolchain")
2001            .expect("manifest records selected build toolchain");
2002        assert_eq!(
2003            build_toolchain.get("source").and_then(serde_json::Value::as_str),
2004            Some("ExplicitSysroot")
2005        );
2006        assert_eq!(
2007            build_toolchain.get("sysroot").and_then(serde_json::Value::as_str),
2008            Some(sysroot.to_str().expect("test sysroot is UTF-8"))
2009        );
2010    }
2011
2012    #[test]
2013    fn cargo_capability_build_passes_lean_num_threads_to_lake() {
2014        let root = make_project("cargo-capability-threads", "MyCapability");
2015        let sysroot = make_fake_sysroot("cargo-capability-threads");
2016        let mut runner = FakeLake::new(FakeMode::SuccessModern);
2017
2018        CargoLeanCapability::new(&root, "MyCapability")
2019            .package("my_pkg")
2020            .module("MyCapability")
2021            .lean_sysroot(&sysroot)
2022            .lean_num_threads(1)
2023            .build_with_runner(&mut runner, CargoMetadata::Suppress)
2024            .expect("cargo helper build with capped threads");
2025
2026        assert_eq!(runner.seen_threads(), vec![Some(1)]);
2027    }
2028
2029    #[test]
2030    fn cargo_capability_manifest_uses_selected_sysroot_fingerprint() {
2031        let Some(alias) = header_identical_alias() else {
2032            return;
2033        };
2034        let root = make_project("cargo-capability-explicit-sysroot-fingerprint", "MyCapability");
2035        let sysroot = make_fake_sysroot_with_version("cargo-capability-fingerprint", alias);
2036        let mut runner = FakeLake::new(FakeMode::SuccessModern);
2037
2038        let built = CargoLeanCapability::new(&root, "MyCapability")
2039            .package("my_pkg")
2040            .module("MyCapability")
2041            .lean_sysroot(&sysroot)
2042            .build_with_runner(&mut runner, CargoMetadata::Suppress)
2043            .expect("cargo helper build");
2044
2045        let manifest: serde_json::Value =
2046            serde_json::from_slice(&fs::read(built.manifest_path()).expect("read manifest"))
2047                .expect("manifest is valid JSON");
2048        let fingerprint = manifest
2049            .get("toolchain_fingerprint")
2050            .and_then(serde_json::Value::as_object)
2051            .expect("manifest records toolchain fingerprint");
2052        assert_eq!(
2053            fingerprint.get("lean_version").and_then(serde_json::Value::as_str),
2054            Some(alias)
2055        );
2056        assert_eq!(
2057            fingerprint.get("resolved_version").and_then(serde_json::Value::as_str),
2058            Some(alias)
2059        );
2060        assert_eq!(
2061            fingerprint.get("header_sha256").and_then(serde_json::Value::as_str),
2062            Some(crate::LEAN_HEADER_DIGEST)
2063        );
2064    }
2065
2066    #[test]
2067    fn cargo_capability_explicit_sysroot_does_not_fall_back_to_ambient_discovery() {
2068        let root = make_project("cargo-capability-invalid-explicit-sysroot", "MyCapability");
2069        let mut runner = FakeLake::new(FakeMode::SuccessModern);
2070
2071        let err = CargoLeanCapability::new(&root, "MyCapability")
2072            .package("my_pkg")
2073            .module("MyCapability")
2074            .lean_sysroot("/definitely/not/a/lean/sysroot")
2075            .build_with_runner(&mut runner, CargoMetadata::Suppress)
2076            .expect_err("invalid explicit sysroot must not fall back to ambient probes");
2077
2078        match err {
2079            LinkDiagnostics::MissingLean { tried } => {
2080                assert!(
2081                    tried.iter().any(|line| line.contains("explicit_sysroot=")),
2082                    "diagnostic should name the explicit sysroot probe: {tried:?}",
2083                );
2084                assert!(
2085                    tried.iter().any(|line| line == "PATH lookup disabled"),
2086                    "ambient PATH probe should be disabled: {tried:?}",
2087                );
2088            }
2089            other => panic!("expected MissingLean, got {other:?}"),
2090        }
2091        assert_eq!(runner.calls(), 0, "Lake must not run after invalid explicit sysroot");
2092    }
2093}