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