1use std::env;
28use std::fmt::Write as _;
29use std::fs;
30use std::io::{self, Write as _};
31use std::path::{Path, PathBuf};
32use std::process::Command;
33use std::sync::OnceLock;
34use std::time::UNIX_EPOCH;
35
36use sha2::{Digest, Sha256};
37
38use crate::diagnostics::LinkDiagnostics;
39use crate::discover::{DiscoverOptions, ToolchainInfo, discover_toolchain};
40use crate::fingerprint::ToolchainFingerprint;
41use crate::lakefile_toml::parse_lakefile_toml;
42
43pub const CAPABILITY_MANIFEST_SCHEMA_VERSION: u32 = 1;
45
46static EMITTED: OnceLock<()> = OnceLock::new();
50
51pub fn emit_lean_link_directives() {
71 if let Err(diagnostic) = emit_lean_link_directives_checked() {
72 println!("cargo:warning={diagnostic}");
73 }
74}
75
76pub fn emit_lean_link_directives_checked() -> Result<(), LinkDiagnostics> {
94 if EMITTED.get().is_some() {
95 return Ok(());
96 }
97
98 match discover_toolchain(&DiscoverOptions::default()) {
99 Ok(info) => {
100 emit_for(&info);
101 let _ = EMITTED.set(());
102 Ok(())
103 }
104 Err(diagnostic) => {
105 emit_rerun_triggers(None);
106 Err(diagnostic)
107 }
108 }
109}
110
111pub fn build_lake_target(project_root: &Path, target_name: &str) -> Result<PathBuf, LinkDiagnostics> {
141 let mut runner = RealLakeRunner;
142 build_lake_target_with_runner(project_root, target_name, &mut runner, CargoMetadata::Emit)
143}
144
145pub fn build_lake_target_quiet(project_root: &Path, target_name: &str) -> Result<PathBuf, LinkDiagnostics> {
156 let mut runner = RealLakeRunner;
157 build_lake_target_with_runner(project_root, target_name, &mut runner, CargoMetadata::Suppress)
158}
159
160#[derive(Clone, Debug)]
177pub struct CargoLeanCapability {
178 project_root: PathBuf,
179 target_name: String,
180 package: Option<String>,
181 module: Option<String>,
182 env_var: Option<String>,
183 manifest_env_var: Option<String>,
184}
185
186impl CargoLeanCapability {
187 #[must_use]
189 pub fn new(project_root: impl Into<PathBuf>, target_name: impl Into<String>) -> Self {
190 Self {
191 project_root: project_root.into(),
192 target_name: target_name.into(),
193 package: None,
194 module: None,
195 env_var: None,
196 manifest_env_var: None,
197 }
198 }
199
200 #[must_use]
206 pub fn package(mut self, package: impl Into<String>) -> Self {
207 self.package = Some(package.into());
208 self
209 }
210
211 #[must_use]
215 pub fn module(mut self, module: impl Into<String>) -> Self {
216 self.module = Some(module.into());
217 self
218 }
219
220 #[must_use]
225 pub fn env_var(mut self, env_var: impl Into<String>) -> Self {
226 self.env_var = Some(env_var.into());
227 self
228 }
229
230 #[must_use]
236 pub fn manifest_env_var(mut self, env_var: impl Into<String>) -> Self {
237 self.manifest_env_var = Some(env_var.into());
238 self
239 }
240
241 pub fn build(self) -> Result<BuiltLeanCapability, LinkDiagnostics> {
250 emit_lean_link_directives_checked()?;
251 let dylib_path = build_lake_target(&self.project_root, &self.target_name)?;
252 self.finish(dylib_path, CargoMetadata::Emit)
253 }
254
255 pub fn build_quiet(self) -> Result<BuiltLeanCapability, LinkDiagnostics> {
265 let dylib_path = build_lake_target_quiet(&self.project_root, &self.target_name)?;
266 self.finish(dylib_path, CargoMetadata::Suppress)
267 }
268
269 fn finish(
270 self,
271 dylib_path: PathBuf,
272 cargo_metadata: CargoMetadata,
273 ) -> Result<BuiltLeanCapability, LinkDiagnostics> {
274 let project_root =
275 fs::canonicalize(&self.project_root).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
276 project_root: self.project_root.clone(),
277 target_name: self.target_name.clone(),
278 reason: format!(
279 "could not canonicalize project root {} ({err})",
280 self.project_root.display()
281 ),
282 })?;
283 let package = match self.package {
284 Some(package) => package,
285 None => infer_package_name(&project_root, &self.target_name)?,
286 };
287 let module = self.module.unwrap_or_else(|| self.target_name.clone());
288 let env_var = self.env_var.unwrap_or_else(|| capability_env_var(&self.target_name));
289 let manifest_env_var = self
290 .manifest_env_var
291 .unwrap_or_else(|| capability_manifest_env_var(&self.target_name));
292 let manifest_path = write_capability_manifest(
293 &project_root,
294 &self.target_name,
295 &package,
296 &module,
297 &dylib_path,
298 &manifest_env_var,
299 )?;
300 cargo_metadata.println(format_args!("cargo:rustc-env={env_var}={}", dylib_path.display()));
301 cargo_metadata.println(format_args!(
302 "cargo:rustc-env={manifest_env_var}={}",
303 manifest_path.display()
304 ));
305 Ok(BuiltLeanCapability {
306 dylib_path,
307 env_var,
308 manifest_path,
309 manifest_env_var,
310 package,
311 module,
312 target_name: self.target_name,
313 project_root,
314 })
315 }
316}
317
318#[derive(Clone, Debug, Eq, PartialEq)]
320pub struct BuiltLeanCapability {
321 dylib_path: PathBuf,
322 env_var: String,
323 manifest_path: PathBuf,
324 manifest_env_var: String,
325 package: String,
326 module: String,
327 target_name: String,
328 project_root: PathBuf,
329}
330
331impl BuiltLeanCapability {
332 #[must_use]
334 pub fn dylib_path(&self) -> &Path {
335 &self.dylib_path
336 }
337
338 #[must_use]
340 pub fn env_var(&self) -> &str {
341 &self.env_var
342 }
343
344 #[must_use]
346 pub fn manifest_path(&self) -> &Path {
347 &self.manifest_path
348 }
349
350 #[must_use]
352 pub fn manifest_env_var(&self) -> &str {
353 &self.manifest_env_var
354 }
355
356 #[must_use]
358 pub fn package(&self) -> &str {
359 &self.package
360 }
361
362 #[must_use]
364 pub fn module(&self) -> &str {
365 &self.module
366 }
367
368 #[must_use]
370 pub fn target_name(&self) -> &str {
371 &self.target_name
372 }
373
374 #[must_use]
376 pub fn project_root(&self) -> &Path {
377 &self.project_root
378 }
379}
380
381#[must_use]
383pub fn capability_env_var(target_name: &str) -> String {
384 format!("LEAN_RS_CAPABILITY_{}_DYLIB", screaming_snake(target_name))
385}
386
387#[must_use]
389pub fn capability_manifest_env_var(target_name: &str) -> String {
390 format!("LEAN_RS_CAPABILITY_{}_MANIFEST", screaming_snake(target_name))
391}
392
393fn emit_for(info: &ToolchainInfo) {
394 let lib_lean = info.lib_dir.join("lean");
395 println!("cargo:rustc-link-search=native={}", lib_lean.display());
396 println!("cargo:rustc-link-search=native={}", info.lib_dir.display());
397 println!("cargo:rustc-link-lib=dylib=leanshared");
398
399 let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
404 if matches!(target_os.as_str(), "macos" | "linux") {
405 println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_lean.display());
406 }
407
408 emit_rerun_triggers(Some(info));
409}
410
411fn emit_rerun_triggers(info: Option<&ToolchainInfo>) {
412 if let Some(info) = info {
413 println!("cargo:rerun-if-changed={}", info.header_path.display());
414 }
415 println!("cargo:rerun-if-env-changed=LEAN_SYSROOT");
416 println!("cargo:rerun-if-env-changed=ELAN_HOME");
417 println!("cargo:rerun-if-env-changed=PATH");
418}
419
420trait LakeRunner {
421 fn build_shared(&mut self, project_root: &Path, target_name: &str) -> Result<LakeRun, std::io::Error>;
422}
423
424struct RealLakeRunner;
425
426impl LakeRunner for RealLakeRunner {
427 fn build_shared(&mut self, project_root: &Path, target_name: &str) -> Result<LakeRun, std::io::Error> {
428 let output = Command::new("lake")
429 .arg("build")
430 .arg(format!("{target_name}:shared"))
431 .current_dir(project_root)
432 .output()?;
433 Ok(LakeRun {
434 success: output.status.success(),
435 status: output.status.to_string(),
436 stdout: output.stdout,
437 stderr: output.stderr,
438 })
439 }
440}
441
442struct LakeRun {
443 success: bool,
444 status: String,
445 stdout: Vec<u8>,
446 stderr: Vec<u8>,
447}
448
449#[derive(Clone, Copy, Debug, Eq, PartialEq)]
450enum CargoMetadata {
451 Emit,
452 Suppress,
453}
454
455impl CargoMetadata {
456 fn println(self, args: std::fmt::Arguments<'_>) {
457 if matches!(self, Self::Emit) {
458 println!("{args}");
459 }
460 }
461
462 fn trace(self, args: std::fmt::Arguments<'_>) {
463 if matches!(self, Self::Emit) {
464 emit_lake_trace(args);
465 }
466 }
467}
468
469fn build_lake_target_with_runner(
470 project_root: &Path,
471 target_name: &str,
472 runner: &mut impl LakeRunner,
473 cargo_metadata: CargoMetadata,
474) -> Result<PathBuf, LinkDiagnostics> {
475 let project_root = fs::canonicalize(project_root).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
476 project_root: project_root.to_path_buf(),
477 target_name: target_name.to_owned(),
478 reason: format!("could not canonicalize project root {} ({err})", project_root.display()),
479 })?;
480 let lakefile_lean = project_root.join("lakefile.lean");
481 cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", lakefile_lean.display()));
482 let lakefile_toml = project_root.join("lakefile.toml");
483 if lakefile_toml.is_file() {
484 cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", lakefile_toml.display()));
485 }
486 let toolchain_file = project_root.join("lean-toolchain");
487 if toolchain_file.is_file() {
488 cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", toolchain_file.display()));
489 }
490
491 let lakefile = existing_lakefile(&project_root).ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
492 project_root: project_root.clone(),
493 target_name: target_name.to_owned(),
494 reason: format!(
495 "no Lake lakefile found at {} or {}",
496 lakefile_lean.display(),
497 lakefile_toml.display()
498 ),
499 })?;
500
501 if !target_declared_in_lakefile(&lakefile, target_name)? {
502 return Err(LinkDiagnostics::LakeTargetMissing {
503 project_root,
504 target_name: target_name.to_owned(),
505 });
506 }
507
508 let manifest_path = project_root.join("lake-manifest.json");
509 cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", manifest_path.display()));
510 let (manifest_digest, package_name) = match fs::read(&manifest_path) {
511 Ok(manifest_bytes) => (
512 sha256_hex(&manifest_bytes),
513 package_name_from_manifest(&project_root, target_name, &manifest_path, &manifest_bytes)?,
514 ),
515 Err(err) if err.kind() == io::ErrorKind::NotFound => (
516 "missing".to_owned(),
517 package_name_from_lakefile(&project_root, target_name, &lakefile)?,
518 ),
519 Err(err) => {
520 return Err(LinkDiagnostics::LakeOutputUnresolved {
521 project_root: project_root.clone(),
522 target_name: target_name.to_owned(),
523 reason: format!("could not read {} ({err})", manifest_path.display()),
524 });
525 }
526 };
527 let source_set = scan_source_set(&project_root, target_name)?;
528 for path in &source_set.paths {
529 cargo_metadata.println(format_args!("cargo:rerun-if-changed={}", path.display()));
530 }
531
532 let dylib = resolve_dylib_path(&project_root, &package_name, target_name);
533 let initial_cache_key = cache_key(target_name, &package_name, &manifest_digest, &source_set);
534 let cache_path = cache_path(&project_root, target_name);
535 if dylib.is_file() && fs::read_to_string(&cache_path).is_ok_and(|cached| cached == initial_cache_key) {
536 cargo_metadata.trace(format_args!(
537 "lean-toolchain: cache hit for Lake target `{target_name}` in {}; using {}",
538 project_root.display(),
539 dylib.display(),
540 ));
541 return Ok(dylib);
542 }
543 cargo_metadata.trace(format_args!(
544 "lean-toolchain: cache miss for Lake target `{target_name}` in {}; running `lake build {target_name}:shared`",
545 project_root.display(),
546 ));
547
548 let run = runner
549 .build_shared(&project_root, target_name)
550 .map_err(|err| LinkDiagnostics::LakeUnavailable {
551 project_root: project_root.clone(),
552 target_name: target_name.to_owned(),
553 detail: err.to_string(),
554 })?;
555 if !run.success {
556 return Err(LinkDiagnostics::LakeBuildFailed {
557 project_root,
558 target_name: target_name.to_owned(),
559 status: run.status,
560 detail: command_detail(&run.stdout, &run.stderr),
561 });
562 }
563
564 let (final_manifest_digest, final_package_name) = match fs::read(&manifest_path) {
565 Ok(manifest_bytes) => (
566 sha256_hex(&manifest_bytes),
567 package_name_from_manifest(&project_root, target_name, &manifest_path, &manifest_bytes)?,
568 ),
569 Err(_) => (manifest_digest, package_name),
570 };
571 let final_cache_key = cache_key(target_name, &final_package_name, &final_manifest_digest, &source_set);
572
573 let dylib = resolve_dylib_path(&project_root, &final_package_name, target_name);
574 if !dylib.is_file() {
575 return Err(LinkDiagnostics::LakeOutputUnresolved {
576 project_root,
577 target_name: target_name.to_owned(),
578 reason: format!("expected shared library at {}", dylib.display()),
579 });
580 }
581
582 if let Some(parent) = cache_path.parent() {
583 fs::create_dir_all(parent).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
584 project_root: project_root.clone(),
585 target_name: target_name.to_owned(),
586 reason: format!("could not create cache directory {} ({err})", parent.display()),
587 })?;
588 }
589 fs::write(&cache_path, final_cache_key).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
590 project_root,
591 target_name: target_name.to_owned(),
592 reason: format!("could not write cache file {} ({err})", cache_path.display()),
593 })?;
594
595 Ok(dylib)
596}
597
598fn target_declared_in_lakefile(lakefile: &Path, target_name: &str) -> Result<bool, LinkDiagnostics> {
599 let contents = fs::read_to_string(lakefile).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
600 project_root: lakefile.parent().unwrap_or_else(|| Path::new("")).to_path_buf(),
601 target_name: target_name.to_owned(),
602 reason: format!("could not read {} ({err})", lakefile.display()),
603 })?;
604 if lakefile_is_toml(lakefile) {
605 let parsed = parse_lakefile_toml(&contents).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
606 project_root: lakefile.parent().unwrap_or_else(|| Path::new("")).to_path_buf(),
607 target_name: target_name.to_owned(),
608 reason: format!("lakefile {} is not valid TOML ({err})", lakefile.display()),
609 })?;
610 return Ok(parsed.lean_libs.iter().any(|name| name == target_name));
611 }
612 let quoted = format!("lean_lib «{target_name}»");
613 let bare = format!("lean_lib {target_name}");
614 let string = format!("lean_lib \"{target_name}\"");
615 Ok(contents.contains("ed) || contents.contains(&bare) || contents.contains(&string))
616}
617
618fn lakefile_is_toml(lakefile: &Path) -> bool {
619 lakefile.file_name().and_then(|name| name.to_str()) == Some("lakefile.toml")
620}
621
622fn existing_lakefile(project_root: &Path) -> Option<PathBuf> {
623 let toml = project_root.join("lakefile.toml");
624 if toml.is_file() {
625 return Some(toml);
626 }
627 let lean = project_root.join("lakefile.lean");
628 lean.is_file().then_some(lean)
629}
630
631fn package_name_from_manifest(
632 project_root: &Path,
633 target_name: &str,
634 manifest_path: &Path,
635 manifest_bytes: &[u8],
636) -> Result<String, LinkDiagnostics> {
637 let manifest: serde_json::Value =
638 serde_json::from_slice(manifest_bytes).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
639 project_root: project_root.to_path_buf(),
640 target_name: target_name.to_owned(),
641 reason: format!("{} is not valid JSON ({err})", manifest_path.display()),
642 })?;
643 manifest
644 .get("name")
645 .and_then(serde_json::Value::as_str)
646 .filter(|name| !name.is_empty())
647 .map(str::to_owned)
648 .ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
649 project_root: project_root.to_path_buf(),
650 target_name: target_name.to_owned(),
651 reason: format!("{} has no string `name` field", manifest_path.display()),
652 })
653}
654
655fn package_name_from_lakefile(
656 project_root: &Path,
657 target_name: &str,
658 lakefile: &Path,
659) -> Result<String, LinkDiagnostics> {
660 let contents = fs::read_to_string(lakefile).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
661 project_root: project_root.to_path_buf(),
662 target_name: target_name.to_owned(),
663 reason: format!("could not read {} ({err})", lakefile.display()),
664 })?;
665 if lakefile_is_toml(lakefile) {
666 let parsed = parse_lakefile_toml(&contents).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
667 project_root: project_root.to_path_buf(),
668 target_name: target_name.to_owned(),
669 reason: format!("lakefile {} is not valid TOML ({err})", lakefile.display()),
670 })?;
671 return parsed
672 .package_name
673 .ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
674 project_root: project_root.to_path_buf(),
675 target_name: target_name.to_owned(),
676 reason: format!("{} has no top-level `name` field", lakefile.display()),
677 });
678 }
679 for line in contents.lines() {
680 let trimmed = line.trim();
681 if let Some(rest) = trimmed.strip_prefix("package ") {
682 return Ok(normalize_lake_identifier(rest));
683 }
684 }
685 Err(LinkDiagnostics::LakeOutputUnresolved {
686 project_root: project_root.to_path_buf(),
687 target_name: target_name.to_owned(),
688 reason: format!("{} has no `package` declaration", lakefile.display()),
689 })
690}
691
692fn infer_package_name(project_root: &Path, target_name: &str) -> Result<String, LinkDiagnostics> {
693 let manifest_path = project_root.join("lake-manifest.json");
694 match fs::read(&manifest_path) {
695 Ok(manifest_bytes) => package_name_from_manifest(project_root, target_name, &manifest_path, &manifest_bytes),
696 Err(err) if err.kind() == io::ErrorKind::NotFound => {
697 let lakefile = existing_lakefile(project_root).ok_or_else(|| LinkDiagnostics::LakeOutputUnresolved {
698 project_root: project_root.to_path_buf(),
699 target_name: target_name.to_owned(),
700 reason: format!(
701 "no Lake lakefile found at {} or {}",
702 project_root.join("lakefile.lean").display(),
703 project_root.join("lakefile.toml").display()
704 ),
705 })?;
706 package_name_from_lakefile(project_root, target_name, &lakefile)
707 }
708 Err(err) => Err(LinkDiagnostics::LakeOutputUnresolved {
709 project_root: project_root.to_path_buf(),
710 target_name: target_name.to_owned(),
711 reason: format!("could not read {} ({err})", manifest_path.display()),
712 }),
713 }
714}
715
716fn normalize_lake_identifier(raw: &str) -> String {
717 raw.trim()
718 .trim_matches('«')
719 .trim_matches('»')
720 .trim_matches('"')
721 .trim()
722 .to_owned()
723}
724
725struct SourceSet {
726 paths: Vec<PathBuf>,
727 max_mtime_ns: u128,
728}
729
730fn scan_source_set(project_root: &Path, target_name: &str) -> Result<SourceSet, LinkDiagnostics> {
731 let mut paths = Vec::new();
732 collect_lean_sources(project_root, project_root, target_name, &mut paths)?;
733 for file_name in ["lakefile.lean", "lakefile.toml", "lean-toolchain"] {
734 let path = project_root.join(file_name);
735 if path.is_file() {
736 paths.push(path);
737 }
738 }
739 paths.sort();
740 paths.dedup();
741
742 let mut max_mtime_ns = 0;
743 for path in &paths {
744 let metadata = fs::metadata(path).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
745 project_root: project_root.to_path_buf(),
746 target_name: target_name.to_owned(),
747 reason: format!("could not stat {} ({err})", path.display()),
748 })?;
749 let modified = metadata
750 .modified()
751 .map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
752 project_root: project_root.to_path_buf(),
753 target_name: target_name.to_owned(),
754 reason: format!("could not read mtime for {} ({err})", path.display()),
755 })?;
756 let mtime_ns = modified
757 .duration_since(UNIX_EPOCH)
758 .map_or(0, |duration| duration.as_nanos());
759 max_mtime_ns = max_mtime_ns.max(mtime_ns);
760 }
761
762 Ok(SourceSet { paths, max_mtime_ns })
763}
764
765fn collect_lean_sources(
766 project_root: &Path,
767 dir: &Path,
768 target_name: &str,
769 paths: &mut Vec<PathBuf>,
770) -> Result<(), LinkDiagnostics> {
771 for entry in fs::read_dir(dir).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
772 project_root: project_root.to_path_buf(),
773 target_name: target_name.to_owned(),
774 reason: format!("could not read directory {} ({err})", dir.display()),
775 })? {
776 let entry = entry.map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
777 project_root: project_root.to_path_buf(),
778 target_name: target_name.to_owned(),
779 reason: format!("could not read directory entry under {} ({err})", dir.display()),
780 })?;
781 let path = entry.path();
782 if path.file_name().is_some_and(|name| name == ".lake") {
783 continue;
784 }
785 let metadata = entry.metadata().map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
786 project_root: project_root.to_path_buf(),
787 target_name: target_name.to_owned(),
788 reason: format!("could not stat {} ({err})", path.display()),
789 })?;
790 if metadata.is_dir() {
791 collect_lean_sources(project_root, &path, target_name, paths)?;
792 } else if path.extension().is_some_and(|ext| ext == "lean") {
793 paths.push(path);
794 }
795 }
796 Ok(())
797}
798
799fn resolve_dylib_path(project_root: &Path, package_name: &str, target_name: &str) -> PathBuf {
800 let dylib_extension = if cfg!(target_os = "macos") { "dylib" } else { "so" };
801 let lib_dir = project_root.join(".lake").join("build").join("lib");
802 let escaped_package = package_name.replace('_', "__");
803 let new_style = lib_dir.join(format!("lib{escaped_package}_{target_name}.{dylib_extension}"));
804 let old_style = lib_dir.join(format!("lib{target_name}.{dylib_extension}"));
805 if new_style.is_file() {
806 new_style
807 } else if old_style.is_file() {
808 old_style
809 } else {
810 new_style
811 }
812}
813
814fn cache_path(project_root: &Path, target_name: &str) -> PathBuf {
815 project_root
816 .join(".lake")
817 .join("lean-rs-build-cache")
818 .join(format!("{}.cache", sanitize_target_name(target_name)))
819}
820
821fn write_capability_manifest(
822 project_root: &Path,
823 target_name: &str,
824 package: &str,
825 module: &str,
826 dylib_path: &Path,
827 manifest_env_var: &str,
828) -> Result<PathBuf, LinkDiagnostics> {
829 let manifest_path = capability_manifest_path(project_root, target_name);
830 let dependencies = capability_dependencies(project_root, target_name)?;
831 let fingerprint = ToolchainFingerprint::current();
832 let search_dirs = capability_search_dirs(project_root, dylib_path);
833 let manifest = serde_json::json!({
834 "schema_version": CAPABILITY_MANIFEST_SCHEMA_VERSION,
835 "target_name": target_name,
836 "package": package,
837 "module": module,
838 "primary_dylib": dylib_path.display().to_string(),
839 "manifest_env_var": manifest_env_var,
840 "lean_version": &fingerprint.lean_version,
841 "resolved_lean_version": &fingerprint.resolved_version,
842 "lean_header_sha256": &fingerprint.header_sha256,
843 "toolchain_fingerprint": {
844 "lean_version": &fingerprint.lean_version,
845 "resolved_version": &fingerprint.resolved_version,
846 "header_sha256": &fingerprint.header_sha256,
847 "fixture_sha256": &fingerprint.fixture_sha256,
848 "host_triple": &fingerprint.host_triple,
849 },
850 "search_dirs": search_dirs
851 .iter()
852 .map(|path| path.display().to_string())
853 .collect::<Vec<_>>(),
854 "dependencies": dependencies,
855 });
856 let bytes = serde_json::to_vec_pretty(&manifest).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
857 project_root: project_root.to_path_buf(),
858 target_name: target_name.to_owned(),
859 reason: format!("could not encode Lean capability manifest ({err})"),
860 })?;
861 if let Some(parent) = manifest_path.parent() {
862 fs::create_dir_all(parent).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
863 project_root: project_root.to_path_buf(),
864 target_name: target_name.to_owned(),
865 reason: format!("could not create manifest directory {} ({err})", parent.display()),
866 })?;
867 }
868 fs::write(&manifest_path, bytes).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
869 project_root: project_root.to_path_buf(),
870 target_name: target_name.to_owned(),
871 reason: format!(
872 "could not write Lean capability manifest {} ({err})",
873 manifest_path.display()
874 ),
875 })?;
876 Ok(manifest_path)
877}
878
879fn capability_manifest_path(project_root: &Path, target_name: &str) -> PathBuf {
880 if let Some(out_dir) = env::var_os("OUT_DIR") {
881 PathBuf::from(out_dir).join(format!("{}.lean-rs-capability.json", sanitize_target_name(target_name)))
882 } else {
883 project_root
884 .join(".lake")
885 .join("lean-rs-build-cache")
886 .join(format!("{}.lean-rs-capability.json", sanitize_target_name(target_name)))
887 }
888}
889
890fn capability_search_dirs(project_root: &Path, dylib_path: &Path) -> Vec<PathBuf> {
891 let mut dirs = Vec::new();
892 if let Some(parent) = dylib_path.parent() {
893 dirs.push(parent.to_path_buf());
894 }
895 dirs.push(project_root.join(".lake").join("build").join("lib"));
896 dirs.sort();
897 dirs.dedup();
898 dirs
899}
900
901fn capability_dependencies(project_root: &Path, target_name: &str) -> Result<Vec<serde_json::Value>, LinkDiagnostics> {
902 let manifest_path = project_root.join("lake-manifest.json");
903 let bytes = match fs::read(&manifest_path) {
904 Ok(bytes) => bytes,
905 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
906 Err(err) => {
907 return Err(LinkDiagnostics::LakeOutputUnresolved {
908 project_root: project_root.to_path_buf(),
909 target_name: target_name.to_owned(),
910 reason: format!("could not read {} ({err})", manifest_path.display()),
911 });
912 }
913 };
914 let manifest: serde_json::Value =
915 serde_json::from_slice(&bytes).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
916 project_root: project_root.to_path_buf(),
917 target_name: target_name.to_owned(),
918 reason: format!("{} is not valid JSON ({err})", manifest_path.display()),
919 })?;
920 let packages = manifest
921 .get("packages")
922 .and_then(serde_json::Value::as_array)
923 .map_or([].as_slice(), Vec::as_slice);
924 let mut dependencies = Vec::new();
925 for package in packages {
926 let Some(name) = package.get("name").and_then(serde_json::Value::as_str) else {
927 continue;
928 };
929 if name != "lean_rs_interop_shims" {
930 continue;
931 }
932 let Some(dir) = package.get("dir").and_then(serde_json::Value::as_str) else {
933 continue;
934 };
935 let dependency_root = project_root.join(dir);
936 let dependency_root =
937 fs::canonicalize(&dependency_root).map_err(|err| LinkDiagnostics::LakeOutputUnresolved {
938 project_root: project_root.to_path_buf(),
939 target_name: target_name.to_owned(),
940 reason: format!(
941 "could not canonicalize dependency root {} ({err})",
942 dependency_root.display()
943 ),
944 })?;
945 let dylib = resolve_dylib_path(&dependency_root, "lean_rs_interop_shims", "LeanRsInterop");
946 dependencies.push(serde_json::json!({
947 "name": name,
948 "dylib_path": dylib.display().to_string(),
949 "export_symbols_for_dependents": true,
950 "initializer": {
951 "package": "lean_rs_interop_shims",
952 "module": "LeanRsInterop",
953 }
954 }));
955 }
956 Ok(dependencies)
957}
958
959fn sanitize_target_name(target_name: &str) -> String {
960 target_name
961 .chars()
962 .map(|ch| {
963 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
964 ch
965 } else {
966 '_'
967 }
968 })
969 .collect()
970}
971
972fn screaming_snake(input: &str) -> String {
973 let mut out = String::new();
974 let mut prev_was_sep = true;
975 let mut prev_was_lower_or_digit = false;
976 for ch in input.chars() {
977 if ch.is_ascii_alphanumeric() {
978 if ch.is_ascii_uppercase() && prev_was_lower_or_digit && !prev_was_sep {
979 out.push('_');
980 }
981 out.push(ch.to_ascii_uppercase());
982 prev_was_sep = false;
983 prev_was_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
984 } else {
985 if !prev_was_sep {
986 out.push('_');
987 }
988 prev_was_sep = true;
989 prev_was_lower_or_digit = false;
990 }
991 }
992 while out.ends_with('_') {
993 out.pop();
994 }
995 if out.is_empty() { "CAPABILITY".to_owned() } else { out }
996}
997
998fn cache_key(target_name: &str, package_name: &str, manifest_digest: &str, source_set: &SourceSet) -> String {
999 format!(
1000 "target={target_name}\npackage={package_name}\nmanifest={manifest_digest}\nsource_count={}\nsource_max_mtime_ns={}\n",
1001 source_set.paths.len(),
1002 source_set.max_mtime_ns
1003 )
1004}
1005
1006fn sha256_hex(bytes: &[u8]) -> String {
1007 let mut hasher = Sha256::new();
1008 hasher.update(bytes);
1009 let digest = hasher.finalize();
1010 let mut out = String::with_capacity(digest.len().saturating_mul(2));
1011 for byte in digest {
1012 let _ = write!(out, "{byte:02x}");
1013 }
1014 out
1015}
1016
1017fn command_detail(stdout: &[u8], stderr: &[u8]) -> String {
1018 let mut raw = String::new();
1019 if !stderr.is_empty() {
1020 raw.push_str(&String::from_utf8_lossy(stderr));
1021 }
1022 if !stdout.is_empty() {
1023 if !raw.is_empty() {
1024 raw.push_str(" | ");
1025 }
1026 raw.push_str(&String::from_utf8_lossy(stdout));
1027 }
1028 let collapsed = raw.split_whitespace().collect::<Vec<_>>().join(" ");
1029 if collapsed.is_empty() {
1030 "no output".to_owned()
1031 } else if collapsed.len() > 1024 {
1032 let mut bounded = collapsed.chars().take(1024).collect::<String>();
1033 bounded.push_str("...");
1034 bounded
1035 } else {
1036 collapsed
1037 }
1038}
1039
1040fn emit_lake_trace(args: std::fmt::Arguments<'_>) {
1041 let mut stderr = io::stderr().lock();
1042 drop(stderr.write_fmt(args));
1043 drop(stderr.write_all(b"\n"));
1044}
1045
1046#[cfg(test)]
1047#[allow(clippy::expect_used, clippy::panic, clippy::wildcard_enum_match_arm)]
1048mod tests {
1049 use super::{
1050 CAPABILITY_MANIFEST_SCHEMA_VERSION, CargoLeanCapability, CargoMetadata, LakeRun, LakeRunner,
1051 build_lake_target_with_runner, capability_env_var, capability_manifest_env_var, command_detail,
1052 };
1053 use crate::LinkDiagnostics;
1054 use std::cell::Cell;
1055 use std::fs;
1056 use std::path::{Path, PathBuf};
1057 use std::rc::Rc;
1058
1059 #[derive(Clone)]
1060 struct FakeLake {
1061 calls: Rc<Cell<usize>>,
1062 mode: FakeMode,
1063 }
1064
1065 #[derive(Clone)]
1066 enum FakeMode {
1067 SuccessModern,
1068 SuccessLegacy,
1069 Failure,
1070 SpawnError,
1071 }
1072
1073 impl FakeLake {
1074 fn new(mode: FakeMode) -> Self {
1075 Self {
1076 calls: Rc::new(Cell::new(0)),
1077 mode,
1078 }
1079 }
1080
1081 fn calls(&self) -> usize {
1082 self.calls.get()
1083 }
1084 }
1085
1086 impl LakeRunner for FakeLake {
1087 fn build_shared(&mut self, project_root: &Path, target_name: &str) -> Result<LakeRun, std::io::Error> {
1088 self.calls.set(self.calls.get().saturating_add(1));
1089 match self.mode {
1090 FakeMode::SuccessModern => {
1091 let dylib = project_root
1092 .join(".lake")
1093 .join("build")
1094 .join("lib")
1095 .join(format!("libmy__pkg_{target_name}.{}", dylib_ext()));
1096 write_file(&dylib, "dylib");
1097 Ok(success_run())
1098 }
1099 FakeMode::SuccessLegacy => {
1100 let dylib = project_root
1101 .join(".lake")
1102 .join("build")
1103 .join("lib")
1104 .join(format!("lib{target_name}.{}", dylib_ext()));
1105 write_file(&dylib, "dylib");
1106 Ok(success_run())
1107 }
1108 FakeMode::Failure => Ok(LakeRun {
1109 success: false,
1110 status: "exit status: 1".to_owned(),
1111 stdout: b"stdout detail\n".to_vec(),
1112 stderr: b"stderr detail\n".to_vec(),
1113 }),
1114 FakeMode::SpawnError => Err(std::io::Error::new(std::io::ErrorKind::NotFound, "lake missing")),
1115 }
1116 }
1117 }
1118
1119 fn success_run() -> LakeRun {
1120 LakeRun {
1121 success: true,
1122 status: "exit status: 0".to_owned(),
1123 stdout: Vec::new(),
1124 stderr: Vec::new(),
1125 }
1126 }
1127
1128 fn dylib_ext() -> &'static str {
1129 if cfg!(target_os = "macos") { "dylib" } else { "so" }
1130 }
1131
1132 fn make_project(name: &str, target: &str) -> PathBuf {
1133 let root = std::env::temp_dir().join(format!("lean-toolchain-lake-{}-{}", std::process::id(), name));
1134 drop(fs::remove_dir_all(&root));
1135 fs::create_dir_all(&root).expect("create temp project");
1136 write_file(
1137 &root.join("lakefile.lean"),
1138 &format!(
1139 "import Lake\nopen Lake DSL\npackage «my_pkg»\n@[default_target]\nlean_lib «{target}» where\n defaultFacets := #[LeanLib.sharedFacet]\n"
1140 ),
1141 );
1142 write_file(
1143 &root.join("lake-manifest.json"),
1144 r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[],"name":"my_pkg","lakeDir":".lake"}"#,
1145 );
1146 write_file(&root.join("lean-toolchain"), "leanprover/lean4:v4.29.1\n");
1147 write_file(&root.join(format!("{target}.lean")), "def hello : Nat := 1\n");
1148 root
1149 }
1150
1151 fn make_toml_project(name: &str, target: &str) -> PathBuf {
1152 let root = std::env::temp_dir().join(format!("lean-toolchain-lake-{}-{}", std::process::id(), name));
1153 drop(fs::remove_dir_all(&root));
1154 fs::create_dir_all(&root).expect("create temp project");
1155 write_file(
1156 &root.join("lakefile.toml"),
1157 &format!("name = \"my_pkg\"\ndefaultTargets = [\"{target}\"]\n\n[[lean_lib]]\nname = \"{target}\"\n"),
1158 );
1159 write_file(
1160 &root.join("lake-manifest.json"),
1161 r#"{"version":"1.1.0","packagesDir":".lake/packages","packages":[],"name":"my_pkg","lakeDir":".lake"}"#,
1162 );
1163 write_file(&root.join("lean-toolchain"), "leanprover/lean4:v4.29.1\n");
1164 write_file(&root.join(format!("{target}.lean")), "def hello : Nat := 1\n");
1165 root
1166 }
1167
1168 fn write_file(path: &Path, contents: &str) {
1169 if let Some(parent) = path.parent() {
1170 fs::create_dir_all(parent).expect("create parent");
1171 }
1172 fs::write(path, contents).expect("write file");
1173 }
1174
1175 #[test]
1176 fn cache_hit_skips_lake_invocation() {
1177 let root = make_project("cache-hit", "MyCapability");
1178 let mut runner = FakeLake::new(FakeMode::SuccessModern);
1179 let first = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1180 .expect("first build");
1181 let second = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1182 .expect("cached build");
1183
1184 assert_eq!(first, second);
1185 assert_eq!(runner.calls(), 1, "second call should use cache");
1186 }
1187
1188 #[test]
1189 fn missing_manifest_lets_lake_create_manifest() {
1190 let root = make_project("missing-manifest", "MyCapability");
1191 fs::remove_file(root.join("lake-manifest.json")).expect("remove manifest");
1192 let mut runner = FakeLake::new(FakeMode::SuccessModern);
1193 let path = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1194 .expect("build without checked-in manifest");
1195
1196 assert!(path.ends_with(format!("libmy__pkg_MyCapability.{}", dylib_ext())));
1197 assert_eq!(runner.calls(), 1);
1198 }
1199
1200 #[test]
1201 fn legacy_output_path_is_supported() {
1202 let root = make_project("legacy", "MyCapability");
1203 let mut runner = FakeLake::new(FakeMode::SuccessLegacy);
1204 let path = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1205 .expect("legacy build");
1206
1207 assert!(path.ends_with(format!("libMyCapability.{}", dylib_ext())));
1208 }
1209
1210 #[test]
1211 fn missing_target_is_typed() {
1212 let root = make_project("missing-target", "MyCapability");
1213 let mut runner = FakeLake::new(FakeMode::SuccessModern);
1214 let err = build_lake_target_with_runner(&root, "OtherTarget", &mut runner, CargoMetadata::Emit)
1215 .expect_err("missing target");
1216
1217 match err {
1218 LinkDiagnostics::LakeTargetMissing { target_name, .. } => assert_eq!(target_name, "OtherTarget"),
1219 other => panic!("expected LakeTargetMissing, got {other:?}"),
1220 }
1221 assert_eq!(runner.calls(), 0);
1222 }
1223
1224 #[test]
1225 fn toml_lakefile_build_succeeds() {
1226 let root = make_toml_project("toml-success", "KanProofs");
1227 let mut runner = FakeLake::new(FakeMode::SuccessModern);
1228 let path = build_lake_target_with_runner(&root, "KanProofs", &mut runner, CargoMetadata::Emit)
1229 .expect("TOML lakefile build");
1230
1231 assert!(path.ends_with(format!("libmy__pkg_KanProofs.{}", dylib_ext())));
1232 assert_eq!(runner.calls(), 1);
1233 }
1234
1235 #[test]
1236 fn toml_lakefile_missing_target_is_typed() {
1237 let root = make_toml_project("toml-missing", "KanProofs");
1238 let mut runner = FakeLake::new(FakeMode::SuccessModern);
1239 let err = build_lake_target_with_runner(&root, "OtherTarget", &mut runner, CargoMetadata::Emit)
1240 .expect_err("missing TOML target");
1241
1242 match err {
1243 LinkDiagnostics::LakeTargetMissing { target_name, .. } => assert_eq!(target_name, "OtherTarget"),
1244 other => panic!("expected LakeTargetMissing, got {other:?}"),
1245 }
1246 assert_eq!(runner.calls(), 0);
1247 }
1248
1249 #[test]
1250 fn toml_lakefile_missing_manifest_resolves_package() {
1251 let root = make_toml_project("toml-no-manifest", "KanProofs");
1252 fs::remove_file(root.join("lake-manifest.json")).expect("remove manifest");
1253 let mut runner = FakeLake::new(FakeMode::SuccessModern);
1254 let path = build_lake_target_with_runner(&root, "KanProofs", &mut runner, CargoMetadata::Emit)
1255 .expect("TOML build without manifest");
1256
1257 assert!(path.ends_with(format!("libmy__pkg_KanProofs.{}", dylib_ext())));
1258 assert_eq!(runner.calls(), 1);
1259 }
1260
1261 #[test]
1262 fn build_failure_is_typed_and_one_line() {
1263 let root = make_project("failure", "MyCapability");
1264 let mut runner = FakeLake::new(FakeMode::Failure);
1265 let err = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1266 .expect_err("failure");
1267 let rendered = format!("{err}");
1268
1269 match err {
1270 LinkDiagnostics::LakeBuildFailed { detail, .. } => {
1271 assert!(detail.contains("stderr detail"));
1272 assert!(detail.contains("stdout detail"));
1273 assert!(!detail.contains('\n'));
1274 }
1275 other => panic!("expected LakeBuildFailed, got {other:?}"),
1276 }
1277 assert!(!rendered.contains('\n'));
1278 }
1279
1280 #[test]
1281 fn missing_lake_is_typed() {
1282 let root = make_project("spawn-error", "MyCapability");
1283 let mut runner = FakeLake::new(FakeMode::SpawnError);
1284 let err = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Emit)
1285 .expect_err("spawn error");
1286
1287 match err {
1288 LinkDiagnostics::LakeUnavailable {
1289 target_name, detail, ..
1290 } => {
1291 assert_eq!(target_name, "MyCapability");
1292 assert!(detail.contains("lake missing"));
1293 }
1294 other => panic!("expected LakeUnavailable, got {other:?}"),
1295 }
1296 assert_eq!(runner.calls(), 1);
1297 }
1298
1299 #[test]
1300 fn cache_hit_skips_lake_invocation_for_interop_dependency_shape() {
1301 let root = make_project("interop-cache-hit", "InteropConsumer");
1302 write_file(
1303 &root.join("lakefile.lean"),
1304 "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",
1305 );
1306 write_file(
1307 &root.join("lake-manifest.json"),
1308 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"}"#,
1309 );
1310 let mut runner = FakeLake::new(FakeMode::SuccessModern);
1311
1312 let first = build_lake_target_with_runner(&root, "InteropConsumer", &mut runner, CargoMetadata::Emit)
1313 .expect("first build");
1314 let second = build_lake_target_with_runner(&root, "InteropConsumer", &mut runner, CargoMetadata::Emit)
1315 .expect("cached build");
1316
1317 assert_eq!(first, second);
1318 assert_eq!(runner.calls(), 1, "second call should use cache");
1319 }
1320
1321 #[test]
1322 fn command_detail_is_bounded() {
1323 let detail = command_detail(&vec![b'x'; 4096], b"");
1324 assert!(detail.len() <= 1027);
1325 assert!(detail.ends_with("..."));
1326 }
1327
1328 #[test]
1329 fn capability_env_var_is_deterministic() {
1330 assert_eq!(
1331 capability_env_var("MyCapability"),
1332 "LEAN_RS_CAPABILITY_MY_CAPABILITY_DYLIB"
1333 );
1334 assert_eq!(
1335 capability_env_var("lean-dup_index"),
1336 "LEAN_RS_CAPABILITY_LEAN_DUP_INDEX_DYLIB"
1337 );
1338 }
1339
1340 #[test]
1341 fn capability_manifest_env_var_is_deterministic() {
1342 assert_eq!(
1343 capability_manifest_env_var("MyCapability"),
1344 "LEAN_RS_CAPABILITY_MY_CAPABILITY_MANIFEST"
1345 );
1346 assert_eq!(
1347 capability_manifest_env_var("lean-dup_index"),
1348 "LEAN_RS_CAPABILITY_LEAN_DUP_INDEX_MANIFEST"
1349 );
1350 }
1351
1352 #[test]
1353 fn cargo_capability_build_quiet_returns_metadata() {
1354 let root = make_project("cargo-capability", "MyCapability");
1355 let mut runner = FakeLake::new(FakeMode::SuccessModern);
1356 let dylib = build_lake_target_with_runner(&root, "MyCapability", &mut runner, CargoMetadata::Suppress)
1357 .expect("build target");
1358 let built = CargoLeanCapability::new(&root, "MyCapability")
1359 .package("my_pkg")
1360 .module("MyCapability")
1361 .env_var("MY_CAPABILITY_DYLIB")
1362 .manifest_env_var("MY_CAPABILITY_MANIFEST")
1363 .build_quiet()
1364 .expect("cargo helper build");
1365
1366 assert_eq!(built.dylib_path(), dylib.as_path());
1367 assert_eq!(built.env_var(), "MY_CAPABILITY_DYLIB");
1368 assert_eq!(built.manifest_env_var(), "MY_CAPABILITY_MANIFEST");
1369 assert!(built.manifest_path().is_file());
1370 assert_eq!(built.package(), "my_pkg");
1371 assert_eq!(built.module(), "MyCapability");
1372 assert_eq!(built.target_name(), "MyCapability");
1373 assert!(built.project_root().is_absolute());
1374
1375 let manifest: serde_json::Value =
1376 serde_json::from_slice(&fs::read(built.manifest_path()).expect("read manifest"))
1377 .expect("manifest is valid JSON");
1378 assert_eq!(
1379 manifest.get("schema_version").and_then(serde_json::Value::as_u64),
1380 Some(u64::from(CAPABILITY_MANIFEST_SCHEMA_VERSION)),
1381 );
1382 assert_eq!(
1383 manifest.get("package").and_then(serde_json::Value::as_str),
1384 Some("my_pkg")
1385 );
1386 assert_eq!(
1387 manifest.get("module").and_then(serde_json::Value::as_str),
1388 Some("MyCapability")
1389 );
1390 assert_eq!(
1391 manifest
1392 .get("primary_dylib")
1393 .and_then(serde_json::Value::as_str)
1394 .map(Path::new),
1395 Some(dylib.as_path()),
1396 );
1397 assert!(manifest.get("toolchain_fingerprint").is_some());
1398 }
1399}