Skip to main content

alef_core/config/
mod.rs

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