Skip to main content

lean_toolchain/
modules.rs

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