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