1use 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#[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 #[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 #[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 #[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 #[must_use]
67 pub fn import_profile(mut self, profile: LeanWorkerSessionImportProfile) -> Self {
68 self.import_profile = profile;
69 self
70 }
71
72 #[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 #[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 #[must_use]
101 pub fn restart_policy(mut self, policy: LeanWorkerRestartPolicy) -> Self {
102 self.restart_policy = Some(policy);
103 self
104 }
105}
106
107#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct LeanWorkerPlanMetadataExpectation {
110 pub export: String,
111 pub request: Value,
112 pub expected: Option<LeanWorkerCapabilityMetadata>,
113}
114
115#[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 #[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#[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 #[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#[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#[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#[derive(Clone, Debug, Eq, PartialEq)]
246pub struct LeanWorkerImportPlanner {
247 config: LeanWorkerImportPlanConfig,
248}
249
250impl LeanWorkerImportPlanner {
251 #[must_use]
253 pub fn new(config: LeanWorkerImportPlanConfig) -> Self {
254 Self { config }
255 }
256
257 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 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 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 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}