1use std::collections::HashMap;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8use super::extras::Language;
9use super::raw_crate::RawCrateConfig;
10use super::resolve_helpers::{merge_map, resolve_output_paths};
11use super::resolved::ResolvedCrateConfig;
12use super::workspace::WorkspaceConfig;
13
14#[derive(Debug, thiserror::Error)]
16pub enum ResolveError {
17 #[error("duplicate crate name `{0}` — every [[crates]] entry must have a unique name")]
19 DuplicateCrateName(String),
20
21 #[error("crate `{0}` has no target languages — set `languages` on the crate or in `[workspace]`")]
23 EmptyLanguages(String),
24
25 #[error(
27 "overlapping output path for language `{lang}`: `{path}` is claimed by crates: {crates}",
28 path = path.display(),
29 crates = crates.join(", ")
30 )]
31 OverlappingOutputPath {
32 lang: String,
33 path: PathBuf,
34 crates: Vec<String>,
35 },
36}
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct NewAlefConfig {
55 #[serde(default)]
57 pub workspace: WorkspaceConfig,
58 pub crates: Vec<RawCrateConfig>,
60}
61
62impl NewAlefConfig {
63 pub fn resolve(&self) -> Result<Vec<ResolvedCrateConfig>, ResolveError> {
74 let mut seen: HashMap<&str, usize> = HashMap::new();
76 for (idx, krate) in self.crates.iter().enumerate() {
77 if seen.insert(krate.name.as_str(), idx).is_some() {
78 return Err(ResolveError::DuplicateCrateName(krate.name.clone()));
79 }
80 }
81
82 let multi_crate = self.crates.len() > 1;
83 let mut resolved: Vec<ResolvedCrateConfig> = Vec::with_capacity(self.crates.len());
84
85 for krate in &self.crates {
86 resolved.push(self.resolve_one(krate, multi_crate)?);
87 }
88
89 let mut path_owners: HashMap<String, HashMap<PathBuf, Vec<String>>> = HashMap::new();
92 for cfg in &resolved {
93 for (lang, path) in &cfg.output_paths {
94 path_owners
95 .entry(lang.clone())
96 .or_default()
97 .entry(path.clone())
98 .or_default()
99 .push(cfg.name.clone());
100 }
101 }
102 for (lang, path_map) in path_owners {
103 for (path, crates) in path_map {
104 if crates.len() > 1 {
105 return Err(ResolveError::OverlappingOutputPath { lang, path, crates });
106 }
107 }
108 }
109
110 Ok(resolved)
111 }
112
113 fn resolve_one(&self, krate: &RawCrateConfig, multi_crate: bool) -> Result<ResolvedCrateConfig, ResolveError> {
114 let ws = &self.workspace;
115
116 let languages: Vec<Language> = match krate.languages.as_deref() {
118 Some(langs) if !langs.is_empty() => langs.to_vec(),
119 Some(_) => {
120 if ws.languages.is_empty() {
122 return Err(ResolveError::EmptyLanguages(krate.name.clone()));
123 }
124 ws.languages.clone()
125 }
126 None => {
127 if ws.languages.is_empty() {
128 return Err(ResolveError::EmptyLanguages(krate.name.clone()));
129 }
130 ws.languages.clone()
131 }
132 };
133
134 let output_paths = resolve_output_paths(krate, &ws.output_template, &languages, multi_crate);
136
137 let lint = merge_map(&ws.lint, &krate.lint);
146 let test = merge_map(&ws.test, &krate.test);
147 let setup = merge_map(&ws.setup, &krate.setup);
148 let update = merge_map(&ws.update, &krate.update);
149 let clean = merge_map(&ws.clean, &krate.clean);
150 let build_commands = merge_map(&ws.build_commands, &krate.build_commands);
151 let format_overrides = merge_map(&ws.format_overrides, &krate.format_overrides);
152 let generate_overrides = merge_map(&ws.generate_overrides, &krate.generate_overrides);
153
154 Ok(ResolvedCrateConfig {
155 name: krate.name.clone(),
156 sources: krate.sources.clone(),
157 source_crates: krate.source_crates.clone(),
158 version_from: krate.version_from.clone().unwrap_or_else(|| "Cargo.toml".to_string()),
159 core_import: krate.core_import.clone(),
160 workspace_root: krate.workspace_root.clone(),
161 skip_core_import: krate.skip_core_import,
162 error_type: krate.error_type.clone(),
163 error_constructor: krate.error_constructor.clone(),
164 features: krate.features.clone(),
165 path_mappings: krate.path_mappings.clone(),
166 extra_dependencies: krate.extra_dependencies.clone(),
167 auto_path_mappings: krate.auto_path_mappings.unwrap_or(true),
168 languages,
169 python: krate.python.clone(),
170 node: krate.node.clone(),
171 ruby: krate.ruby.clone(),
172 php: krate.php.clone(),
173 elixir: krate.elixir.clone(),
174 wasm: krate.wasm.clone(),
175 ffi: krate.ffi.clone(),
176 gleam: krate.gleam.clone(),
177 go: krate.go.clone(),
178 java: krate.java.clone(),
179 dart: krate.dart.clone(),
180 kotlin: krate.kotlin.clone(),
181 swift: krate.swift.clone(),
182 csharp: krate.csharp.clone(),
183 r: krate.r.clone(),
184 zig: krate.zig.clone(),
185 exclude: krate.exclude.clone(),
186 include: krate.include.clone(),
187 output_paths,
188 explicit_output: krate.output.clone(),
189 lint,
190 test,
191 setup,
192 update,
193 clean,
194 build_commands,
195 generate: krate.generate.clone().unwrap_or_else(|| ws.generate.clone()),
199 generate_overrides,
200 format: krate.format.clone().unwrap_or_else(|| ws.format.clone()),
201 format_overrides,
202 dto: krate.dto.clone().unwrap_or_else(|| ws.dto.clone()),
203 tools: ws.tools.clone(),
204 opaque_types: ws.opaque_types.clone(),
205 sync: ws.sync.clone(),
206 publish: krate.publish.clone(),
207 e2e: krate.e2e.clone(),
208 adapters: krate.adapters.clone(),
209 trait_bridges: krate.trait_bridges.clone(),
210 scaffold: krate.scaffold.clone(),
211 readme: krate.readme.clone(),
212 custom_files: krate.custom_files.clone(),
213 custom_modules: krate.custom_modules.clone(),
214 custom_registrations: krate.custom_registrations.clone(),
215 })
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::config::dto;
223 use crate::config::extras::Language;
224
225 fn two_crate_config() -> NewAlefConfig {
226 toml::from_str(
227 r#"
228[workspace]
229languages = ["python", "node"]
230
231[workspace.output_template]
232python = "packages/python/{crate}/"
233node = "packages/node/{crate}/"
234
235[[crates]]
236name = "alpha"
237sources = ["crates/alpha/src/lib.rs"]
238
239[[crates]]
240name = "beta"
241sources = ["crates/beta/src/lib.rs"]
242"#,
243 )
244 .unwrap()
245 }
246
247 #[test]
248 fn resolve_single_crate_inherits_workspace_languages() {
249 let cfg: NewAlefConfig = toml::from_str(
250 r#"
251[workspace]
252languages = ["python", "go"]
253
254[[crates]]
255name = "spikard"
256sources = ["src/lib.rs"]
257"#,
258 )
259 .unwrap();
260
261 let resolved = cfg.resolve().expect("resolve should succeed");
262 assert_eq!(resolved.len(), 1);
263 let spikard = &resolved[0];
264 assert_eq!(spikard.name, "spikard");
265 assert_eq!(spikard.languages.len(), 2);
266 assert!(spikard.languages.contains(&Language::Python));
267 assert!(spikard.languages.contains(&Language::Go));
268 }
269
270 #[test]
271 fn resolve_per_crate_languages_override_workspace() {
272 let cfg: NewAlefConfig = toml::from_str(
273 r#"
274[workspace]
275languages = ["python", "go"]
276
277[[crates]]
278name = "spikard"
279sources = ["src/lib.rs"]
280languages = ["node"]
281"#,
282 )
283 .unwrap();
284
285 let resolved = cfg.resolve().expect("resolve should succeed");
286 let spikard = &resolved[0];
287 assert_eq!(spikard.languages, vec![Language::Node]);
288 }
289
290 #[test]
291 fn new_alef_config_resolve_propagates_field_renames() {
292 let cfg: NewAlefConfig = toml::from_str(
296 r#"
297[workspace]
298languages = ["python", "node"]
299
300[[crates]]
301name = "spikard"
302sources = ["src/lib.rs"]
303
304[crates.python]
305module_name = "_spikard"
306
307[crates.python.rename_fields]
308"User.type" = "user_type"
309"User.id" = "identifier"
310
311[crates.node]
312package_name = "@spikard/node"
313
314[crates.node.rename_fields]
315"User.type" = "userType"
316"#,
317 )
318 .unwrap();
319
320 let resolved = cfg.resolve().expect("resolve should succeed");
321 let spikard = &resolved[0];
322
323 let py = spikard.python.as_ref().expect("python config should be present");
324 assert_eq!(py.rename_fields.get("User.type").map(String::as_str), Some("user_type"));
325 assert_eq!(py.rename_fields.get("User.id").map(String::as_str), Some("identifier"));
326
327 let node_cfg = spikard.node.as_ref().expect("node config should be present");
328 assert_eq!(
329 node_cfg.rename_fields.get("User.type").map(String::as_str),
330 Some("userType")
331 );
332 }
333
334 #[test]
335 fn resolve_workspace_lint_default_merged_with_crate_override() {
336 let cfg: NewAlefConfig = toml::from_str(
337 r#"
338[workspace]
339languages = ["python", "node"]
340
341[workspace.lint.python]
342check = "ruff check ."
343
344[workspace.lint.node]
345check = "oxlint ."
346
347[[crates]]
348name = "spikard"
349sources = ["src/lib.rs"]
350
351[crates.lint.python]
352check = "ruff check crates/spikard-py/"
353"#,
354 )
355 .unwrap();
356
357 let resolved = cfg.resolve().expect("resolve should succeed");
358 let spikard = &resolved[0];
359
360 let py_lint = spikard.lint.get("python").expect("python lint should be present");
362 assert_eq!(
363 py_lint.check.as_ref().unwrap().commands(),
364 vec!["ruff check crates/spikard-py/"],
365 "per-crate python lint should win over workspace default"
366 );
367
368 let node_lint = spikard.lint.get("node").expect("node lint should be present");
370 assert_eq!(
371 node_lint.check.as_ref().unwrap().commands(),
372 vec!["oxlint ."],
373 "workspace node lint should be inherited when no per-crate override"
374 );
375 }
376
377 #[test]
378 fn resolve_multi_crate_output_paths_use_template() {
379 let cfg = two_crate_config();
380 let resolved = cfg.resolve().expect("resolve should succeed");
381
382 let alpha = resolved.iter().find(|c| c.name == "alpha").unwrap();
383 let beta = resolved.iter().find(|c| c.name == "beta").unwrap();
384
385 assert_eq!(
386 alpha.output_paths.get("python"),
387 Some(&std::path::PathBuf::from("packages/python/alpha/")),
388 "alpha python output path"
389 );
390 assert_eq!(
391 beta.output_paths.get("python"),
392 Some(&std::path::PathBuf::from("packages/python/beta/")),
393 "beta python output path"
394 );
395 assert_eq!(
396 alpha.output_paths.get("node"),
397 Some(&std::path::PathBuf::from("packages/node/alpha/")),
398 "alpha node output path"
399 );
400 }
401
402 #[test]
403 fn resolve_duplicate_crate_name_errors() {
404 let cfg: NewAlefConfig = toml::from_str(
405 r#"
406[workspace]
407languages = ["python"]
408
409[[crates]]
410name = "spikard"
411sources = ["src/lib.rs"]
412
413[[crates]]
414name = "spikard"
415sources = ["src/other.rs"]
416"#,
417 )
418 .unwrap();
419
420 let err = cfg.resolve().unwrap_err();
421 assert!(
422 matches!(err, ResolveError::DuplicateCrateName(ref n) if n == "spikard"),
423 "expected DuplicateCrateName(spikard), got: {err}"
424 );
425 }
426
427 #[test]
428 fn resolve_empty_languages_errors_when_workspace_also_empty() {
429 let cfg: NewAlefConfig = toml::from_str(
430 r#"
431[workspace]
432
433[[crates]]
434name = "spikard"
435sources = ["src/lib.rs"]
436"#,
437 )
438 .unwrap();
439
440 let err = cfg.resolve().unwrap_err();
441 assert!(
442 matches!(err, ResolveError::EmptyLanguages(ref n) if n == "spikard"),
443 "expected EmptyLanguages(spikard), got: {err}"
444 );
445 }
446
447 #[test]
448 fn resolve_overlapping_output_path_errors() {
449 let cfg: NewAlefConfig = toml::from_str(
452 r#"
453[workspace]
454languages = ["python"]
455
456[[crates]]
457name = "alpha"
458sources = ["src/lib.rs"]
459
460[crates.output]
461python = "packages/python/shared/"
462
463[[crates]]
464name = "beta"
465sources = ["src/other.rs"]
466
467[crates.output]
468python = "packages/python/shared/"
469"#,
470 )
471 .unwrap();
472
473 let err = cfg.resolve().unwrap_err();
474 assert!(
475 matches!(err, ResolveError::OverlappingOutputPath { ref lang, .. } if lang == "python"),
476 "expected OverlappingOutputPath for python, got: {err}"
477 );
478 }
479
480 #[test]
481 fn resolve_version_from_defaults_to_cargo_toml() {
482 let cfg: NewAlefConfig = toml::from_str(
483 r#"
484[workspace]
485languages = ["python"]
486
487[[crates]]
488name = "spikard"
489sources = ["src/lib.rs"]
490"#,
491 )
492 .unwrap();
493
494 let resolved = cfg.resolve().expect("resolve should succeed");
495 assert_eq!(resolved[0].version_from, "Cargo.toml");
496 }
497
498 #[test]
499 fn resolve_auto_path_mappings_defaults_to_true() {
500 let cfg: NewAlefConfig = toml::from_str(
501 r#"
502[workspace]
503languages = ["python"]
504
505[[crates]]
506name = "spikard"
507sources = ["src/lib.rs"]
508"#,
509 )
510 .unwrap();
511
512 let resolved = cfg.resolve().expect("resolve should succeed");
513 assert!(resolved[0].auto_path_mappings);
514 }
515
516 #[test]
517 fn resolve_workspace_tools_and_dto_flow_through() {
518 let cfg: NewAlefConfig = toml::from_str(
519 r#"
520[workspace]
521languages = ["python"]
522
523[workspace.tools]
524python_package_manager = "uv"
525
526[workspace.opaque_types]
527Tree = "tree_sitter::Tree"
528
529[[crates]]
530name = "spikard"
531sources = ["src/lib.rs"]
532"#,
533 )
534 .unwrap();
535
536 let resolved = cfg.resolve().expect("resolve should succeed");
537 assert_eq!(resolved[0].tools.python_package_manager.as_deref(), Some("uv"));
538 assert_eq!(
539 resolved[0].opaque_types.get("Tree").map(String::as_str),
540 Some("tree_sitter::Tree")
541 );
542 }
543
544 #[test]
545 fn resolve_workspace_generate_format_dto_flow_through_when_crate_unset() {
546 let cfg: NewAlefConfig = toml::from_str(
547 r#"
548[workspace]
549languages = ["python"]
550
551[workspace.generate]
552public_api = false
553bindings = false
554
555[workspace.format]
556enabled = false
557
558[workspace.dto]
559python = "typed-dict"
560node = "zod"
561
562[[crates]]
563name = "spikard"
564sources = ["src/lib.rs"]
565"#,
566 )
567 .unwrap();
568
569 let resolved = cfg.resolve().expect("resolve should succeed");
570 assert!(
571 !resolved[0].generate.public_api,
572 "workspace generate.public_api must flow through"
573 );
574 assert!(
575 !resolved[0].generate.bindings,
576 "workspace generate.bindings must flow through"
577 );
578 assert!(
579 !resolved[0].format.enabled,
580 "workspace format.enabled must flow through"
581 );
582 assert!(matches!(resolved[0].dto.python, dto::PythonDtoStyle::TypedDict));
583 assert!(matches!(resolved[0].dto.node, dto::NodeDtoStyle::Zod));
584 }
585
586 #[test]
587 fn resolve_per_crate_generate_format_dto_override_workspace() {
588 let cfg: NewAlefConfig = toml::from_str(
589 r#"
590[workspace]
591languages = ["python"]
592
593[workspace.generate]
594public_api = false
595
596[workspace.format]
597enabled = false
598
599[workspace.dto]
600python = "typed-dict"
601
602[[crates]]
603name = "spikard"
604sources = ["src/lib.rs"]
605
606[crates.generate]
607public_api = true
608
609[crates.format]
610enabled = true
611
612[crates.dto]
613python = "dataclass"
614"#,
615 )
616 .unwrap();
617
618 let resolved = cfg.resolve().expect("resolve should succeed");
619 assert!(
620 resolved[0].generate.public_api,
621 "per-crate generate.public_api must override workspace"
622 );
623 assert!(
624 resolved[0].format.enabled,
625 "per-crate format.enabled must override workspace"
626 );
627 assert!(
628 matches!(resolved[0].dto.python, dto::PythonDtoStyle::Dataclass),
629 "per-crate dto.python must override workspace"
630 );
631 }
632
633 #[test]
634 fn resolve_per_crate_explicit_empty_languages_inherits_workspace() {
635 let cfg: NewAlefConfig = toml::from_str(
638 r#"
639[workspace]
640languages = ["python", "node"]
641
642[[crates]]
643name = "spikard"
644sources = ["src/lib.rs"]
645languages = []
646"#,
647 )
648 .unwrap();
649
650 let resolved = cfg.resolve().expect("resolve should succeed");
651 assert_eq!(resolved[0].languages, vec![Language::Python, Language::Node]);
652 }
653
654 #[test]
655 fn resolve_per_crate_empty_languages_with_empty_workspace_errors() {
656 let cfg: NewAlefConfig = toml::from_str(
657 r#"
658[[crates]]
659name = "spikard"
660sources = ["src/lib.rs"]
661languages = []
662"#,
663 )
664 .unwrap();
665
666 let err = cfg
667 .resolve()
668 .expect_err("resolve must fail when both per-crate and workspace languages are empty");
669 match err {
670 ResolveError::EmptyLanguages(name) => assert_eq!(name, "spikard"),
671 other => panic!("expected EmptyLanguages, got {other:?}"),
672 }
673 }
674
675 #[test]
678 fn unknown_top_level_key_is_rejected() {
679 let result: Result<NewAlefConfig, _> = toml::from_str(
683 r#"
684wrkspace = "typo"
685
686[[crates]]
687name = "spikard"
688sources = ["src/lib.rs"]
689"#,
690 );
691 assert!(
693 result.is_err(),
694 "unknown top-level key should be rejected by deny_unknown_fields"
695 );
696 }
697
698 #[test]
701 fn new_alef_config_resolve_rejects_duplicate_crate_name() {
702 let cfg: NewAlefConfig = toml::from_str(
703 r#"
704[workspace]
705languages = ["python"]
706
707[[crates]]
708name = "dup"
709sources = ["src/lib.rs"]
710
711[[crates]]
712name = "dup"
713sources = ["src/other.rs"]
714"#,
715 )
716 .unwrap();
717 let err = cfg.resolve().unwrap_err();
718 assert!(matches!(err, ResolveError::DuplicateCrateName(ref n) if n == "dup"));
719 }
720
721 #[test]
722 fn new_alef_config_resolve_rejects_overlapping_output_paths() {
723 let cfg: NewAlefConfig = toml::from_str(
724 r#"
725[workspace]
726languages = ["python"]
727
728[[crates]]
729name = "a"
730sources = ["src/lib.rs"]
731
732[crates.output]
733python = "packages/python/shared/"
734
735[[crates]]
736name = "b"
737sources = ["src/other.rs"]
738
739[crates.output]
740python = "packages/python/shared/"
741"#,
742 )
743 .unwrap();
744 let err = cfg.resolve().unwrap_err();
745 assert!(matches!(err, ResolveError::OverlappingOutputPath { ref lang, .. } if lang == "python"));
746 }
747
748 #[test]
749 fn new_alef_config_resolve_per_crate_languages_overrides_workspace() {
750 let cfg: NewAlefConfig = toml::from_str(
751 r#"
752[workspace]
753languages = ["python", "go"]
754
755[[crates]]
756name = "x"
757sources = ["src/lib.rs"]
758languages = ["node"]
759"#,
760 )
761 .unwrap();
762 let resolved = cfg.resolve().unwrap();
763 assert_eq!(resolved[0].languages, vec![Language::Node]);
764 }
765}