1use 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#[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 #[error("{0}")]
40 InvalidConfig(String),
41}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct NewAlefConfig {
60 #[serde(default)]
62 pub workspace: WorkspaceConfig,
63 pub crates: Vec<RawCrateConfig>,
65}
66
67impl NewAlefConfig {
68 pub fn resolve(&self) -> Result<Vec<ResolvedCrateConfig>, ResolveError> {
79 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 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 let languages: Vec<Language> = match krate.languages.as_deref() {
123 Some(langs) if !langs.is_empty() => langs.to_vec(),
124 Some(_) => {
125 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 let output_paths = resolve_output_paths(krate, &ws.output_template, &languages, multi_crate);
141
142 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 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 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 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 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 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 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 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 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 #[test]
947 fn unknown_top_level_key_is_rejected() {
948 let result: Result<NewAlefConfig, _> = toml::from_str(
952 r#"
953wrkspace = "typo"
954
955[[crates]]
956name = "spikard"
957sources = ["src/lib.rs"]
958"#,
959 );
960 assert!(
962 result.is_err(),
963 "unknown top-level key should be rejected by deny_unknown_fields"
964 );
965 }
966
967 #[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}