Skip to main content

alef_core/config/
mod.rs

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