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