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