Skip to main content

alef_core/config/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5pub mod build_defaults;
6pub mod clean_defaults;
7pub mod dto;
8pub mod e2e;
9pub mod extras;
10pub mod languages;
11pub mod lint_defaults;
12pub mod output;
13pub mod publish;
14pub mod setup_defaults;
15pub mod test_defaults;
16pub mod tools;
17pub mod trait_bridge;
18pub mod update_defaults;
19pub mod validation;
20
21// Re-exports for backward compatibility — all types were previously flat in config.rs.
22pub use dto::{
23    CsharpDtoStyle, DtoConfig, ElixirDtoStyle, GoDtoStyle, JavaDtoStyle, NodeDtoStyle, PhpDtoStyle, PythonDtoStyle,
24    RDtoStyle, RubyDtoStyle,
25};
26pub use e2e::E2eConfig;
27pub use extras::{AdapterConfig, AdapterParam, AdapterPattern, Language};
28pub use languages::{
29    CSharpConfig, CustomModulesConfig, CustomRegistration, CustomRegistrationsConfig, DartConfig, DartStyle,
30    ElixirConfig, FfiConfig, GleamConfig, GoConfig, JavaConfig, KotlinConfig, KotlinTarget, NodeConfig, PhpConfig,
31    PythonConfig, RConfig, RubyConfig, StubsConfig, SwiftConfig, WasmConfig, ZigConfig,
32};
33pub use output::{
34    BuildCommandConfig, CleanConfig, ExcludeConfig, IncludeConfig, LintConfig, OutputConfig, ReadmeConfig,
35    ScaffoldConfig, SetupConfig, SyncConfig, TestConfig, TextReplacement, UpdateConfig,
36};
37pub use publish::{PublishConfig, PublishLanguageConfig, VendorMode};
38pub use tools::{DEFAULT_RUST_DEV_TOOLS, LangContext, ToolsConfig, require_tool, require_tools};
39pub use trait_bridge::TraitBridgeConfig;
40
41/// Root configuration from alef.toml.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct AlefConfig {
44    /// Pinned alef CLI version (e.g. "0.7.7"). Used by install-alef to install
45    /// the exact version this project expects.
46    #[serde(default)]
47    pub version: Option<String>,
48    #[serde(rename = "crate")]
49    pub crate_config: CrateConfig,
50    pub languages: Vec<Language>,
51    #[serde(default)]
52    pub exclude: ExcludeConfig,
53    #[serde(default)]
54    pub include: IncludeConfig,
55    #[serde(default)]
56    pub output: OutputConfig,
57    #[serde(default)]
58    pub python: Option<PythonConfig>,
59    #[serde(default)]
60    pub node: Option<NodeConfig>,
61    #[serde(default)]
62    pub ruby: Option<RubyConfig>,
63    #[serde(default)]
64    pub php: Option<PhpConfig>,
65    #[serde(default)]
66    pub elixir: Option<ElixirConfig>,
67    #[serde(default)]
68    pub wasm: Option<WasmConfig>,
69    #[serde(default)]
70    pub ffi: Option<FfiConfig>,
71    #[serde(default)]
72    pub gleam: Option<GleamConfig>,
73    #[serde(default)]
74    pub go: Option<GoConfig>,
75    #[serde(default)]
76    pub java: Option<JavaConfig>,
77    #[serde(default)]
78    pub dart: Option<DartConfig>,
79    #[serde(default)]
80    pub kotlin: Option<KotlinConfig>,
81    #[serde(default)]
82    pub swift: Option<SwiftConfig>,
83    #[serde(default)]
84    pub csharp: Option<CSharpConfig>,
85    #[serde(default)]
86    pub r: Option<RConfig>,
87    #[serde(default)]
88    pub zig: Option<ZigConfig>,
89    #[serde(default)]
90    pub scaffold: Option<ScaffoldConfig>,
91    #[serde(default)]
92    pub readme: Option<ReadmeConfig>,
93    #[serde(default)]
94    pub lint: Option<HashMap<String, LintConfig>>,
95    #[serde(default)]
96    pub update: Option<HashMap<String, UpdateConfig>>,
97    #[serde(default)]
98    pub test: Option<HashMap<String, TestConfig>>,
99    #[serde(default)]
100    pub setup: Option<HashMap<String, SetupConfig>>,
101    #[serde(default)]
102    pub clean: Option<HashMap<String, CleanConfig>>,
103    #[serde(default)]
104    pub build_commands: Option<HashMap<String, BuildCommandConfig>>,
105    /// Publish pipeline configuration (vendoring, packaging, cross-compilation).
106    #[serde(default)]
107    pub publish: Option<PublishConfig>,
108    #[serde(default)]
109    pub custom_files: Option<HashMap<String, Vec<PathBuf>>>,
110    #[serde(default)]
111    pub adapters: Vec<AdapterConfig>,
112    #[serde(default)]
113    pub custom_modules: CustomModulesConfig,
114    #[serde(default)]
115    pub custom_registrations: CustomRegistrationsConfig,
116    #[serde(default)]
117    pub sync: Option<SyncConfig>,
118    /// Declare opaque types from external crates that alef can't extract.
119    /// Map of type name → Rust path (e.g., "Tree" = "tree_sitter_language_pack::Tree").
120    /// These get opaque wrapper structs in all backends.
121    #[serde(default)]
122    pub opaque_types: HashMap<String, String>,
123    /// Controls which generation passes alef runs (all default to true).
124    #[serde(default)]
125    pub generate: GenerateConfig,
126    /// Per-language overrides for generate flags (key = language name, e.g., "python").
127    #[serde(default)]
128    pub generate_overrides: HashMap<String, GenerateConfig>,
129    /// Post-generation formatting configuration (default: enabled for all languages).
130    #[serde(default)]
131    pub format: FormatConfig,
132    /// Per-language formatting overrides (key = language name, e.g., "elixir").
133    #[serde(default)]
134    pub format_overrides: HashMap<String, FormatConfig>,
135    /// Per-language DTO/type generation style (dataclass vs TypedDict, zod vs interface, etc.).
136    #[serde(default)]
137    pub dto: DtoConfig,
138    /// E2E test generation configuration.
139    #[serde(default)]
140    pub e2e: Option<E2eConfig>,
141    /// Trait bridge configurations — generate FFI bridge code that allows
142    /// foreign language objects to implement Rust traits.
143    #[serde(default)]
144    pub trait_bridges: Vec<TraitBridgeConfig>,
145    /// Global tooling preferences — package managers and dev tools used by
146    /// the default per-language pipeline commands. Sensible defaults apply
147    /// when omitted.
148    #[serde(default)]
149    pub tools: ToolsConfig,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct CrateConfig {
154    pub name: String,
155    pub sources: Vec<PathBuf>,
156    #[serde(default = "default_version_from")]
157    pub version_from: String,
158    #[serde(default)]
159    pub core_import: Option<String>,
160    /// Optional workspace root path for resolving `pub use` re-exports from sibling crates.
161    #[serde(default)]
162    pub workspace_root: Option<PathBuf>,
163    /// When true, skip adding `use {core_import};` to generated bindings.
164    #[serde(default)]
165    pub skip_core_import: bool,
166    /// The crate's error type name (e.g., `"KreuzbergError"`).
167    /// Used in trait bridge generation for error wrapping.
168    /// Defaults to `"Error"` if not set.
169    #[serde(default)]
170    pub error_type: Option<String>,
171    /// Pattern for constructing error values from a String message in trait bridges.
172    /// `{msg}` is replaced with the format!(...) expression.
173    /// Example: `"KreuzbergError::Plugin { message: {msg}, plugin_name: name.to_string() }"`
174    /// Defaults to `"{error_type}::from({msg})"` if not set.
175    #[serde(default)]
176    pub error_constructor: Option<String>,
177    /// Cargo features that are enabled in binding crates.
178    /// Fields gated by `#[cfg(feature = "...")]` matching these features
179    /// are treated as always-present (cfg stripped from the IR).
180    #[serde(default)]
181    pub features: Vec<String>,
182    /// Maps extracted rust_path prefixes to actual import paths in binding crates.
183    /// Example: { "spikard" = "spikard_http" } rewrites "spikard::ServerConfig" to "spikard_http::ServerConfig"
184    #[serde(default)]
185    pub path_mappings: HashMap<String, String>,
186    /// Additional Cargo dependencies added to ALL binding crate Cargo.tomls.
187    /// Each entry is a crate name mapping to a TOML dependency spec
188    /// (string for version-only, or inline table for path/features/etc.).
189    #[serde(default)]
190    pub extra_dependencies: HashMap<String, toml::Value>,
191    /// When true (default), automatically derive path_mappings from source file locations.
192    /// For each source file matching `crates/{name}/src/`, adds a mapping from
193    /// `{name}` to the configured `core_import`.
194    #[serde(default = "default_true")]
195    pub auto_path_mappings: bool,
196    /// Multi-crate source groups for workspaces with types spread across crates.
197    /// Each entry has a crate `name` and `sources` list. Types extracted from each
198    /// group get `rust_path` reflecting the actual defining crate, not the facade.
199    /// When non-empty, the top-level `sources` field is ignored.
200    #[serde(default)]
201    pub source_crates: Vec<SourceCrate>,
202}
203
204/// A source crate group for multi-crate extraction.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct SourceCrate {
207    /// Crate name (hyphens converted to underscores for rust_path).
208    pub name: String,
209    /// Source files belonging to this crate.
210    pub sources: Vec<PathBuf>,
211}
212
213fn default_version_from() -> String {
214    "Cargo.toml".to_string()
215}
216
217fn default_true() -> bool {
218    true
219}
220
221/// Controls which generation passes alef runs.
222/// All flags default to `true`; set to `false` to skip a pass.
223/// Can be overridden per-language via `[generate_overrides.<lang>]`.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct GenerateConfig {
226    /// Generate low-level struct wrappers, From impls, module init (default: true)
227    #[serde(default = "default_true")]
228    pub bindings: bool,
229    /// Generate error type hierarchies from thiserror enums (default: true)
230    #[serde(default = "default_true")]
231    pub errors: bool,
232    /// Generate config builder constructors from Default types (default: true)
233    #[serde(default = "default_true")]
234    pub configs: bool,
235    /// Generate async/sync function pairs with runtime management (default: true)
236    #[serde(default = "default_true")]
237    pub async_wrappers: bool,
238    /// Generate recursive type marshaling helpers (default: true)
239    #[serde(default = "default_true")]
240    pub type_conversions: bool,
241    /// Generate package manifests (pyproject.toml, package.json, etc.) (default: true)
242    #[serde(default = "default_true")]
243    pub package_metadata: bool,
244    /// Generate idiomatic public API wrappers (default: true)
245    #[serde(default = "default_true")]
246    pub public_api: bool,
247    /// Generate `From<BindingType> for CoreType` reverse conversions (default: true).
248    /// Set to false when the binding layer only returns core types and never accepts them.
249    #[serde(default = "default_true")]
250    pub reverse_conversions: bool,
251}
252
253impl Default for GenerateConfig {
254    fn default() -> Self {
255        Self {
256            bindings: true,
257            errors: true,
258            configs: true,
259            async_wrappers: true,
260            type_conversions: true,
261            package_metadata: true,
262            public_api: true,
263            reverse_conversions: true,
264        }
265    }
266}
267
268/// Post-generation formatting configuration.
269/// After code generation, alef can automatically run language-native formatters
270/// on the emitted package directories to ensure CI formatter checks pass.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct FormatConfig {
273    /// Enable post-generation formatting (default: true).
274    /// Set to false to skip formatting for all languages, or use per-language
275    /// overrides in `[format.<lang>]` to disable specific formatters.
276    #[serde(default = "default_true")]
277    pub enabled: bool,
278    /// Optional custom command override. If set, this command is run instead
279    /// of the language's default formatter. Must be a shell command string
280    /// (e.g., "prettier --write .").
281    #[serde(default)]
282    pub command: Option<String>,
283}
284
285impl Default for FormatConfig {
286    fn default() -> Self {
287        Self {
288            enabled: true,
289            command: None,
290        }
291    }
292}
293
294// ---------------------------------------------------------------------------
295// Shared config resolution helpers
296// ---------------------------------------------------------------------------
297
298impl AlefConfig {
299    /// Resolve the binding field name for a given language, type, and field.
300    ///
301    /// Resolution order (highest to lowest priority):
302    /// 1. Per-language `rename_fields` map for the key `"TypeName.field_name"`.
303    /// 2. Automatic keyword escaping: if the field name is a reserved keyword in the target
304    ///    language, append `_` (e.g. `class` → `class_`).
305    /// 3. Original field name unchanged.
306    ///
307    /// Returns `Some(escaped_name)` when the field needs renaming, `None` when the original
308    /// name can be used as-is.  Call sites that always need a `String` should use
309    /// `resolve_field_name(...).unwrap_or_else(|| field_name.to_string())`.
310    pub fn resolve_field_name(&self, lang: extras::Language, type_name: &str, field_name: &str) -> Option<String> {
311        // 1. Explicit per-language rename_fields entry.
312        let explicit_key = format!("{type_name}.{field_name}");
313        let explicit = match lang {
314            extras::Language::Python => self.python.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
315            extras::Language::Node => self.node.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
316            extras::Language::Ruby => self.ruby.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
317            extras::Language::Php => self.php.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
318            extras::Language::Elixir => self.elixir.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
319            extras::Language::Wasm => self.wasm.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
320            extras::Language::Ffi => self.ffi.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
321            extras::Language::Gleam => self.gleam.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
322            extras::Language::Go => self.go.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
323            extras::Language::Java => self.java.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
324            extras::Language::Kotlin => self.kotlin.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
325            extras::Language::Csharp => self.csharp.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
326            extras::Language::R => self.r.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
327            extras::Language::Zig => self.zig.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
328            extras::Language::Dart => self.dart.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
329            extras::Language::Swift => self.swift.as_ref().and_then(|c| c.rename_fields.get(&explicit_key)),
330            extras::Language::Rust => None,
331        };
332        if let Some(renamed) = explicit {
333            if renamed != field_name {
334                return Some(renamed.clone());
335            }
336            return None;
337        }
338
339        // 2. Automatic keyword escaping.
340        match lang {
341            extras::Language::Python => crate::keywords::python_safe_name(field_name),
342            // Java and C# use PascalCase for field names so `class` becomes `Class` — no conflict.
343            // Go uses PascalCase for exported fields — no conflict.
344            // JS/TS uses camelCase — `class` becomes `class` but is a JS keyword; Node backend
345            // handles this via js_name attributes at the napi layer. For now only Python is wired.
346            _ => None,
347        }
348    }
349
350    /// Get the features to use for a specific language's binding crate.
351    /// Checks for a per-language override first, then falls back to `[crate] features`.
352    pub fn features_for_language(&self, lang: extras::Language) -> &[String] {
353        let override_features = match lang {
354            extras::Language::Python => self.python.as_ref().and_then(|c| c.features.as_deref()),
355            extras::Language::Node => self.node.as_ref().and_then(|c| c.features.as_deref()),
356            extras::Language::Ruby => self.ruby.as_ref().and_then(|c| c.features.as_deref()),
357            extras::Language::Php => self.php.as_ref().and_then(|c| c.features.as_deref()),
358            extras::Language::Elixir => self.elixir.as_ref().and_then(|c| c.features.as_deref()),
359            extras::Language::Wasm => self.wasm.as_ref().and_then(|c| c.features.as_deref()),
360            extras::Language::Ffi => self.ffi.as_ref().and_then(|c| c.features.as_deref()),
361            extras::Language::Gleam => self.gleam.as_ref().and_then(|c| c.features.as_deref()),
362            extras::Language::Go => self.go.as_ref().and_then(|c| c.features.as_deref()),
363            extras::Language::Java => self.java.as_ref().and_then(|c| c.features.as_deref()),
364            extras::Language::Kotlin => self.kotlin.as_ref().and_then(|c| c.features.as_deref()),
365            extras::Language::Csharp => self.csharp.as_ref().and_then(|c| c.features.as_deref()),
366            extras::Language::R => self.r.as_ref().and_then(|c| c.features.as_deref()),
367            extras::Language::Zig => self.zig.as_ref().and_then(|c| c.features.as_deref()),
368            extras::Language::Dart => self.dart.as_ref().and_then(|c| c.features.as_deref()),
369            extras::Language::Swift => self.swift.as_ref().and_then(|c| c.features.as_deref()),
370            extras::Language::Rust => None, // Rust doesn't have binding-specific features
371        };
372        override_features.unwrap_or(&self.crate_config.features)
373    }
374
375    /// Get the merged extra dependencies for a specific language's binding crate.
376    /// Merges crate-level `extra_dependencies` with per-language overrides.
377    /// Language-specific entries override crate-level entries with the same key.
378    pub fn extra_deps_for_language(&self, lang: extras::Language) -> HashMap<String, toml::Value> {
379        let mut deps = self.crate_config.extra_dependencies.clone();
380        let lang_deps = match lang {
381            extras::Language::Python => self.python.as_ref().map(|c| &c.extra_dependencies),
382            extras::Language::Node => self.node.as_ref().map(|c| &c.extra_dependencies),
383            extras::Language::Ruby => self.ruby.as_ref().map(|c| &c.extra_dependencies),
384            extras::Language::Php => self.php.as_ref().map(|c| &c.extra_dependencies),
385            extras::Language::Elixir => self.elixir.as_ref().map(|c| &c.extra_dependencies),
386            extras::Language::Wasm => self.wasm.as_ref().map(|c| &c.extra_dependencies),
387            _ => None,
388        };
389        if let Some(lang_deps) = lang_deps {
390            deps.extend(lang_deps.iter().map(|(k, v)| (k.clone(), v.clone())));
391        }
392        deps
393    }
394
395    /// Get the package output directory for a language.
396    /// Uses `scaffold_output` from per-language config if set, otherwise defaults.
397    ///
398    /// Defaults: `packages/python`, `packages/node`, `packages/ruby`, `packages/php`, `packages/elixir`
399    pub fn package_dir(&self, lang: extras::Language) -> String {
400        let override_path = match lang {
401            extras::Language::Python => self.python.as_ref().and_then(|c| c.scaffold_output.as_ref()),
402            extras::Language::Node => self.node.as_ref().and_then(|c| c.scaffold_output.as_ref()),
403            extras::Language::Ruby => self.ruby.as_ref().and_then(|c| c.scaffold_output.as_ref()),
404            extras::Language::Php => self.php.as_ref().and_then(|c| c.scaffold_output.as_ref()),
405            extras::Language::Elixir => self.elixir.as_ref().and_then(|c| c.scaffold_output.as_ref()),
406            _ => None,
407        };
408        if let Some(p) = override_path {
409            p.to_string_lossy().to_string()
410        } else {
411            match lang {
412                extras::Language::Python => "packages/python".to_string(),
413                extras::Language::Node => "packages/node".to_string(),
414                extras::Language::Ruby => "packages/ruby".to_string(),
415                extras::Language::Php => "packages/php".to_string(),
416                extras::Language::Elixir => "packages/elixir".to_string(),
417                _ => format!("packages/{lang}"),
418            }
419        }
420    }
421
422    /// Validate user-supplied pipeline overrides.
423    ///
424    /// Custom `[lint|test|build_commands|setup|update|clean].<lang>` tables
425    /// that override a main command field must declare a `precondition`
426    /// so the step degrades gracefully when the underlying tool is missing
427    /// on the user's system. See [`validation::validate`] for details.
428    pub fn validate(&self) -> Result<(), crate::error::AlefError> {
429        validation::validate(self)
430    }
431
432    /// Get the effective lint configuration for a language.
433    ///
434    /// Returns the explicit `[lint.<lang>]` config if present in alef.toml,
435    /// otherwise falls back to sensible defaults for the language.
436    pub fn lint_config_for_language(&self, lang: extras::Language) -> output::LintConfig {
437        if let Some(lint_map) = &self.lint {
438            let lang_str = lang.to_string();
439            if let Some(explicit) = lint_map.get(&lang_str) {
440                return explicit.clone();
441            }
442        }
443        let output_dir = self.package_dir(lang);
444        let run_wrapper = self.run_wrapper_for_language(lang);
445        let extra_lint_paths = self.extra_lint_paths_for_language(lang);
446        let project_file = self.project_file_for_language(lang);
447        let ctx = LangContext {
448            tools: &self.tools,
449            run_wrapper,
450            extra_lint_paths,
451            project_file,
452        };
453        lint_defaults::default_lint_config(lang, &output_dir, &ctx)
454    }
455
456    /// Get the effective update configuration for a language.
457    ///
458    /// Returns the explicit `[update.<lang>]` config if present in alef.toml,
459    /// otherwise falls back to sensible defaults for the language.
460    pub fn update_config_for_language(&self, lang: extras::Language) -> output::UpdateConfig {
461        if let Some(update_map) = &self.update {
462            let lang_str = lang.to_string();
463            if let Some(explicit) = update_map.get(&lang_str) {
464                return explicit.clone();
465            }
466        }
467        let output_dir = self.package_dir(lang);
468        let ctx = LangContext {
469            tools: &self.tools,
470            run_wrapper: None,
471            extra_lint_paths: &[],
472            project_file: None,
473        };
474        update_defaults::default_update_config(lang, &output_dir, &ctx)
475    }
476
477    /// Get the effective test configuration for a language.
478    ///
479    /// Returns the explicit `[test.<lang>]` config if present in alef.toml,
480    /// otherwise falls back to sensible defaults for the language.
481    pub fn test_config_for_language(&self, lang: extras::Language) -> output::TestConfig {
482        if let Some(test_map) = &self.test {
483            let lang_str = lang.to_string();
484            if let Some(explicit) = test_map.get(&lang_str) {
485                return explicit.clone();
486            }
487        }
488        let output_dir = self.package_dir(lang);
489        let run_wrapper = self.run_wrapper_for_language(lang);
490        let project_file = self.project_file_for_language(lang);
491        let ctx = LangContext {
492            tools: &self.tools,
493            run_wrapper,
494            extra_lint_paths: &[],
495            project_file,
496        };
497        test_defaults::default_test_config(lang, &output_dir, &ctx)
498    }
499
500    /// Get the effective setup configuration for a language.
501    ///
502    /// Returns the explicit `[setup.<lang>]` config if present in alef.toml,
503    /// otherwise falls back to sensible defaults for the language.
504    pub fn setup_config_for_language(&self, lang: extras::Language) -> output::SetupConfig {
505        if let Some(setup_map) = &self.setup {
506            let lang_str = lang.to_string();
507            if let Some(explicit) = setup_map.get(&lang_str) {
508                return explicit.clone();
509            }
510        }
511        let output_dir = self.package_dir(lang);
512        let ctx = LangContext {
513            tools: &self.tools,
514            run_wrapper: None,
515            extra_lint_paths: &[],
516            project_file: None,
517        };
518        setup_defaults::default_setup_config(lang, &output_dir, &ctx)
519    }
520
521    /// Get the effective clean configuration for a language.
522    ///
523    /// Returns the explicit `[clean.<lang>]` config if present in alef.toml,
524    /// otherwise falls back to sensible defaults for the language.
525    pub fn clean_config_for_language(&self, lang: extras::Language) -> output::CleanConfig {
526        if let Some(clean_map) = &self.clean {
527            let lang_str = lang.to_string();
528            if let Some(explicit) = clean_map.get(&lang_str) {
529                return explicit.clone();
530            }
531        }
532        let output_dir = self.package_dir(lang);
533        let ctx = LangContext {
534            tools: &self.tools,
535            run_wrapper: None,
536            extra_lint_paths: &[],
537            project_file: None,
538        };
539        clean_defaults::default_clean_config(lang, &output_dir, &ctx)
540    }
541
542    /// Get the effective build command configuration for a language.
543    ///
544    /// Returns the explicit `[build_commands.<lang>]` config if present in alef.toml,
545    /// otherwise falls back to sensible defaults for the language.
546    pub fn build_command_config_for_language(&self, lang: extras::Language) -> output::BuildCommandConfig {
547        if let Some(build_map) = &self.build_commands {
548            let lang_str = lang.to_string();
549            if let Some(explicit) = build_map.get(&lang_str) {
550                return explicit.clone();
551            }
552        }
553        let output_dir = self.package_dir(lang);
554        let crate_name = &self.crate_config.name;
555        let run_wrapper = self.run_wrapper_for_language(lang);
556        let project_file = self.project_file_for_language(lang);
557        let ctx = LangContext {
558            tools: &self.tools,
559            run_wrapper,
560            extra_lint_paths: &[],
561            project_file,
562        };
563        build_defaults::default_build_config(lang, &output_dir, crate_name, &ctx)
564    }
565
566    /// Get the core crate import path (e.g., "liter_llm"). Used by codegen to call into the core crate.
567    pub fn core_import(&self) -> String {
568        self.crate_config
569            .core_import
570            .clone()
571            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
572    }
573
574    /// Get the crate error type name (e.g., "KreuzbergError"). Defaults to "Error".
575    pub fn error_type(&self) -> String {
576        self.crate_config
577            .error_type
578            .clone()
579            .unwrap_or_else(|| "Error".to_string())
580    }
581
582    /// Get the error constructor pattern. `{msg}` is replaced with the message expression.
583    /// Defaults to `"{core_import}::{error_type}::from({msg})"`.
584    pub fn error_constructor(&self) -> String {
585        self.crate_config
586            .error_constructor
587            .clone()
588            .unwrap_or_else(|| format!("{}::{}::from({{msg}})", self.core_import(), self.error_type()))
589    }
590
591    /// Get the run_wrapper for a language, if set.
592    /// Returns the wrapper string that prefixes default tool invocations.
593    pub fn run_wrapper_for_language(&self, lang: extras::Language) -> Option<&str> {
594        match lang {
595            extras::Language::Python => self.python.as_ref().and_then(|c| c.run_wrapper.as_deref()),
596            extras::Language::Node => self.node.as_ref().and_then(|c| c.run_wrapper.as_deref()),
597            extras::Language::Ruby => self.ruby.as_ref().and_then(|c| c.run_wrapper.as_deref()),
598            extras::Language::Php => self.php.as_ref().and_then(|c| c.run_wrapper.as_deref()),
599            extras::Language::Elixir => self.elixir.as_ref().and_then(|c| c.run_wrapper.as_deref()),
600            extras::Language::Wasm => self.wasm.as_ref().and_then(|c| c.run_wrapper.as_deref()),
601            extras::Language::Go => self.go.as_ref().and_then(|c| c.run_wrapper.as_deref()),
602            extras::Language::Java => self.java.as_ref().and_then(|c| c.run_wrapper.as_deref()),
603            extras::Language::Csharp => self.csharp.as_ref().and_then(|c| c.run_wrapper.as_deref()),
604            extras::Language::R => self.r.as_ref().and_then(|c| c.run_wrapper.as_deref()),
605            extras::Language::Kotlin => self.kotlin.as_ref().and_then(|c| c.run_wrapper.as_deref()),
606            extras::Language::Dart => self.dart.as_ref().and_then(|c| c.run_wrapper.as_deref()),
607            extras::Language::Swift => self.swift.as_ref().and_then(|c| c.run_wrapper.as_deref()),
608            extras::Language::Gleam => self.gleam.as_ref().and_then(|c| c.run_wrapper.as_deref()),
609            extras::Language::Zig => self.zig.as_ref().and_then(|c| c.run_wrapper.as_deref()),
610            extras::Language::Ffi | extras::Language::Rust => None,
611        }
612    }
613
614    /// Get the extra_lint_paths for a language.
615    /// Returns a slice of paths to append to default lint commands.
616    pub fn extra_lint_paths_for_language(&self, lang: extras::Language) -> &[String] {
617        match lang {
618            extras::Language::Python => self
619                .python
620                .as_ref()
621                .map(|c| c.extra_lint_paths.as_slice())
622                .unwrap_or(&[]),
623            extras::Language::Node => self.node.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
624            extras::Language::Ruby => self.ruby.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
625            extras::Language::Php => self.php.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
626            extras::Language::Elixir => self
627                .elixir
628                .as_ref()
629                .map(|c| c.extra_lint_paths.as_slice())
630                .unwrap_or(&[]),
631            extras::Language::Wasm => self.wasm.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
632            extras::Language::Go => self.go.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
633            extras::Language::Java => self.java.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
634            extras::Language::Csharp => self
635                .csharp
636                .as_ref()
637                .map(|c| c.extra_lint_paths.as_slice())
638                .unwrap_or(&[]),
639            extras::Language::R => self.r.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
640            extras::Language::Kotlin => self
641                .kotlin
642                .as_ref()
643                .map(|c| c.extra_lint_paths.as_slice())
644                .unwrap_or(&[]),
645            extras::Language::Dart => self.dart.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
646            extras::Language::Swift => self
647                .swift
648                .as_ref()
649                .map(|c| c.extra_lint_paths.as_slice())
650                .unwrap_or(&[]),
651            extras::Language::Gleam => self
652                .gleam
653                .as_ref()
654                .map(|c| c.extra_lint_paths.as_slice())
655                .unwrap_or(&[]),
656            extras::Language::Zig => self.zig.as_ref().map(|c| c.extra_lint_paths.as_slice()).unwrap_or(&[]),
657            extras::Language::Ffi | extras::Language::Rust => &[],
658        }
659    }
660
661    /// Get the project_file for a language (Java or C# only).
662    /// Returns the project file path that defaults use instead of output directory.
663    pub fn project_file_for_language(&self, lang: extras::Language) -> Option<&str> {
664        match lang {
665            extras::Language::Java => self.java.as_ref().and_then(|c| c.project_file.as_deref()),
666            extras::Language::Csharp => self.csharp.as_ref().and_then(|c| c.project_file.as_deref()),
667            _ => None,
668        }
669    }
670
671    /// Get the FFI prefix (e.g., "kreuzberg"). Used by FFI, Go, Java, C# backends.
672    pub fn ffi_prefix(&self) -> String {
673        self.ffi
674            .as_ref()
675            .and_then(|f| f.prefix.as_ref())
676            .cloned()
677            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
678    }
679
680    /// Get the FFI native library name (for Go cgo, Java Panama, C# P/Invoke).
681    ///
682    /// Resolution order:
683    /// 1. `[ffi] lib_name` explicit override
684    /// 2. Directory name of `output.ffi` path with hyphens replaced by underscores
685    ///    (e.g. `crates/html-to-markdown-ffi/src/` → `html_to_markdown_ffi`)
686    /// 3. `{ffi_prefix}_ffi` fallback
687    pub fn ffi_lib_name(&self) -> String {
688        // 1. Explicit override in [ffi] section.
689        if let Some(name) = self.ffi.as_ref().and_then(|f| f.lib_name.as_ref()) {
690            return name.clone();
691        }
692
693        // 2. Derive from output.ffi path: take the last meaningful directory component
694        //    (skip trailing "src" or similar), then replace hyphens with underscores.
695        if let Some(ffi_path) = self.output.ffi.as_ref() {
696            let path = std::path::Path::new(ffi_path);
697            // Walk components from the end to find the crate directory name.
698            // Skip components like "src" that are inside the crate dir.
699            let components: Vec<_> = path
700                .components()
701                .filter_map(|c| {
702                    if let std::path::Component::Normal(s) = c {
703                        s.to_str()
704                    } else {
705                        None
706                    }
707                })
708                .collect();
709            // The crate name is typically the last component that looks like a crate dir
710            // (i.e. not "src", "lib", or similar). Search from the end.
711            let crate_dir = components
712                .iter()
713                .rev()
714                .find(|&&s| s != "src" && s != "lib" && s != "include")
715                .copied();
716            if let Some(dir) = crate_dir {
717                return dir.replace('-', "_");
718            }
719        }
720
721        // 3. Default fallback.
722        format!("{}_ffi", self.ffi_prefix())
723    }
724
725    /// Get the FFI header name.
726    pub fn ffi_header_name(&self) -> String {
727        self.ffi
728            .as_ref()
729            .and_then(|f| f.header_name.as_ref())
730            .cloned()
731            .unwrap_or_else(|| format!("{}.h", self.ffi_prefix()))
732    }
733
734    /// Get the Dart bridging style (`frb` or `ffi`).
735    pub fn dart_style(&self) -> languages::DartStyle {
736        self.dart.as_ref().map(|d| d.style).unwrap_or_default()
737    }
738
739    /// Get the Python module name.
740    pub fn python_module_name(&self) -> String {
741        self.python
742            .as_ref()
743            .and_then(|p| p.module_name.as_ref())
744            .cloned()
745            .unwrap_or_else(|| format!("_{}", self.crate_config.name.replace('-', "_")))
746    }
747
748    /// Get the PyPI package name used as `[project] name` in `pyproject.toml`.
749    ///
750    /// Returns `[python] pip_name` if set, otherwise falls back to the crate name.
751    pub fn python_pip_name(&self) -> String {
752        self.python
753            .as_ref()
754            .and_then(|p| p.pip_name.as_ref())
755            .cloned()
756            .unwrap_or_else(|| self.crate_config.name.clone())
757    }
758
759    /// Get the PHP Composer autoload namespace derived from the extension name.
760    ///
761    /// Converts the extension name (e.g. `html_to_markdown_rs`) into a
762    /// PSR-4 namespace string (e.g. `Html\\To\\Markdown\\Rs`).
763    pub fn php_autoload_namespace(&self) -> String {
764        use heck::ToPascalCase;
765        let ext = self.php_extension_name();
766        if ext.contains('_') {
767            ext.split('_')
768                .map(|p| p.to_pascal_case())
769                .collect::<Vec<_>>()
770                .join("\\")
771        } else {
772            ext.to_pascal_case()
773        }
774    }
775
776    /// Get the Node package name.
777    pub fn node_package_name(&self) -> String {
778        self.node
779            .as_ref()
780            .and_then(|n| n.package_name.as_ref())
781            .cloned()
782            .unwrap_or_else(|| self.crate_config.name.clone())
783    }
784
785    /// Get the Ruby gem name.
786    pub fn ruby_gem_name(&self) -> String {
787        self.ruby
788            .as_ref()
789            .and_then(|r| r.gem_name.as_ref())
790            .cloned()
791            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
792    }
793
794    /// Get the PHP extension name.
795    pub fn php_extension_name(&self) -> String {
796        self.php
797            .as_ref()
798            .and_then(|p| p.extension_name.as_ref())
799            .cloned()
800            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
801    }
802
803    /// Get the Elixir app name.
804    pub fn elixir_app_name(&self) -> String {
805        self.elixir
806            .as_ref()
807            .and_then(|e| e.app_name.as_ref())
808            .cloned()
809            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
810    }
811
812    /// Get the Go module path.
813    pub fn go_module(&self) -> String {
814        self.go
815            .as_ref()
816            .and_then(|g| g.module.as_ref())
817            .cloned()
818            .unwrap_or_else(|| format!("github.com/kreuzberg-dev/{}", self.crate_config.name))
819    }
820
821    /// Get the GitHub repository URL.
822    ///
823    /// Resolution order:
824    /// 1. `[e2e.registry] github_repo`
825    /// 2. `[scaffold] repository`
826    /// 3. Default: `https://github.com/kreuzberg-dev/{crate.name}`
827    pub fn github_repo(&self) -> String {
828        if let Some(e2e) = &self.e2e {
829            if let Some(url) = &e2e.registry.github_repo {
830                return url.clone();
831            }
832        }
833        self.scaffold
834            .as_ref()
835            .and_then(|s| s.repository.as_ref())
836            .cloned()
837            .unwrap_or_else(|| format!("https://github.com/kreuzberg-dev/{}", self.crate_config.name))
838    }
839
840    /// Get the Java package name.
841    pub fn java_package(&self) -> String {
842        self.java
843            .as_ref()
844            .and_then(|j| j.package.as_ref())
845            .cloned()
846            .unwrap_or_else(|| "dev.kreuzberg".to_string())
847    }
848
849    /// Get the Java Maven groupId.
850    ///
851    /// Uses the full Java package as the groupId, matching Maven convention
852    /// where groupId equals the package declaration.
853    pub fn java_group_id(&self) -> String {
854        self.java_package()
855    }
856
857    /// Get the Kotlin package name.
858    pub fn kotlin_package(&self) -> String {
859        self.kotlin
860            .as_ref()
861            .and_then(|k| k.package.as_ref())
862            .cloned()
863            .unwrap_or_else(|| "dev.kreuzberg".to_string())
864    }
865
866    /// Get the Kotlin target platform.
867    ///
868    /// Returns `KotlinTarget::Jvm` (the default) when the `[kotlin]` section is absent or
869    /// `target` is not set.
870    pub fn kotlin_target(&self) -> KotlinTarget {
871        self.kotlin.as_ref().map(|k| k.target).unwrap_or_default()
872    }
873
874    /// Get the Dart pubspec package name.
875    ///
876    /// Returns `[dart] pubspec_name` if set, otherwise derives a snake_case
877    /// name from the crate name by replacing hyphens with underscores.
878    pub fn dart_pubspec_name(&self) -> String {
879        self.dart
880            .as_ref()
881            .and_then(|d| d.pubspec_name.as_ref())
882            .cloned()
883            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
884    }
885
886    /// Get the resolved flutter_rust_bridge version, falling back to
887    /// `template_versions::cargo::FLUTTER_RUST_BRIDGE`.
888    pub fn dart_frb_version(&self) -> String {
889        self.dart
890            .as_ref()
891            .and_then(|d| d.frb_version.as_ref())
892            .cloned()
893            .unwrap_or_else(|| crate::template_versions::cargo::FLUTTER_RUST_BRIDGE.to_string())
894    }
895
896    /// Get the Swift module name.
897    ///
898    /// Returns `[swift] module_name` if configured, otherwise derives a PascalCase
899    /// name from the crate name (e.g. `"my-lib"` → `"MyLib"`).
900    pub fn swift_module(&self) -> String {
901        self.swift
902            .as_ref()
903            .and_then(|s| s.module_name.as_ref())
904            .cloned()
905            .unwrap_or_else(|| {
906                use heck::ToUpperCamelCase;
907                self.crate_config.name.to_upper_camel_case()
908            })
909    }
910
911    /// Get the resolved swift-bridge version, falling back to
912    /// `template_versions::cargo::SWIFT_BRIDGE`.
913    pub fn swift_bridge_version(&self) -> String {
914        self.swift
915            .as_ref()
916            .and_then(|s| s.swift_bridge_version.as_ref())
917            .cloned()
918            .unwrap_or_else(|| crate::template_versions::cargo::SWIFT_BRIDGE.to_string())
919    }
920
921    /// Get the resolved minimum macOS deployment target.
922    pub fn swift_min_macos(&self) -> String {
923        self.swift
924            .as_ref()
925            .and_then(|s| s.min_macos_version.as_ref())
926            .cloned()
927            .unwrap_or_else(|| crate::template_versions::toolchain::SWIFT_MIN_MACOS.to_string())
928    }
929
930    /// Get the resolved minimum iOS deployment target.
931    pub fn swift_min_ios(&self) -> String {
932        self.swift
933            .as_ref()
934            .and_then(|s| s.min_ios_version.as_ref())
935            .cloned()
936            .unwrap_or_else(|| crate::template_versions::toolchain::SWIFT_MIN_IOS.to_string())
937    }
938
939    /// Get the Gleam app name.
940    pub fn gleam_app_name(&self) -> String {
941        self.gleam
942            .as_ref()
943            .and_then(|g| g.app_name.as_ref())
944            .cloned()
945            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
946    }
947
948    /// Get the Gleam NIF module name (Erlang atom for @external(erlang, "<nif>", ...) lookups).
949    /// Defaults to "Elixir.<PascalCase>.Native" to match the atom registered by
950    /// `rustler::init!` in the Rustler backend.
951    pub fn gleam_nif_module(&self) -> String {
952        use heck::ToUpperCamelCase;
953        self.gleam
954            .as_ref()
955            .and_then(|g| g.nif_module.as_ref())
956            .cloned()
957            .unwrap_or_else(|| {
958                let pascal = self
959                    .elixir
960                    .as_ref()
961                    .and_then(|e| e.app_name.as_deref())
962                    .unwrap_or(&self.crate_config.name)
963                    .to_upper_camel_case();
964                format!("Elixir.{pascal}.Native")
965            })
966    }
967
968    /// Get the Zig module name.
969    pub fn zig_module_name(&self) -> String {
970        self.zig
971            .as_ref()
972            .and_then(|z| z.module_name.as_ref())
973            .cloned()
974            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
975    }
976
977    /// Get the C# namespace.
978    pub fn csharp_namespace(&self) -> String {
979        self.csharp
980            .as_ref()
981            .and_then(|c| c.namespace.as_ref())
982            .cloned()
983            .unwrap_or_else(|| {
984                use heck::ToPascalCase;
985                self.crate_config.name.to_pascal_case()
986            })
987    }
988
989    /// Get the directory name of the core crate (derived from sources or falling back to name).
990    ///
991    /// For example, if `sources` contains `"crates/html-to-markdown/src/lib.rs"`, this returns
992    /// `"html-to-markdown"`.  Used by the scaffold to generate correct `path = "../../crates/…"`
993    /// references in binding-crate `Cargo.toml` files.
994    pub fn core_crate_dir(&self) -> String {
995        // Try to derive from first source path: "crates/foo/src/types/config.rs" → "foo"
996        // Walk up from the file until we find the "src" directory, then take its parent.
997        if let Some(first_source) = self.crate_config.sources.first() {
998            let path = std::path::Path::new(first_source);
999            let mut current = path.parent();
1000            while let Some(dir) = current {
1001                if dir.file_name().is_some_and(|n| n == "src") {
1002                    if let Some(crate_dir) = dir.parent() {
1003                        if let Some(dir_name) = crate_dir.file_name() {
1004                            return dir_name.to_string_lossy().into_owned();
1005                        }
1006                    }
1007                    break;
1008                }
1009                current = dir.parent();
1010            }
1011        }
1012        self.crate_config.name.clone()
1013    }
1014
1015    /// Get the WASM type name prefix (e.g. "Wasm" produces `WasmConversionOptions`).
1016    /// Defaults to `"Wasm"`.
1017    pub fn wasm_type_prefix(&self) -> String {
1018        self.wasm
1019            .as_ref()
1020            .and_then(|w| w.type_prefix.as_ref())
1021            .cloned()
1022            .unwrap_or_else(|| "Wasm".to_string())
1023    }
1024
1025    /// Get the Node/NAPI type name prefix (e.g. "Js" produces `JsConversionOptions`).
1026    /// Defaults to `"Js"`.
1027    pub fn node_type_prefix(&self) -> String {
1028        self.node
1029            .as_ref()
1030            .and_then(|n| n.type_prefix.as_ref())
1031            .cloned()
1032            .unwrap_or_else(|| "Js".to_string())
1033    }
1034
1035    /// Get the R package name.
1036    pub fn r_package_name(&self) -> String {
1037        self.r
1038            .as_ref()
1039            .and_then(|r| r.package_name.as_ref())
1040            .cloned()
1041            .unwrap_or_else(|| self.crate_config.name.clone())
1042    }
1043
1044    /// Attempt to read the resolved version string from the configured `version_from` file.
1045    /// Returns `None` if the file cannot be read or the version cannot be found.
1046    pub fn resolved_version(&self) -> Option<String> {
1047        let content = std::fs::read_to_string(&self.crate_config.version_from).ok()?;
1048        let value: toml::Value = toml::from_str(&content).ok()?;
1049        if let Some(v) = value
1050            .get("workspace")
1051            .and_then(|w| w.get("package"))
1052            .and_then(|p| p.get("version"))
1053            .and_then(|v| v.as_str())
1054        {
1055            return Some(v.to_string());
1056        }
1057        value
1058            .get("package")
1059            .and_then(|p| p.get("version"))
1060            .and_then(|v| v.as_str())
1061            .map(|v| v.to_string())
1062    }
1063
1064    /// Get the effective serde rename_all strategy for a given language.
1065    ///
1066    /// Resolution order:
1067    /// 1. Per-language config override (`[python] serde_rename_all = "..."`)
1068    /// 2. Language default:
1069    ///    - camelCase: node, wasm, java, csharp
1070    ///    - snake_case: python, ruby, php, go, ffi, elixir, r, kotlin, gleam, zig
1071    pub fn serde_rename_all_for_language(&self, lang: extras::Language) -> String {
1072        // 1. Check per-language config override.
1073        let override_val = match lang {
1074            extras::Language::Python => self.python.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1075            extras::Language::Node => self.node.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1076            extras::Language::Ruby => self.ruby.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1077            extras::Language::Php => self.php.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1078            extras::Language::Elixir => self.elixir.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1079            extras::Language::Wasm => self.wasm.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1080            extras::Language::Ffi => self.ffi.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1081            extras::Language::Gleam => self.gleam.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1082            extras::Language::Go => self.go.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1083            extras::Language::Java => self.java.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1084            extras::Language::Kotlin => self.kotlin.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1085            extras::Language::Csharp => self.csharp.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1086            extras::Language::R => self.r.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1087            extras::Language::Zig => self.zig.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1088            extras::Language::Dart => self.dart.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1089            extras::Language::Swift => self.swift.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
1090            extras::Language::Rust => None, // Rust uses native naming (snake_case)
1091        };
1092
1093        if let Some(val) = override_val {
1094            return val.to_string();
1095        }
1096
1097        // 2. Language defaults.
1098        match lang {
1099            extras::Language::Node | extras::Language::Wasm | extras::Language::Java | extras::Language::Csharp => {
1100                "camelCase".to_string()
1101            }
1102            extras::Language::Python
1103            | extras::Language::Ruby
1104            | extras::Language::Php
1105            | extras::Language::Go
1106            | extras::Language::Ffi
1107            | extras::Language::Elixir
1108            | extras::Language::R
1109            | extras::Language::Rust
1110            | extras::Language::Kotlin
1111            | extras::Language::Gleam
1112            | extras::Language::Zig
1113            | extras::Language::Swift
1114            | extras::Language::Dart => "snake_case".to_string(),
1115        }
1116    }
1117
1118    /// Rewrite a rust_path using path_mappings.
1119    /// Matches the longest prefix first.
1120    pub fn rewrite_path(&self, rust_path: &str) -> String {
1121        // Sort mappings by key length descending (longest prefix first)
1122        let mut mappings: Vec<_> = self.crate_config.path_mappings.iter().collect();
1123        mappings.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
1124
1125        for (from, to) in &mappings {
1126            if rust_path.starts_with(from.as_str()) {
1127                return format!("{}{}", to, &rust_path[from.len()..]);
1128            }
1129        }
1130        rust_path.to_string()
1131    }
1132
1133    /// Return the effective path mappings for this config.
1134    ///
1135    /// When `auto_path_mappings` is true, automatically derives a mapping from each source
1136    /// crate to the configured `core_import` facade.  For each source file whose path contains
1137    /// `crates/{crate-name}/src/`, a mapping `{crate_name}` → `{core_import}` is added
1138    /// (hyphens in the crate name are converted to underscores).  Source crates that already
1139    /// equal `core_import` are skipped.
1140    ///
1141    /// Explicit entries in `path_mappings` always override auto-derived ones.
1142    pub fn effective_path_mappings(&self) -> HashMap<String, String> {
1143        let mut mappings = HashMap::new();
1144
1145        if self.crate_config.auto_path_mappings {
1146            let core_import = self.core_import();
1147
1148            for source in &self.crate_config.sources {
1149                let source_str = source.to_string_lossy();
1150                // Match `crates/{name}/src/` pattern in the path.
1151                if let Some(after_crates) = find_after_crates_prefix(&source_str) {
1152                    // Extract the crate directory name (everything before the next `/`).
1153                    if let Some(slash_pos) = after_crates.find('/') {
1154                        let crate_dir = &after_crates[..slash_pos];
1155                        let crate_ident = crate_dir.replace('-', "_");
1156                        // Only add a mapping when the source crate differs from the facade.
1157                        if crate_ident != core_import && !mappings.contains_key(&crate_ident) {
1158                            mappings.insert(crate_ident, core_import.clone());
1159                        }
1160                    }
1161                }
1162            }
1163        }
1164
1165        // Explicit path_mappings always win — insert last so they overwrite auto entries.
1166        for (from, to) in &self.crate_config.path_mappings {
1167            mappings.insert(from.clone(), to.clone());
1168        }
1169
1170        mappings
1171    }
1172}
1173
1174/// Find the path segment that comes after a `crates/` component.
1175///
1176/// Handles both absolute paths (e.g., `/workspace/repo/crates/foo/src/lib.rs`)
1177/// and relative paths (e.g., `crates/foo/src/lib.rs`).  Returns the slice
1178/// starting immediately after the `crates/` prefix, or `None` if the path
1179/// does not contain such a component.
1180fn find_after_crates_prefix(path: &str) -> Option<&str> {
1181    // Normalise to forward slashes for cross-platform matching.
1182    // We search for `/crates/` (with leading slash) first, then fall back to
1183    // a leading `crates/` for relative paths that start with that component.
1184    if let Some(pos) = path.find("/crates/") {
1185        return Some(&path[pos + "/crates/".len()..]);
1186    }
1187    if let Some(stripped) = path.strip_prefix("crates/") {
1188        return Some(stripped);
1189    }
1190    None
1191}
1192
1193/// Helper function to resolve output directory path from config.
1194/// Replaces {name} placeholder with the crate name.
1195pub fn resolve_output_dir(config_path: Option<&PathBuf>, crate_name: &str, default: &str) -> String {
1196    config_path
1197        .map(|p| p.to_string_lossy().replace("{name}", crate_name))
1198        .unwrap_or_else(|| default.replace("{name}", crate_name))
1199}
1200
1201/// Detect whether `serde` and `serde_json` are available in a binding crate's Cargo.toml.
1202///
1203/// `output_dir` is the generated source directory (e.g., `crates/spikard-py/src/`).
1204/// The function walks up to find the crate's Cargo.toml and checks its `[dependencies]`
1205/// for both `serde` and `serde_json`.
1206pub fn detect_serde_available(output_dir: &str) -> bool {
1207    let src_path = std::path::Path::new(output_dir);
1208    // Walk up from the output dir to find Cargo.toml (usually output_dir is `crates/foo/src/`)
1209    let mut dir = src_path;
1210    loop {
1211        let cargo_toml = dir.join("Cargo.toml");
1212        if cargo_toml.exists() {
1213            return cargo_toml_has_serde(&cargo_toml);
1214        }
1215        match dir.parent() {
1216            Some(parent) if !parent.as_os_str().is_empty() => dir = parent,
1217            _ => break,
1218        }
1219    }
1220    false
1221}
1222
1223/// Check if a Cargo.toml has both `serde` (with derive feature) and `serde_json` in its dependencies.
1224///
1225/// The `serde::Serialize` derive macro requires `serde` as a direct dependency with the `derive`
1226/// feature enabled. Having only `serde_json` is not sufficient since it only pulls in `serde`
1227/// transitively without the derive proc-macro.
1228fn cargo_toml_has_serde(path: &std::path::Path) -> bool {
1229    let content = match std::fs::read_to_string(path) {
1230        Ok(c) => c,
1231        Err(_) => return false,
1232    };
1233
1234    let has_serde_json = content.contains("serde_json");
1235    // Check for `serde` as a direct dependency (not just serde_json).
1236    // Must match "serde" as a TOML key, not as a substring of "serde_json".
1237    // Valid patterns: `serde = `, `serde.`, `[dependencies.serde]`
1238    let has_serde_dep = content.lines().any(|line| {
1239        let trimmed = line.trim();
1240        // Match `serde = ...` or `serde.workspace = true` etc., but not `serde_json`
1241        trimmed.starts_with("serde ")
1242            || trimmed.starts_with("serde=")
1243            || trimmed.starts_with("serde.")
1244            || trimmed == "[dependencies.serde]"
1245    });
1246
1247    has_serde_json && has_serde_dep
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252    use super::*;
1253
1254    fn minimal_config() -> AlefConfig {
1255        toml::from_str(
1256            r#"
1257languages = ["python", "node", "rust"]
1258
1259[crate]
1260name = "test-lib"
1261sources = ["src/lib.rs"]
1262"#,
1263        )
1264        .unwrap()
1265    }
1266
1267    #[test]
1268    fn lint_config_falls_back_to_defaults() {
1269        let config = minimal_config();
1270        assert!(config.lint.is_none());
1271
1272        let py = config.lint_config_for_language(Language::Python);
1273        assert!(py.format.is_some());
1274        assert!(py.check.is_some());
1275        assert!(py.typecheck.is_some());
1276
1277        let node = config.lint_config_for_language(Language::Node);
1278        assert!(node.format.is_some());
1279        assert!(node.check.is_some());
1280    }
1281
1282    #[test]
1283    fn lint_config_explicit_overrides_default() {
1284        let config: AlefConfig = toml::from_str(
1285            r#"
1286languages = ["python"]
1287
1288[crate]
1289name = "test-lib"
1290sources = ["src/lib.rs"]
1291
1292[lint.python]
1293format = "custom-formatter"
1294check = "custom-checker"
1295"#,
1296        )
1297        .unwrap();
1298
1299        let py = config.lint_config_for_language(Language::Python);
1300        assert_eq!(py.format.unwrap().commands(), vec!["custom-formatter"]);
1301        assert_eq!(py.check.unwrap().commands(), vec!["custom-checker"]);
1302        assert!(py.typecheck.is_none()); // explicit config had no typecheck
1303    }
1304
1305    #[test]
1306    fn lint_config_partial_override_does_not_merge() {
1307        let config: AlefConfig = toml::from_str(
1308            r#"
1309languages = ["python"]
1310
1311[crate]
1312name = "test-lib"
1313sources = ["src/lib.rs"]
1314
1315[lint.python]
1316format = "only-format"
1317"#,
1318        )
1319        .unwrap();
1320
1321        let py = config.lint_config_for_language(Language::Python);
1322        assert_eq!(py.format.unwrap().commands(), vec!["only-format"]);
1323        // Explicit config replaces entirely, no fallback for missing fields
1324        assert!(py.check.is_none());
1325        assert!(py.typecheck.is_none());
1326    }
1327
1328    #[test]
1329    fn lint_config_unconfigured_language_uses_defaults() {
1330        let config: AlefConfig = toml::from_str(
1331            r#"
1332languages = ["python", "node"]
1333
1334[crate]
1335name = "test-lib"
1336sources = ["src/lib.rs"]
1337
1338[lint.python]
1339format = "custom"
1340"#,
1341        )
1342        .unwrap();
1343
1344        // Python uses explicit config
1345        let py = config.lint_config_for_language(Language::Python);
1346        assert_eq!(py.format.unwrap().commands(), vec!["custom"]);
1347
1348        // Node falls back to defaults since not in [lint]
1349        let node = config.lint_config_for_language(Language::Node);
1350        let fmt = node.format.unwrap().commands().join(" ");
1351        assert!(fmt.contains("oxfmt"));
1352    }
1353
1354    #[test]
1355    fn update_config_falls_back_to_defaults() {
1356        let config = minimal_config();
1357        assert!(config.update.is_none());
1358
1359        let py = config.update_config_for_language(Language::Python);
1360        assert!(py.update.is_some());
1361        assert!(py.upgrade.is_some());
1362
1363        let rust = config.update_config_for_language(Language::Rust);
1364        let update = rust.update.unwrap().commands().join(" ");
1365        assert!(update.contains("cargo update"));
1366    }
1367
1368    #[test]
1369    fn update_config_explicit_overrides_default() {
1370        let config: AlefConfig = toml::from_str(
1371            r#"
1372languages = ["rust"]
1373
1374[crate]
1375name = "test-lib"
1376sources = ["src/lib.rs"]
1377
1378[update.rust]
1379update = "my-custom-update"
1380upgrade = ["step1", "step2"]
1381"#,
1382        )
1383        .unwrap();
1384
1385        let rust = config.update_config_for_language(Language::Rust);
1386        assert_eq!(rust.update.unwrap().commands(), vec!["my-custom-update"]);
1387        assert_eq!(rust.upgrade.unwrap().commands(), vec!["step1", "step2"]);
1388    }
1389
1390    #[test]
1391    fn test_config_falls_back_to_defaults() {
1392        let config = minimal_config();
1393        assert!(config.test.is_none());
1394
1395        let py = config.test_config_for_language(Language::Python);
1396        assert!(py.command.is_some());
1397        assert!(py.coverage.is_some());
1398        assert!(py.e2e.is_none());
1399
1400        let rust = config.test_config_for_language(Language::Rust);
1401        let cmd = rust.command.unwrap().commands().join(" ");
1402        assert!(cmd.contains("cargo test"));
1403    }
1404
1405    #[test]
1406    fn test_config_explicit_overrides_default() {
1407        let config: AlefConfig = toml::from_str(
1408            r#"
1409languages = ["python"]
1410
1411[crate]
1412name = "test-lib"
1413sources = ["src/lib.rs"]
1414
1415[test.python]
1416command = "my-custom-test"
1417"#,
1418        )
1419        .unwrap();
1420
1421        let py = config.test_config_for_language(Language::Python);
1422        assert_eq!(py.command.unwrap().commands(), vec!["my-custom-test"]);
1423        assert!(py.coverage.is_none()); // explicit config had no coverage
1424    }
1425
1426    #[test]
1427    fn setup_config_falls_back_to_defaults() {
1428        let config = minimal_config();
1429        assert!(config.setup.is_none());
1430
1431        let py = config.setup_config_for_language(Language::Python);
1432        assert!(py.install.is_some());
1433        let install = py.install.unwrap().commands().join(" ");
1434        assert!(install.contains("uv sync"));
1435
1436        let rust = config.setup_config_for_language(Language::Rust);
1437        let install = rust.install.unwrap().commands().join(" ");
1438        assert!(install.contains("rustup update"));
1439    }
1440
1441    #[test]
1442    fn setup_config_explicit_overrides_default() {
1443        let config: AlefConfig = toml::from_str(
1444            r#"
1445languages = ["python"]
1446
1447[crate]
1448name = "test-lib"
1449sources = ["src/lib.rs"]
1450
1451[setup.python]
1452install = "my-custom-install"
1453"#,
1454        )
1455        .unwrap();
1456
1457        let py = config.setup_config_for_language(Language::Python);
1458        assert_eq!(py.install.unwrap().commands(), vec!["my-custom-install"]);
1459    }
1460
1461    #[test]
1462    fn clean_config_falls_back_to_defaults() {
1463        let config = minimal_config();
1464        assert!(config.clean.is_none());
1465
1466        let py = config.clean_config_for_language(Language::Python);
1467        assert!(py.clean.is_some());
1468        let clean = py.clean.unwrap().commands().join(" ");
1469        assert!(clean.contains("__pycache__"));
1470
1471        let rust = config.clean_config_for_language(Language::Rust);
1472        let clean = rust.clean.unwrap().commands().join(" ");
1473        assert!(clean.contains("cargo clean"));
1474    }
1475
1476    #[test]
1477    fn clean_config_explicit_overrides_default() {
1478        let config: AlefConfig = toml::from_str(
1479            r#"
1480languages = ["rust"]
1481
1482[crate]
1483name = "test-lib"
1484sources = ["src/lib.rs"]
1485
1486[clean.rust]
1487clean = "my-custom-clean"
1488"#,
1489        )
1490        .unwrap();
1491
1492        let rust = config.clean_config_for_language(Language::Rust);
1493        assert_eq!(rust.clean.unwrap().commands(), vec!["my-custom-clean"]);
1494    }
1495
1496    #[test]
1497    fn build_command_config_falls_back_to_defaults() {
1498        let config = minimal_config();
1499        assert!(config.build_commands.is_none());
1500
1501        let py = config.build_command_config_for_language(Language::Python);
1502        assert!(py.build.is_some());
1503        assert!(py.build_release.is_some());
1504        let build = py.build.unwrap().commands().join(" ");
1505        assert!(build.contains("maturin develop"));
1506
1507        let rust = config.build_command_config_for_language(Language::Rust);
1508        let build = rust.build.unwrap().commands().join(" ");
1509        assert!(build.contains("cargo build --workspace"));
1510    }
1511
1512    #[test]
1513    fn build_command_config_explicit_overrides_default() {
1514        let config: AlefConfig = toml::from_str(
1515            r#"
1516languages = ["rust"]
1517
1518[crate]
1519name = "test-lib"
1520sources = ["src/lib.rs"]
1521
1522[build_commands.rust]
1523build = "my-custom-build"
1524build_release = "my-custom-build --release"
1525"#,
1526        )
1527        .unwrap();
1528
1529        let rust = config.build_command_config_for_language(Language::Rust);
1530        assert_eq!(rust.build.unwrap().commands(), vec!["my-custom-build"]);
1531        assert_eq!(
1532            rust.build_release.unwrap().commands(),
1533            vec!["my-custom-build --release"]
1534        );
1535    }
1536
1537    #[test]
1538    fn build_command_config_uses_crate_name() {
1539        let config = minimal_config();
1540        let py = config.build_command_config_for_language(Language::Python);
1541        let build = py.build.unwrap().commands().join(" ");
1542        assert!(
1543            build.contains("test-lib-py"),
1544            "Python build should reference crate name, got: {build}"
1545        );
1546    }
1547
1548    #[test]
1549    fn package_dir_defaults_are_correct() {
1550        let config = minimal_config();
1551        assert_eq!(config.package_dir(Language::Python), "packages/python");
1552        assert_eq!(config.package_dir(Language::Node), "packages/node");
1553        assert_eq!(config.package_dir(Language::Ruby), "packages/ruby");
1554        assert_eq!(config.package_dir(Language::Go), "packages/go");
1555        assert_eq!(config.package_dir(Language::Java), "packages/java");
1556    }
1557
1558    #[test]
1559    fn explicit_lint_config_preserves_precondition_and_before() {
1560        let config: AlefConfig = toml::from_str(
1561            r#"
1562languages = ["go"]
1563
1564[crate]
1565name = "test"
1566sources = ["src/lib.rs"]
1567
1568[lint.go]
1569precondition = "test -f target/release/libtest_ffi.so"
1570before = "cargo build --release -p test-ffi"
1571format = "gofmt -w packages/go"
1572check = "golangci-lint run ./..."
1573"#,
1574        )
1575        .unwrap();
1576
1577        let lint = config.lint_config_for_language(Language::Go);
1578        assert_eq!(
1579            lint.precondition.as_deref(),
1580            Some("test -f target/release/libtest_ffi.so"),
1581            "precondition should be preserved from explicit config"
1582        );
1583        assert_eq!(
1584            lint.before.unwrap().commands(),
1585            vec!["cargo build --release -p test-ffi"],
1586            "before should be preserved from explicit config"
1587        );
1588    }
1589
1590    #[test]
1591    fn explicit_lint_config_with_before_list_preserves_all_commands() {
1592        let config: AlefConfig = toml::from_str(
1593            r#"
1594languages = ["go"]
1595
1596[crate]
1597name = "test"
1598sources = ["src/lib.rs"]
1599
1600[lint.go]
1601before = ["cargo build --release -p test-ffi", "cp target/release/libtest_ffi.so packages/go/"]
1602check = "golangci-lint run ./..."
1603"#,
1604        )
1605        .unwrap();
1606
1607        let lint = config.lint_config_for_language(Language::Go);
1608        assert!(lint.precondition.is_none(), "precondition should be None when not set");
1609        assert_eq!(
1610            lint.before.unwrap().commands(),
1611            vec![
1612                "cargo build --release -p test-ffi",
1613                "cp target/release/libtest_ffi.so packages/go/"
1614            ],
1615            "before list should be preserved from explicit config"
1616        );
1617    }
1618
1619    #[test]
1620    fn default_lint_config_has_command_v_precondition() {
1621        let config = minimal_config();
1622        let py = config.lint_config_for_language(Language::Python);
1623        assert_eq!(py.precondition.as_deref(), Some("command -v ruff >/dev/null 2>&1"));
1624        assert!(py.before.is_none(), "default lint config should have no before");
1625
1626        let go = config.lint_config_for_language(Language::Go);
1627        assert_eq!(go.precondition.as_deref(), Some("command -v gofmt >/dev/null 2>&1"));
1628        assert!(go.before.is_none(), "default Go lint config should have no before");
1629    }
1630
1631    #[test]
1632    fn explicit_test_config_preserves_precondition_and_before() {
1633        let config: AlefConfig = toml::from_str(
1634            r#"
1635languages = ["python"]
1636
1637[crate]
1638name = "test"
1639sources = ["src/lib.rs"]
1640
1641[test.python]
1642precondition = "test -f target/release/libtest.so"
1643before = "maturin develop"
1644command = "pytest"
1645"#,
1646        )
1647        .unwrap();
1648
1649        let test = config.test_config_for_language(Language::Python);
1650        assert_eq!(
1651            test.precondition.as_deref(),
1652            Some("test -f target/release/libtest.so"),
1653            "test precondition should be preserved"
1654        );
1655        assert_eq!(
1656            test.before.unwrap().commands(),
1657            vec!["maturin develop"],
1658            "test before should be preserved"
1659        );
1660    }
1661
1662    #[test]
1663    fn default_test_config_has_command_v_precondition() {
1664        let config = minimal_config();
1665        let py = config.test_config_for_language(Language::Python);
1666        assert_eq!(py.precondition.as_deref(), Some("command -v uv >/dev/null 2>&1"));
1667        assert!(py.before.is_none(), "default test config should have no before");
1668    }
1669
1670    #[test]
1671    fn explicit_setup_config_preserves_precondition_and_before() {
1672        let config: AlefConfig = toml::from_str(
1673            r#"
1674languages = ["python"]
1675
1676[crate]
1677name = "test"
1678sources = ["src/lib.rs"]
1679
1680[setup.python]
1681precondition = "which uv"
1682before = "pip install uv"
1683install = "uv sync"
1684"#,
1685        )
1686        .unwrap();
1687
1688        let setup = config.setup_config_for_language(Language::Python);
1689        assert_eq!(
1690            setup.precondition.as_deref(),
1691            Some("which uv"),
1692            "setup precondition should be preserved"
1693        );
1694        assert_eq!(
1695            setup.before.unwrap().commands(),
1696            vec!["pip install uv"],
1697            "setup before should be preserved"
1698        );
1699    }
1700
1701    #[test]
1702    fn default_setup_config_has_command_v_precondition() {
1703        let config = minimal_config();
1704        let py = config.setup_config_for_language(Language::Python);
1705        assert_eq!(py.precondition.as_deref(), Some("command -v uv >/dev/null 2>&1"));
1706        assert!(py.before.is_none(), "default setup config should have no before");
1707    }
1708
1709    #[test]
1710    fn explicit_update_config_preserves_precondition_and_before() {
1711        let config: AlefConfig = toml::from_str(
1712            r#"
1713languages = ["rust"]
1714
1715[crate]
1716name = "test"
1717sources = ["src/lib.rs"]
1718
1719[update.rust]
1720precondition = "test -f Cargo.lock"
1721before = "cargo fetch"
1722update = "cargo update"
1723"#,
1724        )
1725        .unwrap();
1726
1727        let update = config.update_config_for_language(Language::Rust);
1728        assert_eq!(
1729            update.precondition.as_deref(),
1730            Some("test -f Cargo.lock"),
1731            "update precondition should be preserved"
1732        );
1733        assert_eq!(
1734            update.before.unwrap().commands(),
1735            vec!["cargo fetch"],
1736            "update before should be preserved"
1737        );
1738    }
1739
1740    #[test]
1741    fn default_update_config_has_command_v_precondition() {
1742        let config = minimal_config();
1743        let rust = config.update_config_for_language(Language::Rust);
1744        assert_eq!(rust.precondition.as_deref(), Some("command -v cargo >/dev/null 2>&1"));
1745        assert!(rust.before.is_none(), "default update config should have no before");
1746    }
1747
1748    #[test]
1749    fn explicit_clean_config_preserves_precondition_and_before() {
1750        let config: AlefConfig = toml::from_str(
1751            r#"
1752languages = ["rust"]
1753
1754[crate]
1755name = "test"
1756sources = ["src/lib.rs"]
1757
1758[clean.rust]
1759precondition = "test -d target"
1760before = "echo cleaning"
1761clean = "cargo clean"
1762"#,
1763        )
1764        .unwrap();
1765
1766        let clean = config.clean_config_for_language(Language::Rust);
1767        assert_eq!(
1768            clean.precondition.as_deref(),
1769            Some("test -d target"),
1770            "clean precondition should be preserved"
1771        );
1772        assert_eq!(
1773            clean.before.unwrap().commands(),
1774            vec!["echo cleaning"],
1775            "clean before should be preserved"
1776        );
1777    }
1778
1779    #[test]
1780    fn default_clean_config_precondition_matches_toolchain_use() {
1781        let config = minimal_config();
1782        // Rust clean uses `cargo clean` → precondition guards on cargo.
1783        let rust = config.clean_config_for_language(Language::Rust);
1784        assert_eq!(rust.precondition.as_deref(), Some("command -v cargo >/dev/null 2>&1"));
1785        assert!(rust.before.is_none(), "default clean config should have no before");
1786
1787        // Python clean is pure shell `rm -rf …` → no precondition needed.
1788        let py = config.clean_config_for_language(Language::Python);
1789        assert!(
1790            py.precondition.is_none(),
1791            "pure-shell clean should not have a precondition"
1792        );
1793    }
1794
1795    #[test]
1796    fn explicit_build_command_config_preserves_precondition_and_before() {
1797        let config: AlefConfig = toml::from_str(
1798            r#"
1799languages = ["go"]
1800
1801[crate]
1802name = "test"
1803sources = ["src/lib.rs"]
1804
1805[build_commands.go]
1806precondition = "which go"
1807before = "cargo build --release -p test-ffi"
1808build = "cd packages/go && go build ./..."
1809build_release = "cd packages/go && go build -ldflags='-s -w' ./..."
1810"#,
1811        )
1812        .unwrap();
1813
1814        let build = config.build_command_config_for_language(Language::Go);
1815        assert_eq!(
1816            build.precondition.as_deref(),
1817            Some("which go"),
1818            "build precondition should be preserved"
1819        );
1820        assert_eq!(
1821            build.before.unwrap().commands(),
1822            vec!["cargo build --release -p test-ffi"],
1823            "build before should be preserved"
1824        );
1825    }
1826
1827    #[test]
1828    fn default_build_command_config_has_command_v_precondition() {
1829        let config = minimal_config();
1830        let rust = config.build_command_config_for_language(Language::Rust);
1831        assert_eq!(rust.precondition.as_deref(), Some("command -v cargo >/dev/null 2>&1"));
1832        assert!(
1833            rust.before.is_none(),
1834            "default build command config should have no before"
1835        );
1836    }
1837
1838    #[test]
1839    fn version_defaults_to_none_when_omitted() {
1840        let config = minimal_config();
1841        assert!(config.version.is_none());
1842    }
1843
1844    #[test]
1845    fn version_parses_from_top_level_key() {
1846        let config: AlefConfig = toml::from_str(
1847            r#"
1848version = "0.7.7"
1849languages = ["python"]
1850
1851[crate]
1852name = "test-lib"
1853sources = ["src/lib.rs"]
1854"#,
1855        )
1856        .unwrap();
1857        assert_eq!(config.version.as_deref(), Some("0.7.7"));
1858    }
1859}