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