1use std::fmt;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::time::UNIX_EPOCH;
10
11use sha2::{Digest, Sha256};
12
13use crate::ToolchainFingerprint;
14use crate::lakefile_toml::parse_lakefile_toml;
15
16#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct LeanModuleDescriptor {
19 pub module: String,
20 pub path: PathBuf,
21 pub source_root: String,
22}
23
24#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct LeanModuleSetFingerprint {
27 pub toolchain: ToolchainFingerprint,
28 pub lakefile_sha256: String,
29 pub manifest_sha256: Option<String>,
30 pub source_count: u64,
31 pub source_max_mtime_ns: u128,
32}
33
34#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct LeanLakeProjectModules {
37 pub requested_root: PathBuf,
38 pub project_root: PathBuf,
39 pub lakefile: PathBuf,
40 pub declared_lean_libs: Vec<String>,
47 pub module_roots: Vec<String>,
48 pub selected_roots: Vec<String>,
49 pub modules: Vec<LeanModuleDescriptor>,
50 pub fingerprint: LeanModuleSetFingerprint,
51}
52
53#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct LeanModuleDiscoveryOptions {
56 requested_root: PathBuf,
57 selected_roots: Option<Vec<String>>,
58 toolchain: ToolchainFingerprint,
59}
60
61impl LeanModuleDiscoveryOptions {
62 #[must_use]
64 pub fn new(requested_root: impl Into<PathBuf>) -> Self {
65 Self {
66 requested_root: requested_root.into(),
67 selected_roots: None,
68 toolchain: ToolchainFingerprint::current(),
69 }
70 }
71
72 #[must_use]
74 pub fn selected_roots(mut self, roots: impl IntoIterator<Item = impl Into<String>>) -> Self {
75 self.selected_roots = Some(roots.into_iter().map(Into::into).collect());
76 self
77 }
78
79 #[must_use]
84 pub fn toolchain(mut self, toolchain: ToolchainFingerprint) -> Self {
85 self.toolchain = toolchain;
86 self
87 }
88}
89
90#[derive(Debug)]
92pub enum LeanModuleDiscoveryDiagnostic {
93 MissingLakeRoot {
94 requested_root: PathBuf,
95 },
96 MissingModuleRoot {
97 project_root: PathBuf,
98 module_root: String,
99 },
100 InvalidModuleName {
101 module: String,
102 reason: String,
103 },
104 UnsupportedToolchain {
105 active: String,
106 supported_window: String,
107 },
108 Io {
109 path: PathBuf,
110 message: &'static str,
111 source: std::io::Error,
112 },
113}
114
115impl fmt::Display for LeanModuleDiscoveryDiagnostic {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 match self {
118 Self::MissingLakeRoot { requested_root } => {
119 write!(
120 f,
121 "lean-toolchain: no Lake project found at {} or {}/lean",
122 requested_root.display(),
123 requested_root.display()
124 )
125 }
126 Self::MissingModuleRoot {
127 project_root,
128 module_root,
129 } => {
130 write!(
131 f,
132 "lean-toolchain: module root `{module_root}` was not found in {}",
133 project_root.display()
134 )
135 }
136 Self::InvalidModuleName { module, reason } => {
137 write!(f, "lean-toolchain: invalid Lean module name `{module}`: {reason}")
138 }
139 Self::UnsupportedToolchain {
140 active,
141 supported_window,
142 } => {
143 write!(
144 f,
145 "lean-toolchain: active Lean toolchain {active} is not in the supported window: {supported_window}"
146 )
147 }
148 Self::Io { path, message, source } => {
149 write!(f, "lean-toolchain: {message} at {}: {source}", path.display())
150 }
151 }
152 }
153}
154
155impl std::error::Error for LeanModuleDiscoveryDiagnostic {
156 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
157 match self {
158 Self::Io { source, .. } => Some(source),
159 Self::MissingLakeRoot { .. }
160 | Self::MissingModuleRoot { .. }
161 | Self::InvalidModuleName { .. }
162 | Self::UnsupportedToolchain { .. } => None,
163 }
164 }
165}
166
167pub fn discover_lake_modules(
175 options: LeanModuleDiscoveryOptions,
176) -> Result<LeanLakeProjectModules, LeanModuleDiscoveryDiagnostic> {
177 if !options.toolchain.is_supported() {
178 return Err(LeanModuleDiscoveryDiagnostic::UnsupportedToolchain {
179 active: options.toolchain.lean_version.to_owned(),
180 supported_window: supported_window(),
181 });
182 }
183
184 let requested_root = normalize_existing(&options.requested_root)?;
185 let (project_root, lakefile) = lake_root_for(&requested_root)?;
186 let (declared_lean_libs, module_roots) = discover_module_roots(&project_root, &lakefile)?;
187 let selected_roots = options.selected_roots.unwrap_or_else(|| module_roots.clone());
188 for root in &selected_roots {
189 validate_module_name(root)?;
190 if !module_root_exists(&project_root, root) {
191 return Err(LeanModuleDiscoveryDiagnostic::MissingModuleRoot {
192 project_root,
193 module_root: root.clone(),
194 });
195 }
196 }
197
198 let modules = enumerate_sources(&project_root, &selected_roots)?;
199 let fingerprint = fingerprint_source_set(&project_root, &lakefile, &modules, options.toolchain)?;
200 Ok(LeanLakeProjectModules {
201 requested_root,
202 project_root,
203 lakefile,
204 declared_lean_libs,
205 module_roots,
206 selected_roots,
207 modules,
208 fingerprint,
209 })
210}
211
212fn normalize_existing(path: &Path) -> Result<PathBuf, LeanModuleDiscoveryDiagnostic> {
213 let expanded = expand_home(path);
214 if !expanded.exists() {
215 return Err(LeanModuleDiscoveryDiagnostic::MissingLakeRoot {
216 requested_root: expanded,
217 });
218 }
219 fs::canonicalize(&expanded).map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
220 path: expanded,
221 message: "could not canonicalize path",
222 source,
223 })
224}
225
226fn expand_home(path: &Path) -> PathBuf {
227 let Some(text) = path.to_str() else {
228 return path.to_path_buf();
229 };
230 if text == "~" {
231 return home_dir().unwrap_or_else(|| path.to_path_buf());
232 }
233 if let Some(rest) = text.strip_prefix("~/")
234 && let Some(home) = home_dir()
235 {
236 return home.join(rest);
237 }
238 path.to_path_buf()
239}
240
241fn home_dir() -> Option<PathBuf> {
242 std::env::var_os("HOME").map(PathBuf::from)
243}
244
245fn lake_root_for(path: &Path) -> Result<(PathBuf, PathBuf), LeanModuleDiscoveryDiagnostic> {
246 if let Some(lakefile) = lakefile_path(path) {
247 return Ok((path.to_path_buf(), lakefile));
248 }
249 let nested = path.join("lean");
250 if let Some(lakefile) = lakefile_path(&nested) {
251 return Ok((nested, lakefile));
252 }
253 Err(LeanModuleDiscoveryDiagnostic::MissingLakeRoot {
254 requested_root: path.to_path_buf(),
255 })
256}
257
258fn lakefile_path(root: &Path) -> Option<PathBuf> {
259 let toml = root.join("lakefile.toml");
260 if toml.is_file() {
261 return Some(toml);
262 }
263 let lean = root.join("lakefile.lean");
264 lean.is_file().then_some(lean)
265}
266
267fn discover_module_roots(
281 project_root: &Path,
282 lakefile: &Path,
283) -> Result<(Vec<String>, Vec<String>), LeanModuleDiscoveryDiagnostic> {
284 let mut declared = if lakefile.file_name().and_then(|name| name.to_str()) == Some("lakefile.toml") {
285 discover_toml_lakefile_roots(lakefile)?
286 } else {
287 discover_lean_lakefile_roots(lakefile)?
288 };
289 declared.sort();
290 declared.dedup();
291 for root in &declared {
292 validate_module_name(root)?;
293 }
294
295 let module_roots = if declared.is_empty() {
296 let mut fallback = discover_top_level_roots(project_root)?;
297 fallback.sort();
298 fallback.dedup();
299 for root in &fallback {
300 validate_module_name(root)?;
301 }
302 fallback
303 } else {
304 declared.clone()
305 };
306
307 Ok((declared, module_roots))
308}
309
310fn discover_toml_lakefile_roots(lakefile: &Path) -> Result<Vec<String>, LeanModuleDiscoveryDiagnostic> {
311 let text = read_to_string(lakefile, "could not read Lake TOML file")?;
312 let parsed = parse_lakefile_toml(&text).map_err(|err| LeanModuleDiscoveryDiagnostic::Io {
313 path: lakefile.to_path_buf(),
314 message: "could not parse Lake TOML file",
315 source: std::io::Error::other(err.to_string()),
316 })?;
317 Ok(parsed.lean_libs)
318}
319
320fn discover_lean_lakefile_roots(lakefile: &Path) -> Result<Vec<String>, LeanModuleDiscoveryDiagnostic> {
321 let text = read_to_string(lakefile, "could not read Lake file")?;
322 Ok(text
323 .lines()
324 .filter_map(|line| {
325 let trimmed = line.trim_start();
326 let rest = trimmed.strip_prefix("lean_lib ")?;
327 let raw = rest.split_whitespace().next()?;
328 let root = normalize_lake_identifier(raw);
329 (!root.is_empty()).then_some(root)
330 })
331 .collect())
332}
333
334fn discover_top_level_roots(project_root: &Path) -> Result<Vec<String>, LeanModuleDiscoveryDiagnostic> {
335 let mut roots = Vec::new();
336 for entry in fs::read_dir(project_root).map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
337 path: project_root.to_path_buf(),
338 message: "could not read Lake project directory",
339 source,
340 })? {
341 let entry = entry.map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
342 path: project_root.to_path_buf(),
343 message: "could not read Lake project directory entry",
344 source,
345 })?;
346 let path = entry.path();
347 if path.extension().and_then(|extension| extension.to_str()) == Some("lean")
348 && let Some(stem) = path.file_stem().and_then(|stem| stem.to_str())
349 {
350 roots.push(stem.to_owned());
351 }
352 }
353 Ok(roots)
354}
355
356fn module_root_exists(project_root: &Path, module_root: &str) -> bool {
357 module_to_file(project_root, module_root).is_file() || module_to_dir(project_root, module_root).is_dir()
358}
359
360fn enumerate_sources(
361 project_root: &Path,
362 selected_roots: &[String],
363) -> Result<Vec<LeanModuleDescriptor>, LeanModuleDiscoveryDiagnostic> {
364 let mut modules = std::collections::BTreeMap::<String, LeanModuleDescriptor>::new();
365 for source_root in selected_roots {
366 let root_file = module_to_file(project_root, source_root);
367 if root_file.is_file() {
368 modules.insert(
369 source_root.clone(),
370 LeanModuleDescriptor {
371 module: source_root.clone(),
372 path: root_file,
373 source_root: source_root.clone(),
374 },
375 );
376 }
377
378 let module_dir = module_to_dir(project_root, source_root);
379 if module_dir.is_dir() {
380 collect_sources(project_root, &module_dir, source_root, &mut modules)?;
381 }
382 }
383 Ok(modules.into_values().collect())
384}
385
386fn collect_sources(
387 project_root: &Path,
388 dir: &Path,
389 source_root: &str,
390 modules: &mut std::collections::BTreeMap<String, LeanModuleDescriptor>,
391) -> Result<(), LeanModuleDiscoveryDiagnostic> {
392 for entry in fs::read_dir(dir).map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
393 path: dir.to_path_buf(),
394 message: "could not read Lean source directory",
395 source,
396 })? {
397 let entry = entry.map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
398 path: dir.to_path_buf(),
399 message: "could not read Lean source directory entry",
400 source,
401 })?;
402 let path = entry.path();
403 let metadata = entry.metadata().map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
404 path: path.clone(),
405 message: "could not stat Lean source path",
406 source,
407 })?;
408 if metadata.is_dir() {
409 collect_sources(project_root, &path, source_root, modules)?;
410 } else if path.extension().and_then(|extension| extension.to_str()) == Some("lean") {
411 let module = module_from_path(project_root, &path)?;
412 validate_module_name(&module)?;
413 modules.insert(
414 module.clone(),
415 LeanModuleDescriptor {
416 module,
417 path,
418 source_root: source_root.to_owned(),
419 },
420 );
421 }
422 }
423 Ok(())
424}
425
426fn fingerprint_source_set(
427 project_root: &Path,
428 lakefile: &Path,
429 modules: &[LeanModuleDescriptor],
430 toolchain: ToolchainFingerprint,
431) -> Result<LeanModuleSetFingerprint, LeanModuleDiscoveryDiagnostic> {
432 let lakefile_sha256 = sha256_file(lakefile)?;
433 let manifest = project_root.join("lake-manifest.json");
434 let manifest_sha256 = if manifest.is_file() {
435 Some(sha256_file(&manifest)?)
436 } else {
437 None
438 };
439 let mut source_max_mtime_ns = 0_u128;
440 for module in modules {
441 let metadata = fs::metadata(&module.path).map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
442 path: module.path.clone(),
443 message: "could not stat Lean module source",
444 source,
445 })?;
446 let modified = metadata
447 .modified()
448 .map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
449 path: module.path.clone(),
450 message: "could not read Lean module source mtime",
451 source,
452 })?;
453 let mtime_ns = modified
454 .duration_since(UNIX_EPOCH)
455 .map_or(0, |duration| duration.as_nanos());
456 source_max_mtime_ns = source_max_mtime_ns.max(mtime_ns);
457 }
458 Ok(LeanModuleSetFingerprint {
459 toolchain,
460 lakefile_sha256,
461 manifest_sha256,
462 source_count: modules.len() as u64,
463 source_max_mtime_ns,
464 })
465}
466
467fn sha256_file(path: &Path) -> Result<String, LeanModuleDiscoveryDiagnostic> {
468 fs::read(path)
469 .map(|bytes| sha256_hex(&bytes))
470 .map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
471 path: path.to_path_buf(),
472 message: "could not read file for fingerprinting",
473 source,
474 })
475}
476
477fn sha256_hex(bytes: &[u8]) -> String {
478 let mut hasher = Sha256::new();
479 hasher.update(bytes);
480 let digest = hasher.finalize();
481 let mut out = String::with_capacity(digest.len().saturating_mul(2));
482 for byte in digest {
483 use std::fmt::Write as _;
484 if write!(out, "{byte:02x}").is_err() {
485 return out;
486 }
487 }
488 out
489}
490
491fn module_from_path(project_root: &Path, path: &Path) -> Result<String, LeanModuleDiscoveryDiagnostic> {
492 let relative = path
493 .strip_prefix(project_root)
494 .map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
495 path: path.to_path_buf(),
496 message: "could not relativize Lean source path",
497 source: std::io::Error::other(source),
498 })?;
499 let mut parts: Vec<String> = relative
500 .components()
501 .map(|component| component.as_os_str().to_string_lossy().into_owned())
502 .collect();
503 if let Some(last) = parts.last_mut()
504 && let Some(stripped) = last.strip_suffix(".lean")
505 {
506 *last = stripped.to_owned();
507 }
508 Ok(parts.join("."))
509}
510
511fn module_to_file(project_root: &Path, module: &str) -> PathBuf {
512 let mut path = module_to_dir(project_root, module);
513 path.set_extension("lean");
514 path
515}
516
517fn module_to_dir(project_root: &Path, module: &str) -> PathBuf {
518 let mut path = project_root.to_path_buf();
519 for part in module.split('.') {
520 path.push(part);
521 }
522 path
523}
524
525fn normalize_lake_identifier(raw: &str) -> String {
526 raw.trim()
527 .trim_matches('`')
528 .trim_matches('«')
529 .trim_matches('»')
530 .trim_matches('"')
531 .trim()
532 .to_owned()
533}
534
535fn validate_module_name(module: &str) -> Result<(), LeanModuleDiscoveryDiagnostic> {
536 if module.is_empty() {
537 return Err(LeanModuleDiscoveryDiagnostic::InvalidModuleName {
538 module: module.to_owned(),
539 reason: "module name is empty".to_owned(),
540 });
541 }
542 for component in module.split('.') {
543 if component.is_empty() {
544 return Err(LeanModuleDiscoveryDiagnostic::InvalidModuleName {
545 module: module.to_owned(),
546 reason: "module name contains an empty component".to_owned(),
547 });
548 }
549 let mut chars = component.chars();
550 let Some(first) = chars.next() else {
551 return Err(LeanModuleDiscoveryDiagnostic::InvalidModuleName {
552 module: module.to_owned(),
553 reason: "module name contains an empty component".to_owned(),
554 });
555 };
556 if !(first == '_' || first.is_alphabetic()) {
557 return Err(LeanModuleDiscoveryDiagnostic::InvalidModuleName {
558 module: module.to_owned(),
559 reason: "module components must begin with a letter or underscore".to_owned(),
560 });
561 }
562 if chars.any(|ch| !(ch == '_' || ch == '\'' || ch.is_alphanumeric())) {
563 return Err(LeanModuleDiscoveryDiagnostic::InvalidModuleName {
564 module: module.to_owned(),
565 reason: "module components may contain only letters, digits, underscores, or apostrophes".to_owned(),
566 });
567 }
568 }
569 Ok(())
570}
571
572fn read_to_string(path: &Path, message: &'static str) -> Result<String, LeanModuleDiscoveryDiagnostic> {
573 fs::read_to_string(path).map_err(|source| LeanModuleDiscoveryDiagnostic::Io {
574 path: path.to_path_buf(),
575 message,
576 source,
577 })
578}
579
580fn supported_window() -> String {
581 lean_rs_sys::SUPPORTED_TOOLCHAINS
582 .iter()
583 .map(|entry| format!("{:?}", entry.versions))
584 .collect::<Vec<_>>()
585 .join(", ")
586}
587
588#[cfg(test)]
589#[allow(
590 clippy::expect_used,
591 clippy::panic,
592 clippy::unwrap_used,
593 clippy::wildcard_enum_match_arm
594)]
595mod tests {
596 use super::{LeanModuleDiscoveryDiagnostic, LeanModuleDiscoveryOptions, discover_lake_modules};
597 use crate::ToolchainFingerprint;
598 use std::fs;
599 use std::path::{Path, PathBuf};
600
601 #[test]
602 fn discovers_lean_lakefile_modules_deterministically() {
603 let root = temp_project("lean-lakefile");
604 write_file(&root.join("lakefile.lean"), "package demo\nlean_lib Demo where\n");
605 write_file(&root.join("Demo.lean"), "#check Nat\n");
606 fs::create_dir(root.join("Demo")).unwrap();
607 write_file(&root.join("Demo").join("B.lean"), "#check String\n");
608
609 let first = discover_lake_modules(LeanModuleDiscoveryOptions::new(&root)).unwrap();
610 let second = discover_lake_modules(LeanModuleDiscoveryOptions::new(&root)).unwrap();
611
612 assert_eq!(first.modules, second.modules);
613 assert_eq!(module_names(&first), vec!["Demo", "Demo.B"]);
614 assert_eq!(first.module_roots, vec!["Demo"]);
615 assert_eq!(first.fingerprint.source_count, 2);
616 }
617
618 #[test]
619 fn discovers_toml_lakefile_roots() {
620 let root = temp_project("toml-lakefile");
621 write_file(
622 &root.join("lakefile.toml"),
623 r#"
624name = "demo"
625[[lean_lib]]
626name = "Demo"
627[[lean_lib]]
628name = "Other"
629"#,
630 );
631 write_file(&root.join("Demo.lean"), "#check Nat\n");
632 write_file(&root.join("Other.lean"), "#check String\n");
633
634 let project = discover_lake_modules(LeanModuleDiscoveryOptions::new(&root)).unwrap();
635 assert_eq!(project.module_roots, vec!["Demo", "Other"]);
636 assert_eq!(module_names(&project), vec!["Demo", "Other"]);
637 }
638
639 #[test]
640 fn missing_selected_root_is_typed() {
641 let root = temp_project("missing-root");
642 write_file(&root.join("lakefile.lean"), "package demo\nlean_lib Demo where\n");
643 write_file(&root.join("Demo.lean"), "#check Nat\n");
644
645 let err = discover_lake_modules(LeanModuleDiscoveryOptions::new(&root).selected_roots(["Missing"]))
646 .expect_err("missing selected root should be typed");
647 match err {
648 LeanModuleDiscoveryDiagnostic::MissingModuleRoot { module_root, .. } => {
649 assert_eq!(module_root, "Missing");
650 }
651 other => panic!("expected missing module root, got {other:?}"),
652 }
653 }
654
655 #[test]
656 fn invalid_module_name_is_typed() {
657 let root = temp_project("invalid-module");
658 write_file(&root.join("lakefile.lean"), "package demo\nlean_lib Demo-Bad where\n");
659 write_file(&root.join("Demo-Bad.lean"), "#check Nat\n");
660
661 let err = discover_lake_modules(LeanModuleDiscoveryOptions::new(&root))
662 .expect_err("invalid Lake module name should be typed");
663 match err {
664 LeanModuleDiscoveryDiagnostic::InvalidModuleName { module, .. } => {
665 assert_eq!(module, "Demo-Bad");
666 }
667 other => panic!("expected invalid module name, got {other:?}"),
668 }
669 }
670
671 #[test]
672 fn unsupported_toolchain_is_typed() {
673 let root = temp_project("unsupported-toolchain");
674 write_file(&root.join("lakefile.lean"), "package demo\nlean_lib Demo where\n");
675 write_file(&root.join("Demo.lean"), "#check Nat\n");
676 let mut fingerprint = ToolchainFingerprint::current();
677 fingerprint.lean_version = "0.0.0-test";
678
679 let err = discover_lake_modules(LeanModuleDiscoveryOptions::new(&root).toolchain(fingerprint))
680 .expect_err("unsupported toolchain should be typed");
681 assert!(matches!(
682 err,
683 LeanModuleDiscoveryDiagnostic::UnsupportedToolchain { .. }
684 ));
685 }
686
687 #[test]
688 fn declared_lean_libs_reflects_lakefile() {
689 let root = temp_project("declared-lean-libs");
690 write_file(
691 &root.join("lakefile.lean"),
692 "package demo\nlean_lib Demo where\nlean_lib Extra where\n",
693 );
694 write_file(&root.join("Demo.lean"), "#check Nat\n");
695 write_file(&root.join("Extra.lean"), "#check Nat\n");
696
697 let project = discover_lake_modules(LeanModuleDiscoveryOptions::new(&root)).unwrap();
698 assert_eq!(project.declared_lean_libs, vec!["Demo", "Extra"]);
699 }
700
701 #[test]
702 fn declared_lean_libs_reflects_toml_lakefile() {
703 let root = temp_project("declared-toml-libs");
704 write_file(
705 &root.join("lakefile.toml"),
706 r#"
707name = "demo"
708[[lean_lib]]
709name = "KanProofs"
710[[lean_lib]]
711name = "Other"
712"#,
713 );
714 write_file(&root.join("KanProofs.lean"), "#check Nat\n");
715 write_file(&root.join("Other.lean"), "#check Nat\n");
716
717 let project = discover_lake_modules(LeanModuleDiscoveryOptions::new(&root)).unwrap();
718 assert_eq!(project.declared_lean_libs, vec!["KanProofs", "Other"]);
719 }
720
721 #[test]
722 fn declared_lean_libs_empty_when_top_level_fallback_active() {
723 let root = temp_project("declared-fallback");
728 write_file(&root.join("lakefile.lean"), "package demo\n");
729 write_file(&root.join("Loose.lean"), "#check Nat\n");
730
731 let project = discover_lake_modules(LeanModuleDiscoveryOptions::new(&root)).unwrap();
732 assert!(project.declared_lean_libs.is_empty());
733 assert!(project.module_roots.iter().any(|root| root == "Loose"));
734 }
735
736 fn module_names(project: &super::LeanLakeProjectModules) -> Vec<&str> {
737 project.modules.iter().map(|module| module.module.as_str()).collect()
738 }
739
740 fn temp_project(name: &str) -> PathBuf {
741 let root = std::env::temp_dir().join(format!("lean-toolchain-modules-{name}-{}", std::process::id()));
742 if root.exists() {
743 fs::remove_dir_all(&root).unwrap();
744 }
745 fs::create_dir_all(&root).unwrap();
746 root
747 }
748
749 fn write_file(path: &Path, contents: &str) {
750 if let Some(parent) = path.parent() {
751 fs::create_dir_all(parent).unwrap();
752 }
753 fs::write(path, contents).unwrap();
754 }
755}