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