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