Skip to main content

lean_rs_worker_parent/
planning.rs

1//! Import-set planning for worker-pool execution.
2//!
3//! The planner groups module work into stable worker session batches. It does
4//! not choose workers, run commands, or define downstream cache semantics.
5
6use std::collections::BTreeMap;
7use std::fmt;
8use std::path::PathBuf;
9
10use lean_toolchain::{
11    LeanLakeProjectModules, LeanModuleDescriptor, LeanModuleDiscoveryDiagnostic, LeanModuleDiscoveryOptions,
12    LeanModuleSetFingerprint, ToolchainFingerprint, discover_lake_modules,
13};
14use serde_json::Value;
15
16use lean_rs_worker_protocol::types::{LeanWorkerCapabilityMetadata, LeanWorkerSessionImportProfile};
17
18use crate::capability::LeanWorkerCapabilityBuilder;
19use crate::pool::{LeanWorkerRestartPolicyClass, LeanWorkerSessionKey};
20use crate::supervisor::LeanWorkerRestartPolicy;
21
22/// Capability and session requirements used to plan worker batches.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct LeanWorkerImportPlanConfig {
25    project_root: PathBuf,
26    package: String,
27    lib_name: String,
28    source_roots: Option<Vec<String>>,
29    base_imports: Vec<String>,
30    import_profile: LeanWorkerSessionImportProfile,
31    metadata_expectation: Option<LeanWorkerPlanMetadataExpectation>,
32    restart_policy: Option<LeanWorkerRestartPolicy>,
33}
34
35impl LeanWorkerImportPlanConfig {
36    /// Create planner configuration for a Lake capability target.
37    #[must_use]
38    pub fn new(project_root: impl Into<PathBuf>, package: impl Into<String>, lib_name: impl Into<String>) -> Self {
39        Self {
40            project_root: project_root.into(),
41            package: package.into(),
42            lib_name: lib_name.into(),
43            source_roots: None,
44            base_imports: Vec::new(),
45            import_profile: LeanWorkerSessionImportProfile::default(),
46            metadata_expectation: None,
47            restart_policy: None,
48        }
49    }
50
51    /// Restrict discovery to these source roots.
52    #[must_use]
53    pub fn source_roots(mut self, roots: impl IntoIterator<Item = impl Into<String>>) -> Self {
54        self.source_roots = Some(roots.into_iter().map(Into::into).collect());
55        self
56    }
57
58    /// Add imports required by every planned session batch.
59    #[must_use]
60    pub fn base_imports(mut self, imports: impl IntoIterator<Item = impl Into<String>>) -> Self {
61        self.base_imports = imports.into_iter().map(Into::into).collect();
62        self
63    }
64
65    /// Select the full-session import profile used by planned worker batches.
66    #[must_use]
67    pub fn import_profile(mut self, profile: LeanWorkerSessionImportProfile) -> Self {
68        self.import_profile = profile;
69        self
70    }
71
72    /// Validate metadata before a worker batch is used.
73    #[must_use]
74    pub fn validate_metadata(mut self, export: impl Into<String>, request: Value) -> Self {
75        self.metadata_expectation = Some(LeanWorkerPlanMetadataExpectation {
76            export: export.into(),
77            request,
78            expected: None,
79        });
80        self
81    }
82
83    /// Require exact generic capability metadata before a worker batch is used.
84    #[must_use]
85    pub fn expect_metadata(
86        mut self,
87        export: impl Into<String>,
88        request: Value,
89        expected: LeanWorkerCapabilityMetadata,
90    ) -> Self {
91        self.metadata_expectation = Some(LeanWorkerPlanMetadataExpectation {
92            export: export.into(),
93            request,
94            expected: Some(expected),
95        });
96        self
97    }
98
99    /// Use a worker restart policy for builders derived from planned batches.
100    #[must_use]
101    pub fn restart_policy(mut self, policy: LeanWorkerRestartPolicy) -> Self {
102        self.restart_policy = Some(policy);
103        self
104    }
105}
106
107/// Metadata expectation carried into planned session keys.
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct LeanWorkerPlanMetadataExpectation {
110    pub export: String,
111    pub request: Value,
112    pub expected: Option<LeanWorkerCapabilityMetadata>,
113}
114
115/// One module-sized unit of planned worker work.
116#[derive(Clone, Debug, Eq, PartialEq)]
117pub struct LeanWorkerModuleWork {
118    pub module: String,
119    pub path: PathBuf,
120    pub source_root: String,
121    pub imports: Vec<String>,
122}
123
124impl LeanWorkerModuleWork {
125    /// Create one module work item with explicit imports.
126    #[must_use]
127    pub fn new(
128        module: impl Into<String>,
129        path: impl Into<PathBuf>,
130        source_root: impl Into<String>,
131        imports: impl IntoIterator<Item = impl Into<String>>,
132    ) -> Self {
133        Self {
134            module: module.into(),
135            path: path.into(),
136            source_root: source_root.into(),
137            imports: imports.into_iter().map(Into::into).collect(),
138        }
139    }
140
141    fn from_descriptor(descriptor: &LeanModuleDescriptor, imports: &[String]) -> Self {
142        Self {
143            module: descriptor.module.clone(),
144            path: descriptor.path.clone(),
145            source_root: descriptor.source_root.clone(),
146            imports: imports.to_vec(),
147        }
148    }
149}
150
151/// One stable pool-execution batch.
152#[derive(Clone, Debug, Eq, PartialEq)]
153pub struct LeanWorkerPlannedBatch {
154    pub session_key: LeanWorkerSessionKey,
155    pub project_root: PathBuf,
156    pub package: String,
157    pub lib_name: String,
158    pub source_root: String,
159    pub imports: Vec<String>,
160    pub import_profile: LeanWorkerSessionImportProfile,
161    pub modules: Vec<LeanWorkerModuleWork>,
162    pub fingerprint: LeanWorkerBatchFingerprint,
163    metadata_expectation: Option<LeanWorkerPlanMetadataExpectation>,
164    restart_policy: Option<LeanWorkerRestartPolicy>,
165}
166
167impl LeanWorkerPlannedBatch {
168    /// Create a capability builder for this batch.
169    ///
170    /// The caller may still add packaging-specific details such as an explicit
171    /// worker executable. The batch supplies the stable session material.
172    #[must_use]
173    pub fn capability_builder(&self) -> LeanWorkerCapabilityBuilder {
174        let mut builder = LeanWorkerCapabilityBuilder::new(
175            self.project_root.clone(),
176            self.package.clone(),
177            self.lib_name.clone(),
178            self.imports.clone(),
179        )
180        .import_profile(self.import_profile);
181        if let Some(policy) = &self.restart_policy {
182            builder = builder.restart_policy(policy.clone());
183        }
184        if let Some(expectation) = &self.metadata_expectation {
185            builder = match &expectation.expected {
186                Some(expected) => builder.expect_metadata(
187                    expectation.export.clone(),
188                    expectation.request.clone(),
189                    expected.clone(),
190                ),
191                None => builder.validate_metadata(expectation.export.clone(), expectation.request.clone()),
192            };
193        }
194        builder
195    }
196}
197
198/// Stable cache-key-relevant facts for a planned batch.
199#[derive(Clone, Debug, Eq, PartialEq)]
200pub struct LeanWorkerBatchFingerprint {
201    pub toolchain: ToolchainFingerprint,
202    pub source_set: LeanModuleSetFingerprint,
203    pub batch_key: String,
204}
205
206/// Import planning diagnostics.
207#[derive(Debug)]
208pub enum LeanWorkerImportPlanError {
209    ModuleDiscovery { diagnostic: LeanModuleDiscoveryDiagnostic },
210    InvalidModuleName { module: String, reason: String },
211    UnresolvedCapabilityTarget { project_root: PathBuf, target_name: String },
212}
213
214impl fmt::Display for LeanWorkerImportPlanError {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        match self {
217            Self::ModuleDiscovery { diagnostic } => write!(f, "{diagnostic}"),
218            Self::InvalidModuleName { module, reason } => {
219                write!(f, "lean-rs-worker: invalid module `{module}` in import plan: {reason}")
220            }
221            Self::UnresolvedCapabilityTarget {
222                project_root,
223                target_name,
224            } => {
225                write!(
226                    f,
227                    "lean-rs-worker: capability target `{target_name}` is not declared in {}",
228                    project_root.display()
229                )
230            }
231        }
232    }
233}
234
235impl std::error::Error for LeanWorkerImportPlanError {
236    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
237        match self {
238            Self::ModuleDiscovery { diagnostic } => Some(diagnostic),
239            Self::InvalidModuleName { .. } | Self::UnresolvedCapabilityTarget { .. } => None,
240        }
241    }
242}
243
244/// Planner for worker-pool import/session batches.
245#[derive(Clone, Debug, Eq, PartialEq)]
246pub struct LeanWorkerImportPlanner {
247    config: LeanWorkerImportPlanConfig,
248}
249
250impl LeanWorkerImportPlanner {
251    /// Create a planner from capability/session requirements.
252    #[must_use]
253    pub fn new(config: LeanWorkerImportPlanConfig) -> Self {
254        Self { config }
255    }
256
257    /// Discover a Lake project and return stable worker batches.
258    ///
259    /// # Errors
260    ///
261    /// Returns typed planning diagnostics for missing Lake roots, selected
262    /// module roots, invalid module names, unsupported toolchains, or an
263    /// unresolved capability target.
264    pub fn plan_lake_project(&self) -> Result<Vec<LeanWorkerPlannedBatch>, LeanWorkerImportPlanError> {
265        let mut options = LeanModuleDiscoveryOptions::new(&self.config.project_root);
266        if let Some(roots) = &self.config.source_roots {
267            options = options.selected_roots(roots.clone());
268        }
269        let discovered = discover_lake_modules(options)
270            .map_err(|diagnostic| LeanWorkerImportPlanError::ModuleDiscovery { diagnostic })?;
271        if !discovered
272            .declared_lean_libs
273            .iter()
274            .any(|name| name == &self.config.lib_name)
275        {
276            return Err(LeanWorkerImportPlanError::UnresolvedCapabilityTarget {
277                project_root: discovered.project_root,
278                target_name: self.config.lib_name.clone(),
279            });
280        }
281        self.plan_discovered(&discovered)
282    }
283
284    /// Plan batches from already discovered module descriptors.
285    ///
286    /// # Errors
287    ///
288    /// Returns a typed error if a supplied module descriptor has an invalid
289    /// module name.
290    pub fn plan_discovered(
291        &self,
292        discovered: &LeanLakeProjectModules,
293    ) -> Result<Vec<LeanWorkerPlannedBatch>, LeanWorkerImportPlanError> {
294        let work = discovered
295            .modules
296            .iter()
297            .map(|module| LeanWorkerModuleWork::from_descriptor(module, &self.config.base_imports));
298        self.plan_work_items(work, &discovered.fingerprint)
299    }
300
301    /// Plan batches from caller-provided module work items.
302    ///
303    /// # Errors
304    ///
305    /// Returns a typed error if a supplied work item has an invalid module
306    /// name.
307    pub fn plan_work_items(
308        &self,
309        modules: impl IntoIterator<Item = LeanWorkerModuleWork>,
310        source_set: &LeanModuleSetFingerprint,
311    ) -> Result<Vec<LeanWorkerPlannedBatch>, LeanWorkerImportPlanError> {
312        let mut groups = BTreeMap::<BatchGroupKey, Vec<LeanWorkerModuleWork>>::new();
313        for module in modules {
314            validate_module_name(&module.module)?;
315            validate_module_name(&module.source_root)?;
316            let key = BatchGroupKey {
317                project_root: self.config.project_root.clone(),
318                package: self.config.package.clone(),
319                lib_name: self.config.lib_name.clone(),
320                source_root: module.source_root.clone(),
321                imports: module.imports.clone(),
322                import_profile: self.config.import_profile,
323                restart_policy_class: restart_policy_class(self.config.restart_policy.as_ref()),
324            };
325            groups.entry(key).or_default().push(module);
326        }
327
328        let mut batches = Vec::new();
329        for (key, mut modules) in groups {
330            modules.sort_by(|left, right| left.module.cmp(&right.module));
331            let mut session_key = LeanWorkerSessionKey::new(
332                key.project_root.clone(),
333                key.package.clone(),
334                key.lib_name.clone(),
335                key.imports.clone(),
336            )
337            .with_import_profile(key.import_profile)
338            .restart_policy_class(key.restart_policy_class);
339            if let Some(expectation) = &self.config.metadata_expectation {
340                session_key = session_key.metadata_expectation(
341                    expectation.export.clone(),
342                    expectation.request.clone(),
343                    expectation.expected.clone(),
344                );
345            }
346            let batch_key = batch_key_string(&key, &modules);
347            batches.push(LeanWorkerPlannedBatch {
348                session_key,
349                project_root: key.project_root,
350                package: key.package,
351                lib_name: key.lib_name,
352                source_root: key.source_root,
353                imports: key.imports,
354                import_profile: key.import_profile,
355                modules,
356                fingerprint: LeanWorkerBatchFingerprint {
357                    toolchain: source_set.toolchain.clone(),
358                    source_set: source_set.clone(),
359                    batch_key,
360                },
361                metadata_expectation: self.config.metadata_expectation.clone(),
362                restart_policy: self.config.restart_policy.clone(),
363            });
364        }
365        Ok(batches)
366    }
367}
368
369#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
370struct BatchGroupKey {
371    project_root: PathBuf,
372    package: String,
373    lib_name: String,
374    source_root: String,
375    imports: Vec<String>,
376    import_profile: LeanWorkerSessionImportProfile,
377    restart_policy_class: LeanWorkerRestartPolicyClass,
378}
379
380fn restart_policy_class(policy: Option<&LeanWorkerRestartPolicy>) -> LeanWorkerRestartPolicyClass {
381    match policy {
382        Some(policy) if policy == &LeanWorkerRestartPolicy::default() => LeanWorkerRestartPolicyClass::Default,
383        Some(_policy) => LeanWorkerRestartPolicyClass::Custom,
384        None => LeanWorkerRestartPolicyClass::Default,
385    }
386}
387
388fn batch_key_string(key: &BatchGroupKey, modules: &[LeanWorkerModuleWork]) -> String {
389    let module_list = modules
390        .iter()
391        .map(|module| module.module.as_str())
392        .collect::<Vec<_>>()
393        .join(",");
394    format!(
395        "project={};package={};lib={};source_root={};imports={};profile={};policy={:?};modules={module_list}",
396        key.project_root.display(),
397        key.package,
398        key.lib_name,
399        key.source_root,
400        key.imports.join(","),
401        key.import_profile.label(),
402        key.restart_policy_class,
403    )
404}
405
406fn validate_module_name(module: &str) -> Result<(), LeanWorkerImportPlanError> {
407    if module.is_empty() {
408        return Err(LeanWorkerImportPlanError::InvalidModuleName {
409            module: module.to_owned(),
410            reason: "module name is empty".to_owned(),
411        });
412    }
413    for component in module.split('.') {
414        if component.is_empty() {
415            return Err(LeanWorkerImportPlanError::InvalidModuleName {
416                module: module.to_owned(),
417                reason: "module name contains an empty component".to_owned(),
418            });
419        }
420        let mut chars = component.chars();
421        let Some(first) = chars.next() else {
422            return Err(LeanWorkerImportPlanError::InvalidModuleName {
423                module: module.to_owned(),
424                reason: "module name contains an empty component".to_owned(),
425            });
426        };
427        if !(first == '_' || first.is_alphabetic()) {
428            return Err(LeanWorkerImportPlanError::InvalidModuleName {
429                module: module.to_owned(),
430                reason: "module components must begin with a letter or underscore".to_owned(),
431            });
432        }
433        if chars.any(|ch| !(ch == '_' || ch == '\'' || ch.is_alphanumeric())) {
434            return Err(LeanWorkerImportPlanError::InvalidModuleName {
435                module: module.to_owned(),
436                reason: "module components may contain only letters, digits, underscores, or apostrophes".to_owned(),
437            });
438        }
439    }
440    Ok(())
441}
442
443#[cfg(test)]
444#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
445mod tests {
446    use super::{LeanWorkerImportPlanConfig, LeanWorkerImportPlanError, LeanWorkerImportPlanner};
447    use std::fs;
448    use std::path::{Path, PathBuf};
449
450    #[test]
451    fn plan_lake_project_accepts_toml_declared_target() {
452        let root = temp_project("toml-target-accept");
453        write_file(
454            &root.join("lakefile.toml"),
455            r#"
456name = "demo"
457[[lean_lib]]
458name = "FixtureLib"
459"#,
460        );
461        write_file(&root.join("FixtureLib.lean"), "#check Nat\n");
462
463        let planner = LeanWorkerImportPlanner::new(LeanWorkerImportPlanConfig::new(&root, "demo", "FixtureLib"));
464        let batches = planner.plan_lake_project().expect("TOML-declared target should plan");
465        assert!(!batches.is_empty());
466    }
467
468    #[test]
469    fn plan_lake_project_rejects_undeclared_toml_target() {
470        let root = temp_project("toml-target-reject");
471        write_file(
472            &root.join("lakefile.toml"),
473            r#"
474name = "demo"
475[[lean_lib]]
476name = "FixtureLib"
477"#,
478        );
479        write_file(&root.join("FixtureLib.lean"), "#check Nat\n");
480
481        let planner = LeanWorkerImportPlanner::new(LeanWorkerImportPlanConfig::new(&root, "demo", "Bogus"));
482        match planner.plan_lake_project() {
483            Err(LeanWorkerImportPlanError::UnresolvedCapabilityTarget { target_name, .. }) => {
484                assert_eq!(target_name, "Bogus");
485            }
486            other => panic!("expected UnresolvedCapabilityTarget, got {other:?}"),
487        }
488    }
489
490    #[test]
491    fn plan_lake_project_rejects_top_level_fallback_target() {
492        // A lakefile that declares no `lean_lib` falls back to discovering
493        // top-level `*.lean` files for source enumeration. The planner must
494        // still reject any target as "not declared," because the build will
495        // fail when Lake can't find a matching `lean_lib`.
496        let root = temp_project("fallback-target-reject");
497        write_file(&root.join("lakefile.lean"), "package demo\n");
498        write_file(&root.join("Demo.lean"), "#check Nat\n");
499
500        let planner = LeanWorkerImportPlanner::new(LeanWorkerImportPlanConfig::new(&root, "demo", "Demo"));
501        match planner.plan_lake_project() {
502            Err(LeanWorkerImportPlanError::UnresolvedCapabilityTarget { target_name, .. }) => {
503                assert_eq!(target_name, "Demo");
504            }
505            other => panic!("expected UnresolvedCapabilityTarget for fallback project, got {other:?}"),
506        }
507    }
508
509    fn temp_project(name: &str) -> PathBuf {
510        let root = std::env::temp_dir().join(format!("lean-rs-worker-parent-planning-{name}-{}", std::process::id()));
511        if root.exists() {
512            fs::remove_dir_all(&root).unwrap();
513        }
514        fs::create_dir_all(&root).unwrap();
515        root
516    }
517
518    fn write_file(path: &Path, contents: &str) {
519        if let Some(parent) = path.parent() {
520            fs::create_dir_all(parent).unwrap();
521        }
522        fs::write(path, contents).unwrap();
523    }
524}