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