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