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