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