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            sync: ws.sync.clone(),
223            citation: ws.citation.clone(),
224            publish: krate.publish.clone(),
225            e2e: krate.e2e.clone(),
226            adapters: krate.adapters.clone(),
227            trait_bridges: krate.trait_bridges.clone(),
228            scaffold: merge_scaffold(
229                ws.scaffold.as_ref(),
230                krate.scaffold.as_ref(),
231                ws.generated_header.as_ref(),
232                ws.precommit.as_ref(),
233            ),
234            readme: krate.readme.clone(),
235            custom_files: krate.custom_files.clone(),
236            custom_modules: krate.custom_modules.clone(),
237            custom_registrations: krate.custom_registrations.clone(),
238        })
239    }
240}
241
242fn merge_scaffold(
243    workspace: Option<&ScaffoldConfig>,
244    krate: Option<&ScaffoldConfig>,
245    workspace_header: Option<&GeneratedHeaderConfig>,
246    workspace_precommit: Option<&PrecommitConfig>,
247) -> Option<ScaffoldConfig> {
248    if workspace.is_none() && krate.is_none() && workspace_header.is_none() && workspace_precommit.is_none() {
249        return None;
250    }
251
252    let generated_header = merge_generated_header(
253        workspace.and_then(|s| s.generated_header.as_ref()).or(workspace_header),
254        krate.and_then(|s| s.generated_header.as_ref()),
255    );
256    let precommit = merge_precommit(
257        workspace.and_then(|s| s.precommit.as_ref()).or(workspace_precommit),
258        krate.and_then(|s| s.precommit.as_ref()),
259    );
260
261    Some(ScaffoldConfig {
262        description: krate
263            .and_then(|s| s.description.clone())
264            .or_else(|| workspace.and_then(|s| s.description.clone())),
265        license: krate
266            .and_then(|s| s.license.clone())
267            .or_else(|| workspace.and_then(|s| s.license.clone())),
268        repository: krate
269            .and_then(|s| s.repository.clone())
270            .or_else(|| workspace.and_then(|s| s.repository.clone())),
271        homepage: krate
272            .and_then(|s| s.homepage.clone())
273            .or_else(|| workspace.and_then(|s| s.homepage.clone())),
274        authors: krate
275            .filter(|s| !s.authors.is_empty())
276            .map(|s| s.authors.clone())
277            .or_else(|| workspace.map(|s| s.authors.clone()))
278            .unwrap_or_default(),
279        keywords: krate
280            .filter(|s| !s.keywords.is_empty())
281            .map(|s| s.keywords.clone())
282            .or_else(|| workspace.map(|s| s.keywords.clone()))
283            .unwrap_or_default(),
284        generated_header,
285        precommit,
286        cargo: krate
287            .and_then(|s| s.cargo.clone())
288            .or_else(|| workspace.and_then(|s| s.cargo.clone())),
289    })
290}
291
292fn merge_generated_header(
293    workspace: Option<&GeneratedHeaderConfig>,
294    krate: Option<&GeneratedHeaderConfig>,
295) -> Option<GeneratedHeaderConfig> {
296    if workspace.is_none() && krate.is_none() {
297        return None;
298    }
299    Some(GeneratedHeaderConfig {
300        issues_url: krate
301            .and_then(|h| h.issues_url.clone())
302            .or_else(|| workspace.and_then(|h| h.issues_url.clone())),
303        regenerate_command: krate
304            .and_then(|h| h.regenerate_command.clone())
305            .or_else(|| workspace.and_then(|h| h.regenerate_command.clone())),
306        verify_command: krate
307            .and_then(|h| h.verify_command.clone())
308            .or_else(|| workspace.and_then(|h| h.verify_command.clone())),
309    })
310}
311
312fn merge_precommit(workspace: Option<&PrecommitConfig>, krate: Option<&PrecommitConfig>) -> Option<PrecommitConfig> {
313    if workspace.is_none() && krate.is_none() {
314        return None;
315    }
316    Some(PrecommitConfig {
317        include_shared_hooks: krate
318            .and_then(|p| p.include_shared_hooks)
319            .or_else(|| workspace.and_then(|p| p.include_shared_hooks)),
320        shared_hooks_repo: krate
321            .and_then(|p| p.shared_hooks_repo.clone())
322            .or_else(|| workspace.and_then(|p| p.shared_hooks_repo.clone())),
323        shared_hooks_rev: krate
324            .and_then(|p| p.shared_hooks_rev.clone())
325            .or_else(|| workspace.and_then(|p| p.shared_hooks_rev.clone())),
326        include_alef_hooks: krate
327            .and_then(|p| p.include_alef_hooks)
328            .or_else(|| workspace.and_then(|p| p.include_alef_hooks)),
329        alef_hooks_repo: krate
330            .and_then(|p| p.alef_hooks_repo.clone())
331            .or_else(|| workspace.and_then(|p| p.alef_hooks_repo.clone())),
332        alef_hooks_rev: krate
333            .and_then(|p| p.alef_hooks_rev.clone())
334            .or_else(|| workspace.and_then(|p| p.alef_hooks_rev.clone())),
335    })
336}
337
338fn merge_build_command_maps(
339    workspace: &HashMap<String, BuildCommandConfig>,
340    krate: &HashMap<String, BuildCommandConfig>,
341) -> HashMap<String, BuildCommandConfig> {
342    let mut merged = workspace.clone();
343    for (lang, override_cfg) in krate {
344        let next = merged
345            .remove(lang)
346            .map(|base| base.merge_overlay(override_cfg))
347            .unwrap_or_else(|| override_cfg.clone());
348        merged.insert(lang.clone(), next);
349    }
350    merged
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::config::dto;
357    use crate::config::extras::Language;
358
359    fn two_crate_config() -> NewAlefConfig {
360        toml::from_str(
361            r#"
362[workspace]
363languages = ["python", "node"]
364
365[workspace.output_template]
366python = "packages/python/{crate}/"
367node   = "packages/node/{crate}/"
368
369[[crates]]
370name = "alpha"
371sources = ["crates/alpha/src/lib.rs"]
372
373[[crates]]
374name = "beta"
375sources = ["crates/beta/src/lib.rs"]
376"#,
377        )
378        .unwrap()
379    }
380
381    #[test]
382    fn resolve_single_crate_inherits_workspace_languages() {
383        let cfg: NewAlefConfig = toml::from_str(
384            r#"
385[workspace]
386languages = ["python", "go"]
387
388[[crates]]
389name = "spikard"
390sources = ["src/lib.rs"]
391"#,
392        )
393        .unwrap();
394
395        let resolved = cfg.resolve().expect("resolve should succeed");
396        assert_eq!(resolved.len(), 1);
397        let spikard = &resolved[0];
398        assert_eq!(spikard.name, "spikard");
399        assert_eq!(spikard.languages.len(), 2);
400        assert!(spikard.languages.contains(&Language::Python));
401        assert!(spikard.languages.contains(&Language::Go));
402    }
403
404    #[test]
405    fn resolve_per_crate_languages_override_workspace() {
406        let cfg: NewAlefConfig = toml::from_str(
407            r#"
408[workspace]
409languages = ["python", "go"]
410
411[[crates]]
412name = "spikard"
413sources = ["src/lib.rs"]
414languages = ["node"]
415"#,
416        )
417        .unwrap();
418
419        let resolved = cfg.resolve().expect("resolve should succeed");
420        let spikard = &resolved[0];
421        assert_eq!(spikard.languages, vec![Language::Node]);
422    }
423
424    #[test]
425    fn resolve_merges_workspace_scaffold_field_by_field() {
426        let cfg: NewAlefConfig = toml::from_str(
427            r#"
428[workspace]
429languages = ["python"]
430
431[workspace.scaffold]
432description = "Workspace description"
433license = "MIT"
434repository = "https://github.com/acme/workspace"
435authors = ["Workspace Team"]
436
437[[crates]]
438name = "spikard"
439sources = ["src/lib.rs"]
440
441[crates.scaffold]
442description = "Crate description"
443keywords = ["bindings"]
444"#,
445        )
446        .unwrap();
447
448        let resolved = cfg.resolve().unwrap().remove(0);
449        let scaffold = resolved.scaffold.unwrap();
450        assert_eq!(scaffold.description.as_deref(), Some("Crate description"));
451        assert_eq!(scaffold.license.as_deref(), Some("MIT"));
452        assert_eq!(
453            scaffold.repository.as_deref(),
454            Some("https://github.com/acme/workspace")
455        );
456        assert_eq!(scaffold.authors, vec!["Workspace Team"]);
457        assert_eq!(scaffold.keywords, vec!["bindings"]);
458    }
459
460    #[test]
461    fn resolve_merges_workspace_header_and_precommit_defaults() {
462        let cfg: NewAlefConfig = toml::from_str(
463            r#"
464[workspace]
465languages = ["python"]
466
467[workspace.generated_header]
468issues_url = "https://docs.example.invalid/alef"
469
470[workspace.precommit]
471shared_hooks_repo = "https://github.com/acme/hooks"
472include_alef_hooks = false
473
474[[crates]]
475name = "spikard"
476sources = ["src/lib.rs"]
477
478[crates.scaffold.generated_header]
479verify_command = "spikard verify"
480
481[crates.scaffold.precommit]
482shared_hooks_rev = "v1.2.3"
483"#,
484        )
485        .unwrap();
486
487        let resolved = cfg.resolve().unwrap().remove(0);
488        let scaffold = resolved.scaffold.unwrap();
489        let header = scaffold.generated_header.unwrap();
490        let precommit = scaffold.precommit.unwrap();
491
492        assert_eq!(header.issues_url.as_deref(), Some("https://docs.example.invalid/alef"));
493        assert_eq!(header.verify_command.as_deref(), Some("spikard verify"));
494        assert_eq!(
495            precommit.shared_hooks_repo.as_deref(),
496            Some("https://github.com/acme/hooks")
497        );
498        assert_eq!(precommit.shared_hooks_rev.as_deref(), Some("v1.2.3"));
499        assert_eq!(precommit.include_alef_hooks, Some(false));
500    }
501
502    #[test]
503    fn resolve_build_commands_merges_workspace_and_crate_fields() {
504        let cfg: NewAlefConfig = toml::from_str(
505            r#"
506[workspace]
507languages = ["go"]
508
509[workspace.build_commands.go]
510precondition = "command -v go"
511before = "cargo build --release -p my-lib-ffi"
512build = "cd packages/go && go build ./..."
513build_release = "cd packages/go && go build -tags release ./..."
514
515[[crates]]
516name = "my-lib"
517sources = ["src/lib.rs"]
518
519[crates.build_commands.go]
520build = "cd packages/go && go build -tags dev ./..."
521"#,
522        )
523        .unwrap();
524
525        let resolved = cfg.resolve().expect("resolve should succeed").remove(0);
526        let build = resolved.build_commands.get("go").expect("go build config");
527        assert_eq!(build.precondition.as_deref(), Some("command -v go"));
528        assert_eq!(
529            build.before.as_ref().unwrap().commands(),
530            vec!["cargo build --release -p my-lib-ffi"]
531        );
532        assert_eq!(
533            build.build.as_ref().unwrap().commands(),
534            vec!["cd packages/go && go build -tags dev ./..."]
535        );
536        assert_eq!(
537            build.build_release.as_ref().unwrap().commands(),
538            vec!["cd packages/go && go build -tags release ./..."]
539        );
540    }
541
542    #[test]
543    fn new_alef_config_resolve_propagates_field_renames() {
544        // Per-language `rename_fields` declared on a `[crates.<lang>]` table must
545        // survive resolution intact — the resolver replaces the per-language
546        // config wholesale rather than merging field-by-field.
547        let cfg: NewAlefConfig = toml::from_str(
548            r#"
549[workspace]
550languages = ["python", "node"]
551
552[[crates]]
553name = "spikard"
554sources = ["src/lib.rs"]
555
556[crates.python]
557module_name = "_spikard"
558
559[crates.python.rename_fields]
560"User.type" = "user_type"
561"User.id" = "identifier"
562
563[crates.node]
564package_name = "@spikard/node"
565
566[crates.node.rename_fields]
567"User.type" = "userType"
568"#,
569        )
570        .unwrap();
571
572        let resolved = cfg.resolve().expect("resolve should succeed");
573        let spikard = &resolved[0];
574
575        let py = spikard.python.as_ref().expect("python config should be present");
576        assert_eq!(py.rename_fields.get("User.type").map(String::as_str), Some("user_type"));
577        assert_eq!(py.rename_fields.get("User.id").map(String::as_str), Some("identifier"));
578
579        let node_cfg = spikard.node.as_ref().expect("node config should be present");
580        assert_eq!(
581            node_cfg.rename_fields.get("User.type").map(String::as_str),
582            Some("userType")
583        );
584    }
585
586    #[test]
587    fn resolve_workspace_lint_default_merged_with_crate_override() {
588        let cfg: NewAlefConfig = toml::from_str(
589            r#"
590[workspace]
591languages = ["python", "node"]
592
593[workspace.lint.python]
594check = "ruff check ."
595
596[workspace.lint.node]
597check = "oxlint ."
598
599[[crates]]
600name = "spikard"
601sources = ["src/lib.rs"]
602
603[crates.lint.python]
604check = "ruff check crates/spikard-py/"
605"#,
606        )
607        .unwrap();
608
609        let resolved = cfg.resolve().expect("resolve should succeed");
610        let spikard = &resolved[0];
611
612        // Per-crate python lint overrides workspace
613        let py_lint = spikard.lint.get("python").expect("python lint should be present");
614        assert_eq!(
615            py_lint.check.as_ref().unwrap().commands(),
616            vec!["ruff check crates/spikard-py/"],
617            "per-crate python lint should win over workspace default"
618        );
619
620        // Workspace node lint is inherited (no per-crate override)
621        let node_lint = spikard.lint.get("node").expect("node lint should be present");
622        assert_eq!(
623            node_lint.check.as_ref().unwrap().commands(),
624            vec!["oxlint ."],
625            "workspace node lint should be inherited when no per-crate override"
626        );
627    }
628
629    #[test]
630    fn resolve_multi_crate_output_paths_use_template() {
631        let cfg = two_crate_config();
632        let resolved = cfg.resolve().expect("resolve should succeed");
633
634        let alpha = resolved.iter().find(|c| c.name == "alpha").unwrap();
635        let beta = resolved.iter().find(|c| c.name == "beta").unwrap();
636
637        assert_eq!(
638            alpha.output_paths.get("python"),
639            Some(&std::path::PathBuf::from("packages/python/alpha/")),
640            "alpha python output path"
641        );
642        assert_eq!(
643            beta.output_paths.get("python"),
644            Some(&std::path::PathBuf::from("packages/python/beta/")),
645            "beta python output path"
646        );
647        assert_eq!(
648            alpha.output_paths.get("node"),
649            Some(&std::path::PathBuf::from("packages/node/alpha/")),
650            "alpha node output path"
651        );
652    }
653
654    #[test]
655    fn resolve_duplicate_crate_name_errors() {
656        let cfg: NewAlefConfig = toml::from_str(
657            r#"
658[workspace]
659languages = ["python"]
660
661[[crates]]
662name = "spikard"
663sources = ["src/lib.rs"]
664
665[[crates]]
666name = "spikard"
667sources = ["src/other.rs"]
668"#,
669        )
670        .unwrap();
671
672        let err = cfg.resolve().unwrap_err();
673        assert!(
674            matches!(err, ResolveError::DuplicateCrateName(ref n) if n == "spikard"),
675            "expected DuplicateCrateName(spikard), got: {err}"
676        );
677    }
678
679    #[test]
680    fn resolve_empty_languages_errors_when_workspace_also_empty() {
681        let cfg: NewAlefConfig = toml::from_str(
682            r#"
683[workspace]
684
685[[crates]]
686name = "spikard"
687sources = ["src/lib.rs"]
688"#,
689        )
690        .unwrap();
691
692        let err = cfg.resolve().unwrap_err();
693        assert!(
694            matches!(err, ResolveError::EmptyLanguages(ref n) if n == "spikard"),
695            "expected EmptyLanguages(spikard), got: {err}"
696        );
697    }
698
699    #[test]
700    fn resolve_overlapping_output_path_errors() {
701        // Both crates have no template and identical names would collide; force
702        // a collision by using an explicit output path on both.
703        let cfg: NewAlefConfig = toml::from_str(
704            r#"
705[workspace]
706languages = ["python"]
707
708[[crates]]
709name = "alpha"
710sources = ["src/lib.rs"]
711
712[crates.output]
713python = "packages/python/shared/"
714
715[[crates]]
716name = "beta"
717sources = ["src/other.rs"]
718
719[crates.output]
720python = "packages/python/shared/"
721"#,
722        )
723        .unwrap();
724
725        let err = cfg.resolve().unwrap_err();
726        assert!(
727            matches!(err, ResolveError::OverlappingOutputPath { ref lang, .. } if lang == "python"),
728            "expected OverlappingOutputPath for python, got: {err}"
729        );
730    }
731
732    #[test]
733    fn resolve_version_from_defaults_to_cargo_toml() {
734        let cfg: NewAlefConfig = toml::from_str(
735            r#"
736[workspace]
737languages = ["python"]
738
739[[crates]]
740name = "spikard"
741sources = ["src/lib.rs"]
742"#,
743        )
744        .unwrap();
745
746        let resolved = cfg.resolve().expect("resolve should succeed");
747        assert_eq!(resolved[0].version_from, "Cargo.toml");
748    }
749
750    #[test]
751    fn resolve_auto_path_mappings_defaults_to_true() {
752        let cfg: NewAlefConfig = toml::from_str(
753            r#"
754[workspace]
755languages = ["python"]
756
757[[crates]]
758name = "spikard"
759sources = ["src/lib.rs"]
760"#,
761        )
762        .unwrap();
763
764        let resolved = cfg.resolve().expect("resolve should succeed");
765        assert!(resolved[0].auto_path_mappings);
766    }
767
768    #[test]
769    fn resolve_workspace_tools_and_dto_flow_through() {
770        let cfg: NewAlefConfig = toml::from_str(
771            r#"
772[workspace]
773languages = ["python"]
774
775[workspace.tools]
776python_package_manager = "uv"
777
778[workspace.opaque_types]
779Tree = "tree_sitter::Tree"
780
781[[crates]]
782name = "spikard"
783sources = ["src/lib.rs"]
784"#,
785        )
786        .unwrap();
787
788        let resolved = cfg.resolve().expect("resolve should succeed");
789        assert_eq!(resolved[0].tools.python_package_manager.as_deref(), Some("uv"));
790        assert_eq!(
791            resolved[0].opaque_types.get("Tree").map(String::as_str),
792            Some("tree_sitter::Tree")
793        );
794    }
795
796    #[test]
797    fn resolve_workspace_generate_format_dto_flow_through_when_crate_unset() {
798        let cfg: NewAlefConfig = toml::from_str(
799            r#"
800[workspace]
801languages = ["python"]
802
803[workspace.generate]
804public_api = false
805bindings = false
806
807[workspace.format]
808enabled = false
809
810[workspace.dto]
811python = "typed-dict"
812node   = "zod"
813
814[[crates]]
815name = "spikard"
816sources = ["src/lib.rs"]
817"#,
818        )
819        .unwrap();
820
821        let resolved = cfg.resolve().expect("resolve should succeed");
822        assert!(
823            !resolved[0].generate.public_api,
824            "workspace generate.public_api must flow through"
825        );
826        assert!(
827            !resolved[0].generate.bindings,
828            "workspace generate.bindings must flow through"
829        );
830        assert!(
831            !resolved[0].format.enabled,
832            "workspace format.enabled must flow through"
833        );
834        assert!(matches!(resolved[0].dto.python, dto::PythonDtoStyle::TypedDict));
835        assert!(matches!(resolved[0].dto.node, dto::NodeDtoStyle::Zod));
836    }
837
838    #[test]
839    fn resolve_per_crate_generate_format_dto_override_workspace() {
840        let cfg: NewAlefConfig = toml::from_str(
841            r#"
842[workspace]
843languages = ["python"]
844
845[workspace.generate]
846public_api = false
847
848[workspace.format]
849enabled = false
850
851[workspace.dto]
852python = "typed-dict"
853
854[[crates]]
855name = "spikard"
856sources = ["src/lib.rs"]
857
858[crates.generate]
859public_api = true
860
861[crates.format]
862enabled = true
863
864[crates.dto]
865python = "dataclass"
866"#,
867        )
868        .unwrap();
869
870        let resolved = cfg.resolve().expect("resolve should succeed");
871        assert!(
872            resolved[0].generate.public_api,
873            "per-crate generate.public_api must override workspace"
874        );
875        assert!(
876            resolved[0].format.enabled,
877            "per-crate format.enabled must override workspace"
878        );
879        assert!(
880            matches!(resolved[0].dto.python, dto::PythonDtoStyle::Dataclass),
881            "per-crate dto.python must override workspace"
882        );
883    }
884
885    #[test]
886    fn resolve_per_crate_explicit_empty_languages_inherits_workspace() {
887        // Explicit `languages = []` per-crate falls back to workspace defaults
888        // (matches the behavior the resolver already implements).
889        let cfg: NewAlefConfig = toml::from_str(
890            r#"
891[workspace]
892languages = ["python", "node"]
893
894[[crates]]
895name = "spikard"
896sources = ["src/lib.rs"]
897languages = []
898"#,
899        )
900        .unwrap();
901
902        let resolved = cfg.resolve().expect("resolve should succeed");
903        assert_eq!(resolved[0].languages, vec![Language::Python, Language::Node]);
904    }
905
906    #[test]
907    fn resolve_per_crate_empty_languages_with_empty_workspace_errors() {
908        let cfg: NewAlefConfig = toml::from_str(
909            r#"
910[[crates]]
911name = "spikard"
912sources = ["src/lib.rs"]
913languages = []
914"#,
915        )
916        .unwrap();
917
918        let err = cfg
919            .resolve()
920            .expect_err("resolve must fail when both per-crate and workspace languages are empty");
921        match err {
922            ResolveError::EmptyLanguages(name) => assert_eq!(name, "spikard"),
923            other => panic!("expected EmptyLanguages, got {other:?}"),
924        }
925    }
926
927    // --- deny_unknown_fields tests ---
928
929    #[test]
930    fn unknown_top_level_key_is_rejected() {
931        // A misspelled key must produce a parse error, not silently succeed with the
932        // field ignored.
933        // typos: ignore start
934        let result: Result<NewAlefConfig, _> = toml::from_str(
935            r#"
936wrkspace = "typo"
937
938[[crates]]
939name = "spikard"
940sources = ["src/lib.rs"]
941"#,
942        );
943        // typos: ignore end
944        assert!(
945            result.is_err(),
946            "unknown top-level key should be rejected by deny_unknown_fields"
947        );
948    }
949
950    // --- new backfill tests ---
951
952    #[test]
953    fn new_alef_config_resolve_rejects_duplicate_crate_name() {
954        let cfg: NewAlefConfig = toml::from_str(
955            r#"
956[workspace]
957languages = ["python"]
958
959[[crates]]
960name = "dup"
961sources = ["src/lib.rs"]
962
963[[crates]]
964name = "dup"
965sources = ["src/other.rs"]
966"#,
967        )
968        .unwrap();
969        let err = cfg.resolve().unwrap_err();
970        assert!(matches!(err, ResolveError::DuplicateCrateName(ref n) if n == "dup"));
971    }
972
973    #[test]
974    fn new_alef_config_resolve_rejects_overlapping_output_paths() {
975        let cfg: NewAlefConfig = toml::from_str(
976            r#"
977[workspace]
978languages = ["python"]
979
980[[crates]]
981name = "a"
982sources = ["src/lib.rs"]
983
984[crates.output]
985python = "packages/python/shared/"
986
987[[crates]]
988name = "b"
989sources = ["src/other.rs"]
990
991[crates.output]
992python = "packages/python/shared/"
993"#,
994        )
995        .unwrap();
996        let err = cfg.resolve().unwrap_err();
997        assert!(matches!(err, ResolveError::OverlappingOutputPath { ref lang, .. } if lang == "python"));
998    }
999
1000    #[test]
1001    fn new_alef_config_resolve_per_crate_languages_overrides_workspace() {
1002        let cfg: NewAlefConfig = toml::from_str(
1003            r#"
1004[workspace]
1005languages = ["python", "go"]
1006
1007[[crates]]
1008name = "x"
1009sources = ["src/lib.rs"]
1010languages = ["node"]
1011"#,
1012        )
1013        .unwrap();
1014        let resolved = cfg.resolve().unwrap();
1015        assert_eq!(resolved[0].languages, vec![Language::Node]);
1016    }
1017}