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::output::{BuildCommandConfig, GeneratedHeaderConfig, PrecommitConfig, ScaffoldConfig};
10use super::raw_crate::RawCrateConfig;
11use super::resolve_helpers::{merge_map, resolve_output_paths};
12use super::resolved::ResolvedCrateConfig;
13use super::workspace::WorkspaceConfig;
14
15/// Error variants produced when resolving a [`NewAlefConfig`] into per-crate views.
16#[derive(Debug, thiserror::Error)]
17pub enum ResolveError {
18    /// Two `[[crates]]` entries share the same `name`.
19    #[error("duplicate crate name `{0}` — every [[crates]] entry must have a unique name")]
20    DuplicateCrateName(String),
21
22    /// A crate has no target languages after merging workspace and per-crate config.
23    #[error("crate `{0}` has no target languages — set `languages` on the crate or in `[workspace]`")]
24    EmptyLanguages(String),
25
26    /// Two or more crates would write to the same output path for the same language.
27    #[error(
28        "overlapping output path for language `{lang}`: `{path}` is claimed by crates: {crates}",
29        path = path.display(),
30        crates = crates.join(", ")
31    )]
32    OverlappingOutputPath {
33        lang: String,
34        path: PathBuf,
35        crates: Vec<String>,
36    },
37
38    /// A crate has an invalid or incompatible configuration.
39    #[error("{0}")]
40    InvalidConfig(String),
41}
42
43/// Top-level multi-crate configuration (new schema).
44///
45/// Deserializes from an `alef.toml` that has a `[workspace]` section and one
46/// or more `[[crates]]` entries.  Call [`NewAlefConfig::resolve`] to produce
47/// the per-crate [`ResolvedCrateConfig`] list that backends consume.
48///
49/// ```toml
50/// [workspace]
51/// languages = ["python", "node"]
52///
53/// [[crates]]
54/// name = "spikard"
55/// sources = ["src/lib.rs"]
56/// ```
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct NewAlefConfig {
60    /// Workspace-level shared defaults.
61    #[serde(default)]
62    pub workspace: WorkspaceConfig,
63    /// One entry per independently published binding package.
64    pub crates: Vec<RawCrateConfig>,
65}
66
67impl NewAlefConfig {
68    /// Merge workspace defaults into each crate and validate the result.
69    ///
70    /// Returns a `Vec<ResolvedCrateConfig>` in the same order as `self.crates`.
71    ///
72    /// # Errors
73    ///
74    /// - [`ResolveError::DuplicateCrateName`] when two crates share a name.
75    /// - [`ResolveError::EmptyLanguages`] when a crate has no target languages.
76    /// - [`ResolveError::OverlappingOutputPath`] when two crates resolve to the
77    ///   same output directory for the same language.
78    pub fn resolve(&self) -> Result<Vec<ResolvedCrateConfig>, ResolveError> {
79        // --- Uniqueness check ---------------------------------------------------
80        let mut seen: HashMap<&str, usize> = HashMap::new();
81        for (idx, krate) in self.crates.iter().enumerate() {
82            if seen.insert(krate.name.as_str(), idx).is_some() {
83                return Err(ResolveError::DuplicateCrateName(krate.name.clone()));
84            }
85        }
86
87        let multi_crate = self.crates.len() > 1;
88        let mut resolved: Vec<ResolvedCrateConfig> = Vec::with_capacity(self.crates.len());
89
90        for krate in &self.crates {
91            resolved.push(self.resolve_one(krate, multi_crate)?);
92        }
93
94        // --- Overlapping output path check --------------------------------------
95        // For each language, build a map path → crate names; error on any dup.
96        let mut path_owners: HashMap<String, HashMap<PathBuf, Vec<String>>> = HashMap::new();
97        for cfg in &resolved {
98            for (lang, path) in &cfg.output_paths {
99                path_owners
100                    .entry(lang.clone())
101                    .or_default()
102                    .entry(path.clone())
103                    .or_default()
104                    .push(cfg.name.clone());
105            }
106        }
107        for (lang, path_map) in path_owners {
108            for (path, crates) in path_map {
109                if crates.len() > 1 {
110                    return Err(ResolveError::OverlappingOutputPath { lang, path, crates });
111                }
112            }
113        }
114
115        Ok(resolved)
116    }
117
118    fn resolve_one(&self, krate: &RawCrateConfig, multi_crate: bool) -> Result<ResolvedCrateConfig, ResolveError> {
119        let ws = &self.workspace;
120
121        // --- Languages ----------------------------------------------------------
122        let languages: Vec<Language> = match krate.languages.as_deref() {
123            Some(langs) if !langs.is_empty() => langs.to_vec(),
124            Some(_) => {
125                // Explicitly empty per-crate list: treat as "no override" and use workspace.
126                if ws.languages.is_empty() {
127                    return Err(ResolveError::EmptyLanguages(krate.name.clone()));
128                }
129                ws.languages.clone()
130            }
131            None => {
132                if ws.languages.is_empty() {
133                    return Err(ResolveError::EmptyLanguages(krate.name.clone()));
134                }
135                ws.languages.clone()
136            }
137        };
138
139        // --- Output paths -------------------------------------------------------
140        let output_paths = resolve_output_paths(krate, &ws.output_template, &languages, multi_crate);
141
142        // --- HashMap pipelines -------------------------------------------------
143        // Most per-language pipeline maps use per-key wholesale overlay. Build
144        // commands are intentionally field-wise so workspace defaults and crate
145        // overrides can compose without restating preconditions/release commands.
146        // `path_mappings` and `extra_dependencies` are intentionally NOT merged
147        // here: WorkspaceConfig has no fields for them, so they remain strictly
148        // per-crate (taken verbatim below).
149        let lint = merge_map(&ws.lint, &krate.lint);
150        let test = merge_map(&ws.test, &krate.test);
151        let setup = merge_map(&ws.setup, &krate.setup);
152        let update = merge_map(&ws.update, &krate.update);
153        let clean = merge_map(&ws.clean, &krate.clean);
154        let build_commands = merge_build_command_maps(&ws.build_commands, &krate.build_commands);
155        let format_overrides = merge_map(&ws.format_overrides, &krate.format_overrides);
156        let generate_overrides = merge_map(&ws.generate_overrides, &krate.generate_overrides);
157
158        // --- Cross-language validation ------------------------------------------
159        // alef-backend-jni is paired with kotlin-android: the JNI backend derives
160        // the package, bridge class name, and feature list from kotlin_android
161        // config. Without it the backend has no symbol prefix to emit.
162        if languages.contains(&Language::Jni) && !languages.contains(&Language::KotlinAndroid) {
163            return Err(ResolveError::InvalidConfig(format!(
164                "crate `{}`: language `jni` requires `kotlin_android` to also be enabled in languages",
165                krate.name
166            )));
167        }
168
169        Ok(ResolvedCrateConfig {
170            name: krate.name.clone(),
171            sources: krate.sources.clone(),
172            source_crates: krate.source_crates.clone(),
173            version_from: krate.version_from.clone().unwrap_or_else(|| "Cargo.toml".to_string()),
174            core_import: krate.core_import.clone(),
175            workspace_root: krate.workspace_root.clone(),
176            skip_core_import: krate.skip_core_import,
177            error_type: krate.error_type.clone(),
178            error_constructor: krate.error_constructor.clone(),
179            features: krate.features.clone(),
180            path_mappings: krate.path_mappings.clone(),
181            extra_dependencies: krate.extra_dependencies.clone(),
182            auto_path_mappings: krate.auto_path_mappings.unwrap_or(true),
183            languages,
184            python: krate.python.clone(),
185            node: krate.node.clone(),
186            ruby: krate.ruby.clone(),
187            php: krate.php.clone(),
188            elixir: krate.elixir.clone(),
189            wasm: krate.wasm.clone(),
190            ffi: krate.ffi.clone(),
191            go: krate.go.clone(),
192            java: krate.java.clone(),
193            dart: krate.dart.clone(),
194            kotlin: krate.kotlin.clone(),
195            kotlin_android: krate.kotlin_android.clone(),
196            jni: krate.jni.clone(),
197            swift: krate.swift.clone(),
198            gleam: krate.gleam.clone(),
199            csharp: krate.csharp.clone(),
200            r: krate.r.clone(),
201            zig: krate.zig.clone(),
202            exclude: krate.exclude.clone(),
203            include: krate.include.clone(),
204            output_paths,
205            explicit_output: krate.output.clone(),
206            lint,
207            test,
208            setup,
209            update,
210            clean,
211            build_commands,
212            // Per-crate generate/format/dto override the workspace value when set.
213            // None inherits the workspace default. tools and opaque_types are
214            // workspace-only by design (see WorkspaceConfig docs).
215            generate: krate.generate.clone().unwrap_or_else(|| ws.generate.clone()),
216            generate_overrides,
217            format: krate.format.clone().unwrap_or_else(|| ws.format.clone()),
218            format_overrides,
219            dto: krate.dto.clone().unwrap_or_else(|| ws.dto.clone()),
220            tools: ws.tools.clone(),
221            opaque_types: ws.opaque_types.clone(),
222            client_constructors: ws.client_constructors.clone(),
223            sync: ws.sync.clone(),
224            citation: ws.citation.clone(),
225            publish: krate.publish.clone(),
226            e2e: krate.e2e.clone(),
227            adapters: krate.adapters.clone(),
228            trait_bridges: krate.trait_bridges.clone(),
229            scaffold: merge_scaffold(
230                ws.scaffold.as_ref(),
231                krate.scaffold.as_ref(),
232                ws.generated_header.as_ref(),
233                ws.precommit.as_ref(),
234            ),
235            readme: krate.readme.clone(),
236            custom_files: krate.custom_files.clone(),
237            custom_modules: krate.custom_modules.clone(),
238            custom_registrations: krate.custom_registrations.clone(),
239        })
240    }
241}
242
243fn merge_scaffold(
244    workspace: Option<&ScaffoldConfig>,
245    krate: Option<&ScaffoldConfig>,
246    workspace_header: Option<&GeneratedHeaderConfig>,
247    workspace_precommit: Option<&PrecommitConfig>,
248) -> Option<ScaffoldConfig> {
249    if workspace.is_none() && krate.is_none() && workspace_header.is_none() && workspace_precommit.is_none() {
250        return None;
251    }
252
253    let generated_header = merge_generated_header(
254        workspace.and_then(|s| s.generated_header.as_ref()).or(workspace_header),
255        krate.and_then(|s| s.generated_header.as_ref()),
256    );
257    let precommit = merge_precommit(
258        workspace.and_then(|s| s.precommit.as_ref()).or(workspace_precommit),
259        krate.and_then(|s| s.precommit.as_ref()),
260    );
261
262    Some(ScaffoldConfig {
263        description: krate
264            .and_then(|s| s.description.clone())
265            .or_else(|| workspace.and_then(|s| s.description.clone())),
266        license: krate
267            .and_then(|s| s.license.clone())
268            .or_else(|| workspace.and_then(|s| s.license.clone())),
269        repository: krate
270            .and_then(|s| s.repository.clone())
271            .or_else(|| workspace.and_then(|s| s.repository.clone())),
272        homepage: krate
273            .and_then(|s| s.homepage.clone())
274            .or_else(|| workspace.and_then(|s| s.homepage.clone())),
275        authors: krate
276            .filter(|s| !s.authors.is_empty())
277            .map(|s| s.authors.clone())
278            .or_else(|| workspace.map(|s| s.authors.clone()))
279            .unwrap_or_default(),
280        keywords: krate
281            .filter(|s| !s.keywords.is_empty())
282            .map(|s| s.keywords.clone())
283            .or_else(|| workspace.map(|s| s.keywords.clone()))
284            .unwrap_or_default(),
285        generated_header,
286        precommit,
287        cargo: krate
288            .and_then(|s| s.cargo.clone())
289            .or_else(|| workspace.and_then(|s| s.cargo.clone())),
290    })
291}
292
293fn merge_generated_header(
294    workspace: Option<&GeneratedHeaderConfig>,
295    krate: Option<&GeneratedHeaderConfig>,
296) -> Option<GeneratedHeaderConfig> {
297    if workspace.is_none() && krate.is_none() {
298        return None;
299    }
300    Some(GeneratedHeaderConfig {
301        issues_url: krate
302            .and_then(|h| h.issues_url.clone())
303            .or_else(|| workspace.and_then(|h| h.issues_url.clone())),
304        regenerate_command: krate
305            .and_then(|h| h.regenerate_command.clone())
306            .or_else(|| workspace.and_then(|h| h.regenerate_command.clone())),
307        verify_command: krate
308            .and_then(|h| h.verify_command.clone())
309            .or_else(|| workspace.and_then(|h| h.verify_command.clone())),
310    })
311}
312
313fn merge_precommit(workspace: Option<&PrecommitConfig>, krate: Option<&PrecommitConfig>) -> Option<PrecommitConfig> {
314    if workspace.is_none() && krate.is_none() {
315        return None;
316    }
317    Some(PrecommitConfig {
318        include_shared_hooks: krate
319            .and_then(|p| p.include_shared_hooks)
320            .or_else(|| workspace.and_then(|p| p.include_shared_hooks)),
321        shared_hooks_repo: krate
322            .and_then(|p| p.shared_hooks_repo.clone())
323            .or_else(|| workspace.and_then(|p| p.shared_hooks_repo.clone())),
324        shared_hooks_rev: krate
325            .and_then(|p| p.shared_hooks_rev.clone())
326            .or_else(|| workspace.and_then(|p| p.shared_hooks_rev.clone())),
327        include_alef_hooks: krate
328            .and_then(|p| p.include_alef_hooks)
329            .or_else(|| workspace.and_then(|p| p.include_alef_hooks)),
330        alef_hooks_repo: krate
331            .and_then(|p| p.alef_hooks_repo.clone())
332            .or_else(|| workspace.and_then(|p| p.alef_hooks_repo.clone())),
333        alef_hooks_rev: krate
334            .and_then(|p| p.alef_hooks_rev.clone())
335            .or_else(|| workspace.and_then(|p| p.alef_hooks_rev.clone())),
336    })
337}
338
339fn merge_build_command_maps(
340    workspace: &HashMap<String, BuildCommandConfig>,
341    krate: &HashMap<String, BuildCommandConfig>,
342) -> HashMap<String, BuildCommandConfig> {
343    let mut merged = workspace.clone();
344    for (lang, override_cfg) in krate {
345        let next = merged
346            .remove(lang)
347            .map(|base| base.merge_overlay(override_cfg))
348            .unwrap_or_else(|| override_cfg.clone());
349        merged.insert(lang.clone(), next);
350    }
351    merged
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::config::dto;
358    use crate::config::extras::Language;
359
360    fn two_crate_config() -> NewAlefConfig {
361        toml::from_str(
362            r#"
363[workspace]
364languages = ["python", "node"]
365
366[workspace.output_template]
367python = "packages/python/{crate}/"
368node   = "packages/node/{crate}/"
369
370[[crates]]
371name = "alpha"
372sources = ["crates/alpha/src/lib.rs"]
373
374[[crates]]
375name = "beta"
376sources = ["crates/beta/src/lib.rs"]
377"#,
378        )
379        .unwrap()
380    }
381
382    #[test]
383    fn resolve_single_crate_inherits_workspace_languages() {
384        let cfg: NewAlefConfig = toml::from_str(
385            r#"
386[workspace]
387languages = ["python", "go"]
388
389[[crates]]
390name = "spikard"
391sources = ["src/lib.rs"]
392"#,
393        )
394        .unwrap();
395
396        let resolved = cfg.resolve().expect("resolve should succeed");
397        assert_eq!(resolved.len(), 1);
398        let spikard = &resolved[0];
399        assert_eq!(spikard.name, "spikard");
400        assert_eq!(spikard.languages.len(), 2);
401        assert!(spikard.languages.contains(&Language::Python));
402        assert!(spikard.languages.contains(&Language::Go));
403    }
404
405    #[test]
406    fn resolve_per_crate_languages_override_workspace() {
407        let cfg: NewAlefConfig = toml::from_str(
408            r#"
409[workspace]
410languages = ["python", "go"]
411
412[[crates]]
413name = "spikard"
414sources = ["src/lib.rs"]
415languages = ["node"]
416"#,
417        )
418        .unwrap();
419
420        let resolved = cfg.resolve().expect("resolve should succeed");
421        let spikard = &resolved[0];
422        assert_eq!(spikard.languages, vec![Language::Node]);
423    }
424
425    #[test]
426    fn resolve_merges_workspace_scaffold_field_by_field() {
427        let cfg: NewAlefConfig = toml::from_str(
428            r#"
429[workspace]
430languages = ["python"]
431
432[workspace.scaffold]
433description = "Workspace description"
434license = "MIT"
435repository = "https://github.com/acme/workspace"
436authors = ["Workspace Team"]
437
438[[crates]]
439name = "spikard"
440sources = ["src/lib.rs"]
441
442[crates.scaffold]
443description = "Crate description"
444keywords = ["bindings"]
445"#,
446        )
447        .unwrap();
448
449        let resolved = cfg.resolve().unwrap().remove(0);
450        let scaffold = resolved.scaffold.unwrap();
451        assert_eq!(scaffold.description.as_deref(), Some("Crate description"));
452        assert_eq!(scaffold.license.as_deref(), Some("MIT"));
453        assert_eq!(
454            scaffold.repository.as_deref(),
455            Some("https://github.com/acme/workspace")
456        );
457        assert_eq!(scaffold.authors, vec!["Workspace Team"]);
458        assert_eq!(scaffold.keywords, vec!["bindings"]);
459    }
460
461    #[test]
462    fn resolve_merges_workspace_header_and_precommit_defaults() {
463        let cfg: NewAlefConfig = toml::from_str(
464            r#"
465[workspace]
466languages = ["python"]
467
468[workspace.generated_header]
469issues_url = "https://docs.example.invalid/alef"
470
471[workspace.precommit]
472shared_hooks_repo = "https://github.com/acme/hooks"
473include_alef_hooks = false
474
475[[crates]]
476name = "spikard"
477sources = ["src/lib.rs"]
478
479[crates.scaffold.generated_header]
480verify_command = "spikard verify"
481
482[crates.scaffold.precommit]
483shared_hooks_rev = "v1.2.3"
484"#,
485        )
486        .unwrap();
487
488        let resolved = cfg.resolve().unwrap().remove(0);
489        let scaffold = resolved.scaffold.unwrap();
490        let header = scaffold.generated_header.unwrap();
491        let precommit = scaffold.precommit.unwrap();
492
493        assert_eq!(header.issues_url.as_deref(), Some("https://docs.example.invalid/alef"));
494        assert_eq!(header.verify_command.as_deref(), Some("spikard verify"));
495        assert_eq!(
496            precommit.shared_hooks_repo.as_deref(),
497            Some("https://github.com/acme/hooks")
498        );
499        assert_eq!(precommit.shared_hooks_rev.as_deref(), Some("v1.2.3"));
500        assert_eq!(precommit.include_alef_hooks, Some(false));
501    }
502
503    #[test]
504    fn resolve_build_commands_merges_workspace_and_crate_fields() {
505        let cfg: NewAlefConfig = toml::from_str(
506            r#"
507[workspace]
508languages = ["go"]
509
510[workspace.build_commands.go]
511precondition = "command -v go"
512before = "cargo build --release -p my-lib-ffi"
513build = "cd packages/go && go build ./..."
514build_release = "cd packages/go && go build -tags release ./..."
515
516[[crates]]
517name = "my-lib"
518sources = ["src/lib.rs"]
519
520[crates.build_commands.go]
521build = "cd packages/go && go build -tags dev ./..."
522"#,
523        )
524        .unwrap();
525
526        let resolved = cfg.resolve().expect("resolve should succeed").remove(0);
527        let build = resolved.build_commands.get("go").expect("go build config");
528        assert_eq!(build.precondition.as_deref(), Some("command -v go"));
529        assert_eq!(
530            build.before.as_ref().unwrap().commands(),
531            vec!["cargo build --release -p my-lib-ffi"]
532        );
533        assert_eq!(
534            build.build.as_ref().unwrap().commands(),
535            vec!["cd packages/go && go build -tags dev ./..."]
536        );
537        assert_eq!(
538            build.build_release.as_ref().unwrap().commands(),
539            vec!["cd packages/go && go build -tags release ./..."]
540        );
541    }
542
543    #[test]
544    fn new_alef_config_resolve_propagates_field_renames() {
545        // Per-language `rename_fields` declared on a `[crates.<lang>]` table must
546        // survive resolution intact — the resolver replaces the per-language
547        // config wholesale rather than merging field-by-field.
548        let cfg: NewAlefConfig = toml::from_str(
549            r#"
550[workspace]
551languages = ["python", "node"]
552
553[[crates]]
554name = "spikard"
555sources = ["src/lib.rs"]
556
557[crates.python]
558module_name = "_spikard"
559
560[crates.python.rename_fields]
561"User.type" = "user_type"
562"User.id" = "identifier"
563
564[crates.node]
565package_name = "@spikard/node"
566
567[crates.node.rename_fields]
568"User.type" = "userType"
569"#,
570        )
571        .unwrap();
572
573        let resolved = cfg.resolve().expect("resolve should succeed");
574        let spikard = &resolved[0];
575
576        let py = spikard.python.as_ref().expect("python config should be present");
577        assert_eq!(py.rename_fields.get("User.type").map(String::as_str), Some("user_type"));
578        assert_eq!(py.rename_fields.get("User.id").map(String::as_str), Some("identifier"));
579
580        let node_cfg = spikard.node.as_ref().expect("node config should be present");
581        assert_eq!(
582            node_cfg.rename_fields.get("User.type").map(String::as_str),
583            Some("userType")
584        );
585    }
586
587    #[test]
588    fn resolve_workspace_lint_default_merged_with_crate_override() {
589        let cfg: NewAlefConfig = toml::from_str(
590            r#"
591[workspace]
592languages = ["python", "node"]
593
594[workspace.lint.python]
595check = "ruff check ."
596
597[workspace.lint.node]
598check = "oxlint ."
599
600[[crates]]
601name = "spikard"
602sources = ["src/lib.rs"]
603
604[crates.lint.python]
605check = "ruff check crates/spikard-py/"
606"#,
607        )
608        .unwrap();
609
610        let resolved = cfg.resolve().expect("resolve should succeed");
611        let spikard = &resolved[0];
612
613        // Per-crate python lint overrides workspace
614        let py_lint = spikard.lint.get("python").expect("python lint should be present");
615        assert_eq!(
616            py_lint.check.as_ref().unwrap().commands(),
617            vec!["ruff check crates/spikard-py/"],
618            "per-crate python lint should win over workspace default"
619        );
620
621        // Workspace node lint is inherited (no per-crate override)
622        let node_lint = spikard.lint.get("node").expect("node lint should be present");
623        assert_eq!(
624            node_lint.check.as_ref().unwrap().commands(),
625            vec!["oxlint ."],
626            "workspace node lint should be inherited when no per-crate override"
627        );
628    }
629
630    #[test]
631    fn resolve_multi_crate_output_paths_use_template() {
632        let cfg = two_crate_config();
633        let resolved = cfg.resolve().expect("resolve should succeed");
634
635        let alpha = resolved.iter().find(|c| c.name == "alpha").unwrap();
636        let beta = resolved.iter().find(|c| c.name == "beta").unwrap();
637
638        assert_eq!(
639            alpha.output_paths.get("python"),
640            Some(&std::path::PathBuf::from("packages/python/alpha/")),
641            "alpha python output path"
642        );
643        assert_eq!(
644            beta.output_paths.get("python"),
645            Some(&std::path::PathBuf::from("packages/python/beta/")),
646            "beta python output path"
647        );
648        assert_eq!(
649            alpha.output_paths.get("node"),
650            Some(&std::path::PathBuf::from("packages/node/alpha/")),
651            "alpha node output path"
652        );
653    }
654
655    #[test]
656    fn resolve_duplicate_crate_name_errors() {
657        let cfg: NewAlefConfig = toml::from_str(
658            r#"
659[workspace]
660languages = ["python"]
661
662[[crates]]
663name = "spikard"
664sources = ["src/lib.rs"]
665
666[[crates]]
667name = "spikard"
668sources = ["src/other.rs"]
669"#,
670        )
671        .unwrap();
672
673        let err = cfg.resolve().unwrap_err();
674        assert!(
675            matches!(err, ResolveError::DuplicateCrateName(ref n) if n == "spikard"),
676            "expected DuplicateCrateName(spikard), got: {err}"
677        );
678    }
679
680    #[test]
681    fn resolve_empty_languages_errors_when_workspace_also_empty() {
682        let cfg: NewAlefConfig = toml::from_str(
683            r#"
684[workspace]
685
686[[crates]]
687name = "spikard"
688sources = ["src/lib.rs"]
689"#,
690        )
691        .unwrap();
692
693        let err = cfg.resolve().unwrap_err();
694        assert!(
695            matches!(err, ResolveError::EmptyLanguages(ref n) if n == "spikard"),
696            "expected EmptyLanguages(spikard), got: {err}"
697        );
698    }
699
700    #[test]
701    fn resolve_overlapping_output_path_errors() {
702        // Both crates have no template and identical names would collide; force
703        // a collision by using an explicit output path on both.
704        let cfg: NewAlefConfig = toml::from_str(
705            r#"
706[workspace]
707languages = ["python"]
708
709[[crates]]
710name = "alpha"
711sources = ["src/lib.rs"]
712
713[crates.output]
714python = "packages/python/shared/"
715
716[[crates]]
717name = "beta"
718sources = ["src/other.rs"]
719
720[crates.output]
721python = "packages/python/shared/"
722"#,
723        )
724        .unwrap();
725
726        let err = cfg.resolve().unwrap_err();
727        assert!(
728            matches!(err, ResolveError::OverlappingOutputPath { ref lang, .. } if lang == "python"),
729            "expected OverlappingOutputPath for python, got: {err}"
730        );
731    }
732
733    #[test]
734    fn resolve_version_from_defaults_to_cargo_toml() {
735        let cfg: NewAlefConfig = toml::from_str(
736            r#"
737[workspace]
738languages = ["python"]
739
740[[crates]]
741name = "spikard"
742sources = ["src/lib.rs"]
743"#,
744        )
745        .unwrap();
746
747        let resolved = cfg.resolve().expect("resolve should succeed");
748        assert_eq!(resolved[0].version_from, "Cargo.toml");
749    }
750
751    #[test]
752    fn resolve_auto_path_mappings_defaults_to_true() {
753        let cfg: NewAlefConfig = toml::from_str(
754            r#"
755[workspace]
756languages = ["python"]
757
758[[crates]]
759name = "spikard"
760sources = ["src/lib.rs"]
761"#,
762        )
763        .unwrap();
764
765        let resolved = cfg.resolve().expect("resolve should succeed");
766        assert!(resolved[0].auto_path_mappings);
767    }
768
769    #[test]
770    fn resolve_workspace_tools_and_dto_flow_through() {
771        let cfg: NewAlefConfig = toml::from_str(
772            r#"
773[workspace]
774languages = ["python"]
775
776[workspace.tools]
777python_package_manager = "uv"
778
779[workspace.opaque_types]
780Tree = "tree_sitter::Tree"
781
782[[crates]]
783name = "spikard"
784sources = ["src/lib.rs"]
785"#,
786        )
787        .unwrap();
788
789        let resolved = cfg.resolve().expect("resolve should succeed");
790        assert_eq!(resolved[0].tools.python_package_manager.as_deref(), Some("uv"));
791        assert_eq!(
792            resolved[0].opaque_types.get("Tree").map(String::as_str),
793            Some("tree_sitter::Tree")
794        );
795    }
796
797    #[test]
798    fn resolve_workspace_generate_format_dto_flow_through_when_crate_unset() {
799        let cfg: NewAlefConfig = toml::from_str(
800            r#"
801[workspace]
802languages = ["python"]
803
804[workspace.generate]
805public_api = false
806bindings = false
807
808[workspace.format]
809enabled = false
810
811[workspace.dto]
812python = "typed-dict"
813node   = "zod"
814
815[[crates]]
816name = "spikard"
817sources = ["src/lib.rs"]
818"#,
819        )
820        .unwrap();
821
822        let resolved = cfg.resolve().expect("resolve should succeed");
823        assert!(
824            !resolved[0].generate.public_api,
825            "workspace generate.public_api must flow through"
826        );
827        assert!(
828            !resolved[0].generate.bindings,
829            "workspace generate.bindings must flow through"
830        );
831        assert!(
832            !resolved[0].format.enabled,
833            "workspace format.enabled must flow through"
834        );
835        assert!(matches!(resolved[0].dto.python, dto::PythonDtoStyle::TypedDict));
836        assert!(matches!(resolved[0].dto.node, dto::NodeDtoStyle::Zod));
837    }
838
839    #[test]
840    fn resolve_per_crate_generate_format_dto_override_workspace() {
841        let cfg: NewAlefConfig = toml::from_str(
842            r#"
843[workspace]
844languages = ["python"]
845
846[workspace.generate]
847public_api = false
848
849[workspace.format]
850enabled = false
851
852[workspace.dto]
853python = "typed-dict"
854
855[[crates]]
856name = "spikard"
857sources = ["src/lib.rs"]
858
859[crates.generate]
860public_api = true
861
862[crates.format]
863enabled = true
864
865[crates.dto]
866python = "dataclass"
867"#,
868        )
869        .unwrap();
870
871        let resolved = cfg.resolve().expect("resolve should succeed");
872        assert!(
873            resolved[0].generate.public_api,
874            "per-crate generate.public_api must override workspace"
875        );
876        assert!(
877            resolved[0].format.enabled,
878            "per-crate format.enabled must override workspace"
879        );
880        assert!(
881            matches!(resolved[0].dto.python, dto::PythonDtoStyle::Dataclass),
882            "per-crate dto.python must override workspace"
883        );
884    }
885
886    #[test]
887    fn resolve_per_crate_explicit_empty_languages_inherits_workspace() {
888        // Explicit `languages = []` per-crate falls back to workspace defaults
889        // (matches the behavior the resolver already implements).
890        let cfg: NewAlefConfig = toml::from_str(
891            r#"
892[workspace]
893languages = ["python", "node"]
894
895[[crates]]
896name = "spikard"
897sources = ["src/lib.rs"]
898languages = []
899"#,
900        )
901        .unwrap();
902
903        let resolved = cfg.resolve().expect("resolve should succeed");
904        assert_eq!(resolved[0].languages, vec![Language::Python, Language::Node]);
905    }
906
907    #[test]
908    fn resolve_per_crate_empty_languages_with_empty_workspace_errors() {
909        let cfg: NewAlefConfig = toml::from_str(
910            r#"
911[[crates]]
912name = "spikard"
913sources = ["src/lib.rs"]
914languages = []
915"#,
916        )
917        .unwrap();
918
919        let err = cfg
920            .resolve()
921            .expect_err("resolve must fail when both per-crate and workspace languages are empty");
922        match err {
923            ResolveError::EmptyLanguages(name) => assert_eq!(name, "spikard"),
924            other => panic!("expected EmptyLanguages, got {other:?}"),
925        }
926    }
927
928    // --- deny_unknown_fields tests ---
929
930    #[test]
931    fn unknown_top_level_key_is_rejected() {
932        // A misspelled key must produce a parse error, not silently succeed with the
933        // field ignored.
934        // typos: ignore start
935        let result: Result<NewAlefConfig, _> = toml::from_str(
936            r#"
937wrkspace = "typo"
938
939[[crates]]
940name = "spikard"
941sources = ["src/lib.rs"]
942"#,
943        );
944        // typos: ignore end
945        assert!(
946            result.is_err(),
947            "unknown top-level key should be rejected by deny_unknown_fields"
948        );
949    }
950
951    // --- new backfill tests ---
952
953    #[test]
954    fn new_alef_config_resolve_rejects_duplicate_crate_name() {
955        let cfg: NewAlefConfig = toml::from_str(
956            r#"
957[workspace]
958languages = ["python"]
959
960[[crates]]
961name = "dup"
962sources = ["src/lib.rs"]
963
964[[crates]]
965name = "dup"
966sources = ["src/other.rs"]
967"#,
968        )
969        .unwrap();
970        let err = cfg.resolve().unwrap_err();
971        assert!(matches!(err, ResolveError::DuplicateCrateName(ref n) if n == "dup"));
972    }
973
974    #[test]
975    fn new_alef_config_resolve_rejects_overlapping_output_paths() {
976        let cfg: NewAlefConfig = toml::from_str(
977            r#"
978[workspace]
979languages = ["python"]
980
981[[crates]]
982name = "a"
983sources = ["src/lib.rs"]
984
985[crates.output]
986python = "packages/python/shared/"
987
988[[crates]]
989name = "b"
990sources = ["src/other.rs"]
991
992[crates.output]
993python = "packages/python/shared/"
994"#,
995        )
996        .unwrap();
997        let err = cfg.resolve().unwrap_err();
998        assert!(matches!(err, ResolveError::OverlappingOutputPath { ref lang, .. } if lang == "python"));
999    }
1000
1001    #[test]
1002    fn new_alef_config_resolve_per_crate_languages_overrides_workspace() {
1003        let cfg: NewAlefConfig = toml::from_str(
1004            r#"
1005[workspace]
1006languages = ["python", "go"]
1007
1008[[crates]]
1009name = "x"
1010sources = ["src/lib.rs"]
1011languages = ["node"]
1012"#,
1013        )
1014        .unwrap();
1015        let resolved = cfg.resolve().unwrap();
1016        assert_eq!(resolved[0].languages, vec![Language::Node]);
1017    }
1018}