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