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