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 = ToolchainFingerprint::current();
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
1040fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
1041    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1042    let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or("manifest");
1043    for attempt in 0..100_u32 {
1044        let nanos = SystemTime::now()
1045            .duration_since(UNIX_EPOCH)
1046            .unwrap_or_default()
1047            .as_nanos();
1048        let tmp_path = parent.join(format!(".{file_name}.{}.{}.{}.tmp", std::process::id(), nanos, attempt));
1049        match OpenOptions::new().write(true).create_new(true).open(&tmp_path) {
1050            Ok(mut file) => {
1051                if let Err(err) = file.write_all(bytes).and_then(|()| file.sync_all()) {
1052                    drop(file);
1053                    drop(fs::remove_file(&tmp_path));
1054                    return Err(err);
1055                }
1056                drop(file);
1057                if let Err(err) = fs::rename(&tmp_path, path) {
1058                    drop(fs::remove_file(&tmp_path));
1059                    return Err(err);
1060                }
1061                return Ok(());
1062            }
1063            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
1064            Err(err) => return Err(err),
1065        }
1066    }
1067    Err(io::Error::new(
1068        io::ErrorKind::AlreadyExists,
1069        format!("could not allocate temporary path for {}", path.display()),
1070    ))
1071}
1072
1073fn capability_manifest_path(
1074    project_root: &Path,
1075    target_name: &str,
1076    export_signatures: &[LeanExportSignature],
1077) -> PathBuf {
1078    let manifest_name = capability_manifest_name(target_name, export_signatures);
1079    if let Some(out_dir) = env::var_os("OUT_DIR") {
1080        PathBuf::from(out_dir).join(manifest_name)
1081    } else {
1082        project_root
1083            .join(".lake")
1084            .join("lean-rs-build-cache")
1085            .join(manifest_name)
1086    }
1087}
1088
1089fn capability_manifest_name(target_name: &str, export_signatures: &[LeanExportSignature]) -> String {
1090    let target = sanitize_target_name(target_name);
1091    if export_signatures.is_empty() {
1092        return format!("{target}.lean-rs-capability.json");
1093    }
1094    let mut hasher = Sha256::new();
1095    for signature in export_signatures {
1096        hasher.update(signature.symbol().as_bytes());
1097        hasher.update([0]);
1098        if let Ok(bytes) = serde_json::to_vec(&signature.to_json()) {
1099            hasher.update(bytes);
1100        }
1101        hasher.update([0xff]);
1102    }
1103    let digest = hasher.finalize();
1104    let mut suffix = String::with_capacity(16);
1105    for byte in digest.iter().take(8) {
1106        let _ = write!(&mut suffix, "{byte:02x}");
1107    }
1108    format!("{target}-{suffix}.lean-rs-capability.json")
1109}
1110
1111fn capability_search_dirs(project_root: &Path, dylib_path: &Path) -> Vec<PathBuf> {
1112    let mut dirs = Vec::new();
1113    if let Some(parent) = dylib_path.parent() {
1114        dirs.push(parent.to_path_buf());
1115    }
1116    dirs.push(project_root.join(".lake").join("build").join("lib"));
1117    dirs.sort();
1118    dirs.dedup();
1119    dirs
1120}
1121
1122fn capability_dependencies(project_root: &Path, target_name: &str) -> Result<Vec<serde_json::Value>, LinkDiagnostics> {
1123    let manifest_path = project_root.join("lake-manifest.json");
1124    let bytes = match fs::read(&manifest_path) {
1125        Ok(bytes) => bytes,
1126        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
1127        Err(err) => {
1128            return Err(LinkDiagnostics::LakeOutputUnresolved {
1129                project_root: project_root.to_path_buf(),
1130                target_name: target_name.to_owned(),
1131                reason: format!("could not read {} ({err})", manifest_path.display()),
1132            });
1133        }
1134    };
1135    let manifest: serde_json::Value =
1136        serde_json::from_slice(&bytes).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
1137            project_root: project_root.to_path_buf(),
1138            target_name: target_name.to_owned(),
1139            reason: format!("{} is not valid JSON ({err})", manifest_path.display()),
1140        })?;
1141    let packages = manifest
1142        .get("packages")
1143        .and_then(serde_json::Value::as_array)
1144        .map_or([].as_slice(), Vec::as_slice);
1145    let mut dependencies = Vec::new();
1146    for package in packages {
1147        let Some(name) = package.get("name").and_then(serde_json::Value::as_str) else {
1148            continue;
1149        };
1150        if name != "lean_rs_interop_shims" {
1151            continue;
1152        }
1153        let Some(dir) = package.get("dir").and_then(serde_json::Value::as_str) else {
1154            continue;
1155        };
1156        let dependency_root = project_root.join(dir);
1157        let dependency_root =
1158            fs::canonicalize(&dependency_root).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
1159                project_root: project_root.to_path_buf(),
1160                target_name: target_name.to_owned(),
1161                reason: format!(
1162                    "could not canonicalize dependency root {} ({err})",
1163                    dependency_root.display()
1164                ),
1165            })?;
1166        let dylib = resolve_dylib_path(&dependency_root, "lean_rs_interop_shims", "LeanRsInterop");
1167        dependencies.push(serde_json::json!({
1168            "name": name,
1169            "dylib_path": dylib.display().to_string(),
1170            "export_symbols_for_dependents": true,
1171            "initializer": {
1172                "package": "lean_rs_interop_shims",
1173                "module": "LeanRsInterop",
1174            }
1175        }));
1176    }
1177    Ok(dependencies)
1178}
1179
1180fn lean_library_dependency_to_json(dependency: &LeanLibraryDependency) -> serde_json::Value {
1181    let initializer = dependency.module_initializer().map(|initializer| {
1182        serde_json::json!({
1183            "package": initializer.package_name(),
1184            "module": initializer.module_name(),
1185        })
1186    });
1187    let name = dependency.module_initializer().map_or_else(
1188        || stable_dependency_name(dependency.path_ref()),
1189        |initializer| initializer.package_name().to_owned(),
1190    );
1191    serde_json::json!({
1192        "name": name,
1193        "dylib_path": dependency.path_ref().display().to_string(),
1194        "export_symbols_for_dependents": dependency.exports_symbols_for_dependents(),
1195        "initializer": initializer,
1196    })
1197}
1198
1199fn stable_dependency_name(path: &Path) -> String {
1200    path.file_stem()
1201        .and_then(|stem| stem.to_str())
1202        .filter(|stem| !stem.is_empty())
1203        .unwrap_or("lean_dependency")
1204        .to_owned()
1205}
1206
1207fn sanitize_target_name(target_name: &str) -> String {
1208    target_name
1209        .chars()
1210        .map(|ch| {
1211            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
1212                ch
1213            } else {
1214                '_'
1215            }
1216        })
1217        .collect()
1218}
1219
1220fn screaming_snake(input: &str) -> String {
1221    let mut out = String::new();
1222    let mut prev_was_sep = true;
1223    let mut prev_was_lower_or_digit = false;
1224    for ch in input.chars() {
1225        if ch.is_ascii_alphanumeric() {
1226            if ch.is_ascii_uppercase() && prev_was_lower_or_digit && !prev_was_sep {
1227                out.push('_');
1228            }
1229            out.push(ch.to_ascii_uppercase());
1230            prev_was_sep = false;
1231            prev_was_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
1232        } else {
1233            if !prev_was_sep {
1234                out.push('_');
1235            }
1236            prev_was_sep = true;
1237            prev_was_lower_or_digit = false;
1238        }
1239    }
1240    while out.ends_with('_') {
1241        out.pop();
1242    }
1243    if out.is_empty() { "CAPABILITY".to_owned() } else { out }
1244}
1245
1246fn cache_key(
1247    target_name: &str,
1248    package_name: &str,
1249    manifest_digest: &str,
1250    source_set: &SourceSet,
1251    options: &LakeBuildOptions,
1252) -> String {
1253    let sysroot = options
1254        .lean_sysroot
1255        .as_deref()
1256        .map_or("ambient", |path| path.to_str().unwrap_or("<non-utf8-sysroot>"));
1257    format!(
1258        "target={target_name}\npackage={package_name}\nmanifest={manifest_digest}\nsource_count={}\nsource_max_mtime_ns={}\nlean_sysroot={sysroot}\n",
1259        source_set.paths.len(),
1260        source_set.max_mtime_ns
1261    )
1262}
1263
1264fn sha256_hex(bytes: &[u8]) -> String {
1265    let mut hasher = Sha256::new();
1266    hasher.update(bytes);
1267    let digest = hasher.finalize();
1268    let mut out = String::with_capacity(digest.len().saturating_mul(2));
1269    for byte in digest {
1270        let _ = write!(out, "{byte:02x}");
1271    }
1272    out
1273}
1274
1275fn command_detail(stdout: &[u8], stderr: &[u8]) -> String {
1276    let mut raw = String::new();
1277    if !stderr.is_empty() {
1278        raw.push_str(&String::from_utf8_lossy(stderr));
1279    }
1280    if !stdout.is_empty() {
1281        if !raw.is_empty() {
1282            raw.push_str(" | ");
1283        }
1284        raw.push_str(&String::from_utf8_lossy(stdout));
1285    }
1286    let collapsed = raw.split_whitespace().collect::<Vec<_>>().join(" ");
1287    if collapsed.is_empty() {
1288        "no output".to_owned()
1289    } else if collapsed.len() > 1024 {
1290        let mut bounded = collapsed.chars().take(1024).collect::<String>();
1291        bounded.push_str("...");
1292        bounded
1293    } else {
1294        collapsed
1295    }
1296}
1297
1298fn emit_lake_trace(args: std::fmt::Arguments<'_>) {
1299    let mut stderr = io::stderr().lock();
1300    drop(stderr.write_fmt(args));
1301    drop(stderr.write_all(b"\n"));
1302}
1303
1304#[cfg(test)]
1305#[allow(clippy::expect_used, clippy::panic, clippy::wildcard_enum_match_arm)]
1306mod tests {
1307    use super::{
1308        CAPABILITY_MANIFEST_SCHEMA_VERSION, CargoLeanCapability, CargoMetadata, LakeBuildOptions, LakeRun, LakeRunner,
1309        build_lake_target_with_runner, build_lake_target_with_runner_and_options, capability_env_var,
1310        capability_manifest_env_var, capability_manifest_name, command_detail,
1311    };
1312    use crate::LinkDiagnostics;
1313    use crate::{
1314        LeanExportAbiRepr, LeanExportArgAbi, LeanExportOwnership, LeanExportResultConvention, LeanExportReturnAbi,
1315        LeanExportSignature, LeanLibraryDependency,
1316    };
1317    use std::cell::{Cell, RefCell};
1318    use std::fs;
1319    use std::path::{Path, PathBuf};
1320    use std::rc::Rc;
1321
1322    #[derive(Clone)]
1323    struct FakeLake {
1324        calls: Rc<Cell<usize>>,
1325        seen_sysroots: Rc<RefCell<Vec<Option<PathBuf>>>>,
1326        mode: FakeMode,
1327    }
1328
1329    #[derive(Clone)]
1330    enum FakeMode {
1331        SuccessModern,
1332        SuccessLegacy,
1333        Failure,
1334        SpawnError,
1335    }
1336
1337    impl FakeLake {
1338        fn new(mode: FakeMode) -> Self {
1339            Self {
1340                calls: Rc::new(Cell::new(0)),
1341                seen_sysroots: Rc::new(RefCell::new(Vec::new())),
1342                mode,
1343            }
1344        }
1345
1346        fn calls(&self) -> usize {
1347            self.calls.get()
1348        }
1349
1350        fn seen_sysroots(&self) -> Vec<Option<PathBuf>> {
1351            self.seen_sysroots.borrow().clone()
1352        }
1353    }
1354
1355    impl LakeRunner for FakeLake {
1356        fn build_shared(
1357            &mut self,
1358            project_root: &Path,
1359            target_name: &str,
1360            options: &LakeBuildOptions,
1361        ) -> Result<LakeRun, std::io::Error> {
1362            self.calls.set(self.calls.get().saturating_add(1));
1363            self.seen_sysroots.borrow_mut().push(options.lean_sysroot.clone());
1364            match self.mode {
1365                FakeMode::SuccessModern => {
1366                    let dylib = project_root
1367                        .join(".lake")
1368                        .join("build")
1369                        .join("lib")
1370                        .join(format!("libmy__pkg_{target_name}.{}", dylib_ext()));
1371                    write_file(&dylib, "dylib");
1372                    Ok(success_run())
1373                }
1374                FakeMode::SuccessLegacy => {
1375                    let dylib = project_root
1376                        .join(".lake")
1377                        .join("build")
1378                        .join("lib")
1379                        .join(format!("lib{target_name}.{}", dylib_ext()));
1380                    write_file(&dylib, "dylib");
1381                    Ok(success_run())
1382                }
1383                FakeMode::Failure => Ok(LakeRun {
1384                    success: false,
1385                    status: "exit status: 1".to_owned(),
1386                    stdout: b"stdout detail\n".to_vec(),
1387                    stderr: b"stderr detail\n".to_vec(),
1388                }),
1389                FakeMode::SpawnError => Err(std::io::Error::new(std::io::ErrorKind::NotFound, "lake missing")),
1390            }
1391        }
1392    }
1393
1394    fn success_run() -> LakeRun {
1395        LakeRun {
1396            success: true,
1397            status: "exit status: 0".to_owned(),
1398            stdout: Vec::new(),
1399            stderr: Vec::new(),
1400        }
1401    }
1402
1403    fn dylib_ext() -> &'static str {
1404        if cfg!(target_os = "macos") { "dylib" } else { "so" }
1405    }
1406
1407    fn make_project(name: &str, target: &str) -> PathBuf {
1408        let root = std::env::temp_dir().join(format!("lean-toolchain-lake-{}-{}", std::process::id(), name));
1409        drop(fs::remove_dir_all(&root));
1410        fs::create_dir_all(&root).expect("create temp project");
1411        write_file(
1412            &root.join("lakefile.lean"),
1413            &format!(
1414                "import Lake\nopen Lake DSL\npackage «my_pkg»\n@[default_target]\nlean_lib «{target}» where\n  defaultFacets := #[LeanLib.sharedFacet]\n"
1415            ),
1416        );
1417        write_file(
1418            &root.join("lake-manifest.json"),
1419            r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[],"name":"my_pkg","lakeDir":".lake"}"#,
1420        );
1421        write_file(&root.join("lean-toolchain"), "leanprover/lean4:v4.29.1\n");
1422        write_file(&root.join(format!("{target}.lean")), "def hello : Nat := 1\n");
1423        root
1424    }
1425
1426    fn make_toml_project(name: &str, target: &str) -> PathBuf {
1427        let root = std::env::temp_dir().join(format!("lean-toolchain-lake-{}-{}", std::process::id(), name));
1428        drop(fs::remove_dir_all(&root));
1429        fs::create_dir_all(&root).expect("create temp project");
1430        write_file(
1431            &root.join("lakefile.toml"),
1432            &format!("name = \"my_pkg\"\ndefaultTargets = [\"{target}\"]\n\n[[lean_lib]]\nname = \"{target}\"\n"),
1433        );
1434        write_file(
1435            &root.join("lake-manifest.json"),
1436            r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[],"name":"my_pkg","lakeDir":".lake"}"#,
1437        );
1438        write_file(&root.join("lean-toolchain"), "leanprover/lean4:v4.29.1\n");
1439        write_file(&root.join(format!("{target}.lean")), "def hello : Nat := 1\n");
1440        root
1441    }
1442
1443    fn write_file(path: &Path, contents: &str) {
1444        if let Some(parent) = path.parent() {
1445            fs::create_dir_all(parent).expect("create parent");
1446        }
1447        fs::write(path, contents).expect("write file");
1448    }
1449
1450    fn make_fake_sysroot(name: &str) -> PathBuf {
1451        let root = std::env::temp_dir().join(format!("lean-toolchain-sysroot-{}-{name}", std::process::id()));
1452        drop(fs::remove_dir_all(&root));
1453        write_file(&root.join("include").join("lean").join("lean.h"), "/* fake lean.h */\n");
1454        root
1455    }
1456
1457    #[test]
1458    fn cache_hit_skips_lake_invocation() {
1459        let root = make_project("cache-hit", "MyCapability");
1460        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1461        let first = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1462            .expect("first build");
1463        let second = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1464            .expect("cached build");
1465
1466        assert_eq!(first, second);
1467        assert_eq!(runner.calls(), 1, "second call should use cache");
1468    }
1469
1470    #[test]
1471    fn explicit_lake_sysroot_is_part_of_runner_options_and_cache_key() {
1472        let root = make_project("explicit-sysroot-cache", "MyCapability");
1473        let sysroot = PathBuf::from("/configured/lean/sysroot");
1474        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1475        let options = LakeBuildOptions {
1476            lean_sysroot: Some(sysroot.clone()),
1477        };
1478        let first = build_lake_target_with_runner_and_options(
1479            &root,
1480            "MyCapability",
1481            &mut runner,
1482            CargoMetadata::Emit,
1483            &options,
1484        )
1485        .expect("first explicit build");
1486        let second = build_lake_target_with_runner_and_options(
1487            &root,
1488            "MyCapability",
1489            &mut runner,
1490            CargoMetadata::Emit,
1491            &options,
1492        )
1493        .expect("cached explicit build");
1494
1495        assert_eq!(first, second);
1496        assert_eq!(runner.calls(), 1, "second call should use explicit-sysroot cache");
1497        assert_eq!(runner.seen_sysroots(), vec![Some(sysroot)]);
1498    }
1499
1500    #[test]
1501    fn missing_manifest_lets_lake_create_manifest() {
1502        let root = make_project("missing-manifest", "MyCapability");
1503        fs::remove_file(root.join("lake-manifest.json")).expect("remove manifest");
1504        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1505        let path = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1506            .expect("build without checked-in manifest");
1507
1508        assert!(path.ends_with(format!("libmy__pkg_MyCapability.{}", dylib_ext())));
1509        assert_eq!(runner.calls(), 1);
1510    }
1511
1512    #[test]
1513    fn legacy_output_path_is_supported() {
1514        let root = make_project("legacy", "MyCapability");
1515        let mut runner = FakeLake::new(FakeMode::SuccessLegacy);
1516        let path = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1517            .expect("legacy build");
1518
1519        assert!(path.ends_with(format!("libMyCapability.{}", dylib_ext())));
1520    }
1521
1522    #[test]
1523    fn missing_target_is_typed() {
1524        let root = make_project("missing-target", "MyCapability");
1525        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1526        let err = build_lake_target_with_runner(&root, "OtherTarget", &mut runner, CargoMetadata::Emit)
1527            .expect_err("missing target");
1528
1529        match err {
1530            LinkDiagnostics::LakeTargetMissing { target_name, .. } => assert_eq!(target_name, "OtherTarget"),
1531            other => panic!("expected LakeTargetMissing, got {other:?}"),
1532        }
1533        assert_eq!(runner.calls(), 0);
1534    }
1535
1536    #[test]
1537    fn toml_lakefile_build_succeeds() {
1538        let root = make_toml_project("toml-success", "FixtureLib");
1539        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1540        let path = build_lake_target_with_runner(&root, "FixtureLib", &mut runner, CargoMetadata::Emit)
1541            .expect("TOML lakefile build");
1542
1543        assert!(path.ends_with(format!("libmy__pkg_FixtureLib.{}", dylib_ext())));
1544        assert_eq!(runner.calls(), 1);
1545    }
1546
1547    #[test]
1548    fn toml_lakefile_missing_target_is_typed() {
1549        let root = make_toml_project("toml-missing", "FixtureLib");
1550        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1551        let err = build_lake_target_with_runner(&root, "OtherTarget", &mut runner, CargoMetadata::Emit)
1552            .expect_err("missing TOML target");
1553
1554        match err {
1555            LinkDiagnostics::LakeTargetMissing { target_name, .. } => assert_eq!(target_name, "OtherTarget"),
1556            other => panic!("expected LakeTargetMissing, got {other:?}"),
1557        }
1558        assert_eq!(runner.calls(), 0);
1559    }
1560
1561    #[test]
1562    fn toml_lakefile_missing_manifest_resolves_package() {
1563        let root = make_toml_project("toml-no-manifest", "FixtureLib");
1564        fs::remove_file(root.join("lake-manifest.json")).expect("remove manifest");
1565        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1566        let path = build_lake_target_with_runner(&root, "FixtureLib", &mut runner, CargoMetadata::Emit)
1567            .expect("TOML build without manifest");
1568
1569        assert!(path.ends_with(format!("libmy__pkg_FixtureLib.{}", dylib_ext())));
1570        assert_eq!(runner.calls(), 1);
1571    }
1572
1573    #[test]
1574    fn build_failure_is_typed_and_one_line() {
1575        let root = make_project("failure", "MyCapability");
1576        let mut runner = FakeLake::new(FakeMode::Failure);
1577        let err = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1578            .expect_err("failure");
1579        let rendered = format!("{err}");
1580
1581        match err {
1582            LinkDiagnostics::LakeBuildFailed { detail, .. } => {
1583                assert!(detail.contains("stderr detail"));
1584                assert!(detail.contains("stdout detail"));
1585                assert!(!detail.contains('\n'));
1586            }
1587            other => panic!("expected LakeBuildFailed, got {other:?}"),
1588        }
1589        assert!(!rendered.contains('\n'));
1590    }
1591
1592    #[test]
1593    fn missing_lake_is_typed() {
1594        let root = make_project("spawn-error", "MyCapability");
1595        let mut runner = FakeLake::new(FakeMode::SpawnError);
1596        let err = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1597            .expect_err("spawn error");
1598
1599        match err {
1600            LinkDiagnostics::LakeUnavailable {
1601                target_name, detail, ..
1602            } => {
1603                assert_eq!(target_name, "MyCapability");
1604                assert!(detail.contains("lake missing"));
1605            }
1606            other => panic!("expected LakeUnavailable, got {other:?}"),
1607        }
1608        assert_eq!(runner.calls(), 1);
1609    }
1610
1611    #[test]
1612    fn cache_hit_skips_lake_invocation_for_interop_dependency_shape() {
1613        let root = make_project("interop-cache-hit", "InteropConsumer");
1614        write_file(
1615            &root.join("lakefile.lean"),
1616            "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",
1617        );
1618        write_file(
1619            &root.join("lake-manifest.json"),
1620            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"}"#,
1621        );
1622        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1623
1624        let first = build_lake_target_with_runner(&root, "InteropConsumer", &mut runner, CargoMetadata::Emit)
1625            .expect("first build");
1626        let second = build_lake_target_with_runner(&root, "InteropConsumer", &mut runner, CargoMetadata::Emit)
1627            .expect("cached build");
1628
1629        assert_eq!(first, second);
1630        assert_eq!(runner.calls(), 1, "second call should use cache");
1631    }
1632
1633    #[test]
1634    fn command_detail_is_bounded() {
1635        let detail = command_detail(&vec![b'x'; 4096], b"");
1636        assert!(detail.len() <= 1027);
1637        assert!(detail.ends_with("..."));
1638    }
1639
1640    #[test]
1641    fn capability_env_var_is_deterministic() {
1642        assert_eq!(
1643            capability_env_var("MyCapability"),
1644            "LEAN_RS_CAPABILITY_MY_CAPABILITY_DYLIB"
1645        );
1646        assert_eq!(
1647            capability_env_var("lean-dup_index"),
1648            "LEAN_RS_CAPABILITY_LEAN_DUP_INDEX_DYLIB"
1649        );
1650    }
1651
1652    #[test]
1653    fn capability_manifest_env_var_is_deterministic() {
1654        assert_eq!(
1655            capability_manifest_env_var("MyCapability"),
1656            "LEAN_RS_CAPABILITY_MY_CAPABILITY_MANIFEST"
1657        );
1658        assert_eq!(
1659            capability_manifest_env_var("lean-dup_index"),
1660            "LEAN_RS_CAPABILITY_LEAN_DUP_INDEX_MANIFEST"
1661        );
1662    }
1663
1664    #[test]
1665    fn capability_manifest_name_includes_signature_digest_for_non_empty_exports() {
1666        let first = vec![LeanExportSignature::function(
1667            "my_capability_u8_identity",
1668            vec![LeanExportArgAbi::new(LeanExportAbiRepr::U8, LeanExportOwnership::None)],
1669            LeanExportReturnAbi::new(
1670                LeanExportAbiRepr::U8,
1671                LeanExportOwnership::None,
1672                LeanExportResultConvention::Pure,
1673            ),
1674        )];
1675        let second = vec![LeanExportSignature::function(
1676            "my_capability_u16_identity",
1677            vec![LeanExportArgAbi::new(LeanExportAbiRepr::U16, LeanExportOwnership::None)],
1678            LeanExportReturnAbi::new(
1679                LeanExportAbiRepr::U16,
1680                LeanExportOwnership::None,
1681                LeanExportResultConvention::Pure,
1682            ),
1683        )];
1684
1685        assert_eq!(
1686            capability_manifest_name("MyCapability", &[]),
1687            "MyCapability.lean-rs-capability.json"
1688        );
1689        let first_name = capability_manifest_name("MyCapability", &first);
1690        let second_name = capability_manifest_name("MyCapability", &second);
1691        assert_ne!(first_name, second_name);
1692        assert!(first_name.starts_with("MyCapability-"));
1693        assert!(first_name.ends_with(".lean-rs-capability.json"));
1694        assert!(second_name.starts_with("MyCapability-"));
1695        assert!(second_name.ends_with(".lean-rs-capability.json"));
1696    }
1697
1698    #[test]
1699    fn cargo_capability_build_quiet_returns_metadata() {
1700        let root = make_project("cargo-capability", "MyCapability");
1701        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1702        let dylib = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Suppress)
1703            .expect("build target");
1704        let built = CargoLeanCapability::new(&root, "MyCapability")
1705            .package("my_pkg")
1706            .module("MyCapability")
1707            .env_var("MY_CAPABILITY_DYLIB")
1708            .manifest_env_var("MY_CAPABILITY_MANIFEST")
1709            .export_signature(LeanExportSignature::function(
1710                "my_capability_u8_identity",
1711                vec![LeanExportArgAbi::new(LeanExportAbiRepr::U8, LeanExportOwnership::None)],
1712                LeanExportReturnAbi::new(
1713                    LeanExportAbiRepr::U8,
1714                    LeanExportOwnership::None,
1715                    LeanExportResultConvention::Pure,
1716                ),
1717            ))
1718            .build_quiet()
1719            .expect("cargo helper build");
1720
1721        assert_eq!(built.dylib_path(), dylib.as_path());
1722        assert_eq!(built.env_var(), "MY_CAPABILITY_DYLIB");
1723        assert_eq!(built.manifest_env_var(), "MY_CAPABILITY_MANIFEST");
1724        assert!(built.manifest_path().is_file());
1725        assert_eq!(built.package(), "my_pkg");
1726        assert_eq!(built.module(), "MyCapability");
1727        assert_eq!(built.target_name(), "MyCapability");
1728        assert!(built.project_root().is_absolute());
1729
1730        let manifest: serde_json::Value =
1731            serde_json::from_slice(&fs::read(built.manifest_path()).expect("read manifest"))
1732                .expect("manifest is valid JSON");
1733        assert_eq!(
1734            manifest.get("schema_version").and_then(serde_json::Value::as_u64),
1735            Some(u64::from(CAPABILITY_MANIFEST_SCHEMA_VERSION)),
1736        );
1737        assert_eq!(
1738            manifest.get("package").and_then(serde_json::Value::as_str),
1739            Some("my_pkg")
1740        );
1741        assert_eq!(
1742            manifest.get("module").and_then(serde_json::Value::as_str),
1743            Some("MyCapability")
1744        );
1745        assert_eq!(
1746            manifest
1747                .get("primary_dylib")
1748                .and_then(serde_json::Value::as_str)
1749                .map(Path::new),
1750            Some(dylib.as_path()),
1751        );
1752        assert!(manifest.get("toolchain_fingerprint").is_some());
1753        assert_eq!(
1754            manifest
1755                .get("exports")
1756                .and_then(serde_json::Value::as_array)
1757                .and_then(|exports| exports.first())
1758                .and_then(|export| export.get("symbol"))
1759                .and_then(serde_json::Value::as_str),
1760            Some("my_capability_u8_identity"),
1761        );
1762    }
1763
1764    #[test]
1765    fn cargo_capability_manifest_records_explicit_dependencies() {
1766        let root = make_project("cargo-capability-explicit-dependency", "MyCapability");
1767        let dependency = root.join(".lake").join("build").join("lib").join("libdependency.dylib");
1768        write_file(&dependency, "dependency dylib");
1769
1770        let built = CargoLeanCapability::new(&root, "MyCapability")
1771            .package("my_pkg")
1772            .module("MyCapability")
1773            .dependency(
1774                LeanLibraryDependency::path(&dependency)
1775                    .export_symbols_for_dependents()
1776                    .initializer("dependency_pkg", "Dependency"),
1777            )
1778            .build_quiet()
1779            .expect("cargo helper build");
1780
1781        let manifest: serde_json::Value =
1782            serde_json::from_slice(&fs::read(built.manifest_path()).expect("read manifest"))
1783                .expect("manifest is valid JSON");
1784        let dependencies = manifest
1785            .get("dependencies")
1786            .and_then(serde_json::Value::as_array)
1787            .expect("manifest dependencies array");
1788        assert_eq!(dependencies.len(), 1);
1789        let dependency_json = dependencies.first().expect("one dependency");
1790        assert_eq!(
1791            dependency_json.get("name").and_then(serde_json::Value::as_str),
1792            Some("dependency_pkg")
1793        );
1794        assert_eq!(
1795            dependency_json
1796                .get("dylib_path")
1797                .and_then(serde_json::Value::as_str)
1798                .map(Path::new),
1799            Some(dependency.as_path())
1800        );
1801        assert_eq!(
1802            dependency_json
1803                .get("export_symbols_for_dependents")
1804                .and_then(serde_json::Value::as_bool),
1805            Some(true)
1806        );
1807        assert_eq!(
1808            dependency_json
1809                .get("initializer")
1810                .and_then(|initializer| initializer.get("package"))
1811                .and_then(serde_json::Value::as_str),
1812            Some("dependency_pkg")
1813        );
1814        assert_eq!(
1815            dependency_json
1816                .get("initializer")
1817                .and_then(|initializer| initializer.get("module"))
1818                .and_then(serde_json::Value::as_str),
1819            Some("Dependency")
1820        );
1821    }
1822
1823    #[test]
1824    fn cargo_capability_dependency_without_initializer_uses_stable_file_name() {
1825        let root = make_project("cargo-capability-explicit-dependency-no-init", "MyCapability");
1826        let dependency = root.join(".lake").join("build").join("lib").join("libsupport.dylib");
1827        write_file(&dependency, "dependency dylib");
1828
1829        let built = CargoLeanCapability::new(&root, "MyCapability")
1830            .package("my_pkg")
1831            .module("MyCapability")
1832            .dependency(LeanLibraryDependency::path(&dependency))
1833            .build_quiet()
1834            .expect("cargo helper build");
1835
1836        let manifest: serde_json::Value =
1837            serde_json::from_slice(&fs::read(built.manifest_path()).expect("read manifest"))
1838                .expect("manifest is valid JSON");
1839        let dependency_json = manifest
1840            .get("dependencies")
1841            .and_then(serde_json::Value::as_array)
1842            .and_then(|dependencies| dependencies.first())
1843            .expect("one dependency");
1844        assert_eq!(
1845            dependency_json.get("name").and_then(serde_json::Value::as_str),
1846            Some("libsupport")
1847        );
1848        assert!(
1849            dependency_json
1850                .get("initializer")
1851                .is_some_and(serde_json::Value::is_null),
1852            "dependencies without initializers record null initializer"
1853        );
1854    }
1855
1856    #[test]
1857    fn cargo_capability_build_quiet_passes_explicit_sysroot_to_lake() {
1858        let root = make_project("cargo-capability-explicit-sysroot", "MyCapability");
1859        let sysroot = make_fake_sysroot("cargo-capability");
1860        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1861
1862        let built = CargoLeanCapability::new(&root, "MyCapability")
1863            .package("my_pkg")
1864            .module("MyCapability")
1865            .lean_sysroot(&sysroot)
1866            .build_with_runner(&mut runner, CargoMetadata::Suppress)
1867            .expect("cargo helper build");
1868
1869        assert_eq!(runner.seen_sysroots(), vec![Some(sysroot.clone())]);
1870
1871        let manifest: serde_json::Value =
1872            serde_json::from_slice(&fs::read(built.manifest_path()).expect("read manifest"))
1873                .expect("manifest is valid JSON");
1874        let build_toolchain = manifest
1875            .get("build_toolchain")
1876            .expect("manifest records selected build toolchain");
1877        assert_eq!(
1878            build_toolchain.get("source").and_then(serde_json::Value::as_str),
1879            Some("ExplicitSysroot")
1880        );
1881        assert_eq!(
1882            build_toolchain.get("sysroot").and_then(serde_json::Value::as_str),
1883            Some(sysroot.to_str().expect("test sysroot is UTF-8"))
1884        );
1885    }
1886
1887    #[test]
1888    fn cargo_capability_explicit_sysroot_does_not_fall_back_to_ambient_discovery() {
1889        let root = make_project("cargo-capability-invalid-explicit-sysroot", "MyCapability");
1890        let mut runner = FakeLake::new(FakeMode::SuccessModern);
1891
1892        let err = CargoLeanCapability::new(&root, "MyCapability")
1893            .package("my_pkg")
1894            .module("MyCapability")
1895            .lean_sysroot("/definitely/not/a/lean/sysroot")
1896            .build_with_runner(&mut runner, CargoMetadata::Suppress)
1897            .expect_err("invalid explicit sysroot must not fall back to ambient probes");
1898
1899        match err {
1900            LinkDiagnostics::MissingLean { tried } => {
1901                assert!(
1902                    tried.iter().any(|line| line.contains("explicit_sysroot=")),
1903                    "diagnostic should name the explicit sysroot probe: {tried:?}",
1904                );
1905                assert!(
1906                    tried.iter().any(|line| line == "PATH lookup disabled"),
1907                    "ambient PATH probe should be disabled: {tried:?}",
1908                );
1909            }
1910            other => panic!("expected MissingLean, got {other:?}"),
1911        }
1912        assert_eq!(runner.calls(), 0, "Lake must not run after invalid explicit sysroot");
1913    }
1914}