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