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