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