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