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