Skip to main content

alef_core/config/
mod.rs

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