Skip to main content

alef_core/config/
new_config.rs

1//! `NewAlefConfig` and `ResolveError` — the multi-crate config schema.
2
3use 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/// Error variants produced when resolving a [`NewAlefConfig`] into per-crate views.
15#[derive(Debug, thiserror::Error)]
16pub enum ResolveError {
17    /// Two `[[crates]]` entries share the same `name`.
18    #[error("duplicate crate name `{0}` — every [[crates]] entry must have a unique name")]
19    DuplicateCrateName(String),
20
21    /// A crate has no target languages after merging workspace and per-crate config.
22    #[error("crate `{0}` has no target languages — set `languages` on the crate or in `[workspace]`")]
23    EmptyLanguages(String),
24
25    /// Two or more crates would write to the same output path for the same language.
26    #[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/// Top-level multi-crate configuration (new schema).
39///
40/// Deserializes from an `alef.toml` that has a `[workspace]` section and one
41/// or more `[[crates]]` entries.  Call [`NewAlefConfig::resolve`] to produce
42/// the per-crate [`ResolvedCrateConfig`] list that backends consume.
43///
44/// ```toml
45/// [workspace]
46/// languages = ["python", "node"]
47///
48/// [[crates]]
49/// name = "spikard"
50/// sources = ["src/lib.rs"]
51/// ```
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct NewAlefConfig {
55    /// Workspace-level shared defaults.
56    #[serde(default)]
57    pub workspace: WorkspaceConfig,
58    /// One entry per independently published binding package.
59    pub crates: Vec<RawCrateConfig>,
60}
61
62impl NewAlefConfig {
63    /// Merge workspace defaults into each crate and validate the result.
64    ///
65    /// Returns a `Vec<ResolvedCrateConfig>` in the same order as `self.crates`.
66    ///
67    /// # Errors
68    ///
69    /// - [`ResolveError::DuplicateCrateName`] when two crates share a name.
70    /// - [`ResolveError::EmptyLanguages`] when a crate has no target languages.
71    /// - [`ResolveError::OverlappingOutputPath`] when two crates resolve to the
72    ///   same output directory for the same language.
73    pub fn resolve(&self) -> Result<Vec<ResolvedCrateConfig>, ResolveError> {
74        // --- Uniqueness check ---------------------------------------------------
75        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        // --- Overlapping output path check --------------------------------------
90        // For each language, build a map path → crate names; error on any dup.
91        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        // --- Languages ----------------------------------------------------------
117        let languages: Vec<Language> = match krate.languages.as_deref() {
118            Some(langs) if !langs.is_empty() => langs.to_vec(),
119            Some(_) => {
120                // Explicitly empty per-crate list: treat as "no override" and use workspace.
121                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        // --- Output paths -------------------------------------------------------
135        let output_paths = resolve_output_paths(krate, &ws.output_template, &languages, multi_crate);
136
137        // --- HashMap pipelines — per-key wholesale overlay ---------------------
138        // For each language code (the HashMap key), if the crate provides a value
139        // it replaces the workspace value entirely. There is no field-level merge
140        // inside the inner config struct — a per-crate `[crates.lint.python]
141        // check = "..."` replaces the whole `LintConfig`, not just the `check`
142        // field. `path_mappings` and `extra_dependencies` are intentionally NOT
143        // merged here: WorkspaceConfig has no fields for them, so they remain
144        // strictly per-crate (taken verbatim below).
145        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            // Per-crate generate/format/dto override the workspace value when set.
196            // None inherits the workspace default. tools and opaque_types are
197            // workspace-only by design (see WorkspaceConfig docs).
198            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        // Per-language `rename_fields` declared on a `[crates.<lang>]` table must
293        // survive resolution intact — the resolver replaces the per-language
294        // config wholesale rather than merging field-by-field.
295        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        // Per-crate python lint overrides workspace
361        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        // Workspace node lint is inherited (no per-crate override)
369        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        // Both crates have no template and identical names would collide; force
450        // a collision by using an explicit output path on both.
451        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        // Explicit `languages = []` per-crate falls back to workspace defaults
636        // (matches the behavior the resolver already implements).
637        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    // --- deny_unknown_fields tests ---
676
677    #[test]
678    fn unknown_top_level_key_is_rejected() {
679        // A misspelled key must produce a parse error, not silently succeed with the
680        // field ignored.
681        // typos: ignore start
682        let result: Result<NewAlefConfig, _> = toml::from_str(
683            r#"
684wrkspace = "typo"
685
686[[crates]]
687name = "spikard"
688sources = ["src/lib.rs"]
689"#,
690        );
691        // typos: ignore end
692        assert!(
693            result.is_err(),
694            "unknown top-level key should be rejected by deny_unknown_fields"
695        );
696    }
697
698    // --- new backfill tests ---
699
700    #[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}