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