Skip to main content

lean_toolchain/
build_helpers.rs

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