Skip to main content

alef_core/config/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5pub mod dto;
6pub mod e2e;
7pub mod extras;
8pub mod languages;
9pub mod output;
10pub mod trait_bridge;
11
12// Re-exports for backward compatibility — all types were previously flat in config.rs.
13pub use dto::{
14    CsharpDtoStyle, DtoConfig, ElixirDtoStyle, GoDtoStyle, JavaDtoStyle, NodeDtoStyle, PhpDtoStyle, PythonDtoStyle,
15    RDtoStyle, RubyDtoStyle,
16};
17pub use e2e::E2eConfig;
18pub use extras::{AdapterConfig, AdapterParam, AdapterPattern, Language};
19pub use languages::{
20    CSharpConfig, CustomModulesConfig, CustomRegistration, CustomRegistrationsConfig, ElixirConfig, FfiConfig,
21    GoConfig, JavaConfig, NodeConfig, PhpConfig, PythonConfig, RConfig, RubyConfig, StubsConfig, WasmConfig,
22};
23pub use output::{
24    ExcludeConfig, IncludeConfig, LintConfig, OutputConfig, ReadmeConfig, ScaffoldConfig, SyncConfig, TestConfig,
25    TextReplacement,
26};
27pub use trait_bridge::TraitBridgeConfig;
28
29/// Root configuration from alef.toml.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct AlefConfig {
32    #[serde(rename = "crate")]
33    pub crate_config: CrateConfig,
34    pub languages: Vec<Language>,
35    #[serde(default)]
36    pub exclude: ExcludeConfig,
37    #[serde(default)]
38    pub include: IncludeConfig,
39    #[serde(default)]
40    pub output: OutputConfig,
41    #[serde(default)]
42    pub python: Option<PythonConfig>,
43    #[serde(default)]
44    pub node: Option<NodeConfig>,
45    #[serde(default)]
46    pub ruby: Option<RubyConfig>,
47    #[serde(default)]
48    pub php: Option<PhpConfig>,
49    #[serde(default)]
50    pub elixir: Option<ElixirConfig>,
51    #[serde(default)]
52    pub wasm: Option<WasmConfig>,
53    #[serde(default)]
54    pub ffi: Option<FfiConfig>,
55    #[serde(default)]
56    pub go: Option<GoConfig>,
57    #[serde(default)]
58    pub java: Option<JavaConfig>,
59    #[serde(default)]
60    pub csharp: Option<CSharpConfig>,
61    #[serde(default)]
62    pub r: Option<RConfig>,
63    #[serde(default)]
64    pub scaffold: Option<ScaffoldConfig>,
65    #[serde(default)]
66    pub readme: Option<ReadmeConfig>,
67    #[serde(default)]
68    pub lint: Option<HashMap<String, LintConfig>>,
69    #[serde(default)]
70    pub test: Option<HashMap<String, TestConfig>>,
71    #[serde(default)]
72    pub custom_files: Option<HashMap<String, Vec<PathBuf>>>,
73    #[serde(default)]
74    pub adapters: Vec<AdapterConfig>,
75    #[serde(default)]
76    pub custom_modules: CustomModulesConfig,
77    #[serde(default)]
78    pub custom_registrations: CustomRegistrationsConfig,
79    #[serde(default)]
80    pub sync: Option<SyncConfig>,
81    /// Declare opaque types from external crates that alef can't extract.
82    /// Map of type name → Rust path (e.g., "Tree" = "tree_sitter_language_pack::Tree").
83    /// These get opaque wrapper structs in all backends.
84    #[serde(default)]
85    pub opaque_types: HashMap<String, String>,
86    /// Controls which generation passes alef runs (all default to true).
87    #[serde(default)]
88    pub generate: GenerateConfig,
89    /// Per-language overrides for generate flags (key = language name, e.g., "python").
90    #[serde(default)]
91    pub generate_overrides: HashMap<String, GenerateConfig>,
92    /// Per-language DTO/type generation style (dataclass vs TypedDict, zod vs interface, etc.).
93    #[serde(default)]
94    pub dto: DtoConfig,
95    /// E2E test generation configuration.
96    #[serde(default)]
97    pub e2e: Option<E2eConfig>,
98    /// Trait bridge configurations — generate FFI bridge code that allows
99    /// foreign language objects to implement Rust traits.
100    #[serde(default)]
101    pub trait_bridges: Vec<TraitBridgeConfig>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct CrateConfig {
106    pub name: String,
107    pub sources: Vec<PathBuf>,
108    #[serde(default = "default_version_from")]
109    pub version_from: String,
110    #[serde(default)]
111    pub core_import: Option<String>,
112    /// Optional workspace root path for resolving `pub use` re-exports from sibling crates.
113    #[serde(default)]
114    pub workspace_root: Option<PathBuf>,
115    /// When true, skip adding `use {core_import};` to generated bindings.
116    #[serde(default)]
117    pub skip_core_import: bool,
118    /// The crate's error type name (e.g., `"KreuzbergError"`).
119    /// Used in trait bridge generation for error wrapping.
120    /// Defaults to `"Error"` if not set.
121    #[serde(default)]
122    pub error_type: Option<String>,
123    /// Pattern for constructing error values from a String message in trait bridges.
124    /// `{msg}` is replaced with the format!(...) expression.
125    /// Example: `"KreuzbergError::Plugin { message: {msg}, plugin_name: name.to_string() }"`
126    /// Defaults to `"{error_type}::from({msg})"` if not set.
127    #[serde(default)]
128    pub error_constructor: Option<String>,
129    /// Cargo features that are enabled in binding crates.
130    /// Fields gated by `#[cfg(feature = "...")]` matching these features
131    /// are treated as always-present (cfg stripped from the IR).
132    #[serde(default)]
133    pub features: Vec<String>,
134    /// Maps extracted rust_path prefixes to actual import paths in binding crates.
135    /// Example: { "spikard" = "spikard_http" } rewrites "spikard::ServerConfig" to "spikard_http::ServerConfig"
136    #[serde(default)]
137    pub path_mappings: HashMap<String, String>,
138    /// Additional Cargo dependencies added to ALL binding crate Cargo.tomls.
139    /// Each entry is a crate name mapping to a TOML dependency spec
140    /// (string for version-only, or inline table for path/features/etc.).
141    #[serde(default)]
142    pub extra_dependencies: HashMap<String, toml::Value>,
143    /// When true (default), automatically derive path_mappings from source file locations.
144    /// For each source file matching `crates/{name}/src/`, adds a mapping from
145    /// `{name}` to the configured `core_import`.
146    #[serde(default = "default_true")]
147    pub auto_path_mappings: bool,
148    /// Multi-crate source groups for workspaces with types spread across crates.
149    /// Each entry has a crate `name` and `sources` list. Types extracted from each
150    /// group get `rust_path` reflecting the actual defining crate, not the facade.
151    /// When non-empty, the top-level `sources` field is ignored.
152    #[serde(default)]
153    pub source_crates: Vec<SourceCrate>,
154}
155
156/// A source crate group for multi-crate extraction.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct SourceCrate {
159    /// Crate name (hyphens converted to underscores for rust_path).
160    pub name: String,
161    /// Source files belonging to this crate.
162    pub sources: Vec<PathBuf>,
163}
164
165fn default_version_from() -> String {
166    "Cargo.toml".to_string()
167}
168
169fn default_true() -> bool {
170    true
171}
172
173/// Controls which generation passes alef runs.
174/// All flags default to `true`; set to `false` to skip a pass.
175/// Can be overridden per-language via `[generate_overrides.<lang>]`.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct GenerateConfig {
178    /// Generate low-level struct wrappers, From impls, module init (default: true)
179    #[serde(default = "default_true")]
180    pub bindings: bool,
181    /// Generate error type hierarchies from thiserror enums (default: true)
182    #[serde(default = "default_true")]
183    pub errors: bool,
184    /// Generate config builder constructors from Default types (default: true)
185    #[serde(default = "default_true")]
186    pub configs: bool,
187    /// Generate async/sync function pairs with runtime management (default: true)
188    #[serde(default = "default_true")]
189    pub async_wrappers: bool,
190    /// Generate recursive type marshaling helpers (default: true)
191    #[serde(default = "default_true")]
192    pub type_conversions: bool,
193    /// Generate package manifests (pyproject.toml, package.json, etc.) (default: true)
194    #[serde(default = "default_true")]
195    pub package_metadata: bool,
196    /// Generate idiomatic public API wrappers (default: true)
197    #[serde(default = "default_true")]
198    pub public_api: bool,
199    /// Generate `From<BindingType> for CoreType` reverse conversions (default: true).
200    /// Set to false when the binding layer only returns core types and never accepts them.
201    #[serde(default = "default_true")]
202    pub reverse_conversions: bool,
203}
204
205impl Default for GenerateConfig {
206    fn default() -> Self {
207        Self {
208            bindings: true,
209            errors: true,
210            configs: true,
211            async_wrappers: true,
212            type_conversions: true,
213            package_metadata: true,
214            public_api: true,
215            reverse_conversions: true,
216        }
217    }
218}
219
220// ---------------------------------------------------------------------------
221// Shared config resolution helpers
222// ---------------------------------------------------------------------------
223
224impl AlefConfig {
225    /// Get the features to use for a specific language's binding crate.
226    /// Checks for a per-language override first, then falls back to `[crate] features`.
227    pub fn features_for_language(&self, lang: extras::Language) -> &[String] {
228        let override_features = match lang {
229            extras::Language::Python => self.python.as_ref().and_then(|c| c.features.as_deref()),
230            extras::Language::Node => self.node.as_ref().and_then(|c| c.features.as_deref()),
231            extras::Language::Ruby => self.ruby.as_ref().and_then(|c| c.features.as_deref()),
232            extras::Language::Php => self.php.as_ref().and_then(|c| c.features.as_deref()),
233            extras::Language::Elixir => self.elixir.as_ref().and_then(|c| c.features.as_deref()),
234            extras::Language::Wasm => self.wasm.as_ref().and_then(|c| c.features.as_deref()),
235            extras::Language::Ffi => self.ffi.as_ref().and_then(|c| c.features.as_deref()),
236            extras::Language::Go => self.go.as_ref().and_then(|c| c.features.as_deref()),
237            extras::Language::Java => self.java.as_ref().and_then(|c| c.features.as_deref()),
238            extras::Language::Csharp => self.csharp.as_ref().and_then(|c| c.features.as_deref()),
239            extras::Language::R => self.r.as_ref().and_then(|c| c.features.as_deref()),
240            extras::Language::Rust => None, // Rust doesn't have binding-specific features
241        };
242        override_features.unwrap_or(&self.crate_config.features)
243    }
244
245    /// Get the merged extra dependencies for a specific language's binding crate.
246    /// Merges crate-level `extra_dependencies` with per-language overrides.
247    /// Language-specific entries override crate-level entries with the same key.
248    pub fn extra_deps_for_language(&self, lang: extras::Language) -> HashMap<String, toml::Value> {
249        let mut deps = self.crate_config.extra_dependencies.clone();
250        let lang_deps = match lang {
251            extras::Language::Python => self.python.as_ref().map(|c| &c.extra_dependencies),
252            extras::Language::Node => self.node.as_ref().map(|c| &c.extra_dependencies),
253            extras::Language::Ruby => self.ruby.as_ref().map(|c| &c.extra_dependencies),
254            extras::Language::Php => self.php.as_ref().map(|c| &c.extra_dependencies),
255            extras::Language::Elixir => self.elixir.as_ref().map(|c| &c.extra_dependencies),
256            extras::Language::Wasm => self.wasm.as_ref().map(|c| &c.extra_dependencies),
257            _ => None,
258        };
259        if let Some(lang_deps) = lang_deps {
260            deps.extend(lang_deps.iter().map(|(k, v)| (k.clone(), v.clone())));
261        }
262        deps
263    }
264
265    /// Get the package output directory for a language.
266    /// Uses `scaffold_output` from per-language config if set, otherwise defaults.
267    ///
268    /// Defaults: `packages/python`, `packages/node`, `packages/ruby`, `packages/php`, `packages/elixir`
269    pub fn package_dir(&self, lang: extras::Language) -> String {
270        let override_path = match lang {
271            extras::Language::Python => self.python.as_ref().and_then(|c| c.scaffold_output.as_ref()),
272            extras::Language::Node => self.node.as_ref().and_then(|c| c.scaffold_output.as_ref()),
273            extras::Language::Ruby => self.ruby.as_ref().and_then(|c| c.scaffold_output.as_ref()),
274            extras::Language::Php => self.php.as_ref().and_then(|c| c.scaffold_output.as_ref()),
275            extras::Language::Elixir => self.elixir.as_ref().and_then(|c| c.scaffold_output.as_ref()),
276            _ => None,
277        };
278        if let Some(p) = override_path {
279            p.to_string_lossy().to_string()
280        } else {
281            match lang {
282                extras::Language::Python => "packages/python".to_string(),
283                extras::Language::Node => "packages/node".to_string(),
284                extras::Language::Ruby => "packages/ruby".to_string(),
285                extras::Language::Php => "packages/php".to_string(),
286                extras::Language::Elixir => "packages/elixir".to_string(),
287                _ => format!("packages/{lang}"),
288            }
289        }
290    }
291
292    /// Get the core crate import path (e.g., "liter_llm"). Used by codegen to call into the core crate.
293    pub fn core_import(&self) -> String {
294        self.crate_config
295            .core_import
296            .clone()
297            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
298    }
299
300    /// Get the crate error type name (e.g., "KreuzbergError"). Defaults to "Error".
301    pub fn error_type(&self) -> String {
302        self.crate_config
303            .error_type
304            .clone()
305            .unwrap_or_else(|| "Error".to_string())
306    }
307
308    /// Get the error constructor pattern. `{msg}` is replaced with the message expression.
309    /// Defaults to `"{core_import}::{error_type}::from({msg})"`.
310    pub fn error_constructor(&self) -> String {
311        self.crate_config
312            .error_constructor
313            .clone()
314            .unwrap_or_else(|| format!("{}::{}::from({{msg}})", self.core_import(), self.error_type()))
315    }
316
317    /// Get the FFI prefix (e.g., "kreuzberg"). Used by FFI, Go, Java, C# backends.
318    pub fn ffi_prefix(&self) -> String {
319        self.ffi
320            .as_ref()
321            .and_then(|f| f.prefix.as_ref())
322            .cloned()
323            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
324    }
325
326    /// Get the FFI native library name (for Go cgo, Java Panama, C# P/Invoke).
327    ///
328    /// Resolution order:
329    /// 1. `[ffi] lib_name` explicit override
330    /// 2. Directory name of `output.ffi` path with hyphens replaced by underscores
331    ///    (e.g. `crates/html-to-markdown-ffi/src/` → `html_to_markdown_ffi`)
332    /// 3. `{ffi_prefix}_ffi` fallback
333    pub fn ffi_lib_name(&self) -> String {
334        // 1. Explicit override in [ffi] section.
335        if let Some(name) = self.ffi.as_ref().and_then(|f| f.lib_name.as_ref()) {
336            return name.clone();
337        }
338
339        // 2. Derive from output.ffi path: take the last meaningful directory component
340        //    (skip trailing "src" or similar), then replace hyphens with underscores.
341        if let Some(ffi_path) = self.output.ffi.as_ref() {
342            let path = std::path::Path::new(ffi_path);
343            // Walk components from the end to find the crate directory name.
344            // Skip components like "src" that are inside the crate dir.
345            let components: Vec<_> = path
346                .components()
347                .filter_map(|c| {
348                    if let std::path::Component::Normal(s) = c {
349                        s.to_str()
350                    } else {
351                        None
352                    }
353                })
354                .collect();
355            // The crate name is typically the last component that looks like a crate dir
356            // (i.e. not "src", "lib", or similar). Search from the end.
357            let crate_dir = components
358                .iter()
359                .rev()
360                .find(|&&s| s != "src" && s != "lib" && s != "include")
361                .copied();
362            if let Some(dir) = crate_dir {
363                return dir.replace('-', "_");
364            }
365        }
366
367        // 3. Default fallback.
368        format!("{}_ffi", self.ffi_prefix())
369    }
370
371    /// Get the FFI header name.
372    pub fn ffi_header_name(&self) -> String {
373        self.ffi
374            .as_ref()
375            .and_then(|f| f.header_name.as_ref())
376            .cloned()
377            .unwrap_or_else(|| format!("{}.h", self.ffi_prefix()))
378    }
379
380    /// Get the Python module name.
381    pub fn python_module_name(&self) -> String {
382        self.python
383            .as_ref()
384            .and_then(|p| p.module_name.as_ref())
385            .cloned()
386            .unwrap_or_else(|| format!("_{}", self.crate_config.name.replace('-', "_")))
387    }
388
389    /// Get the PyPI package name used as `[project] name` in `pyproject.toml`.
390    ///
391    /// Returns `[python] pip_name` if set, otherwise falls back to the crate name.
392    pub fn python_pip_name(&self) -> String {
393        self.python
394            .as_ref()
395            .and_then(|p| p.pip_name.as_ref())
396            .cloned()
397            .unwrap_or_else(|| self.crate_config.name.clone())
398    }
399
400    /// Get the PHP Composer autoload namespace derived from the extension name.
401    ///
402    /// Converts the extension name (e.g. `html_to_markdown_rs`) into a
403    /// PSR-4 namespace string (e.g. `Html\\To\\Markdown\\Rs`).
404    pub fn php_autoload_namespace(&self) -> String {
405        use heck::ToPascalCase;
406        let ext = self.php_extension_name();
407        if ext.contains('_') {
408            ext.split('_')
409                .map(|p| p.to_pascal_case())
410                .collect::<Vec<_>>()
411                .join("\\")
412        } else {
413            ext.to_pascal_case()
414        }
415    }
416
417    /// Get the Node package name.
418    pub fn node_package_name(&self) -> String {
419        self.node
420            .as_ref()
421            .and_then(|n| n.package_name.as_ref())
422            .cloned()
423            .unwrap_or_else(|| self.crate_config.name.clone())
424    }
425
426    /// Get the Ruby gem name.
427    pub fn ruby_gem_name(&self) -> String {
428        self.ruby
429            .as_ref()
430            .and_then(|r| r.gem_name.as_ref())
431            .cloned()
432            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
433    }
434
435    /// Get the PHP extension name.
436    pub fn php_extension_name(&self) -> String {
437        self.php
438            .as_ref()
439            .and_then(|p| p.extension_name.as_ref())
440            .cloned()
441            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
442    }
443
444    /// Get the Elixir app name.
445    pub fn elixir_app_name(&self) -> String {
446        self.elixir
447            .as_ref()
448            .and_then(|e| e.app_name.as_ref())
449            .cloned()
450            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
451    }
452
453    /// Get the Go module path.
454    pub fn go_module(&self) -> String {
455        self.go
456            .as_ref()
457            .and_then(|g| g.module.as_ref())
458            .cloned()
459            .unwrap_or_else(|| format!("github.com/kreuzberg-dev/{}", self.crate_config.name))
460    }
461
462    /// Get the GitHub repository URL.
463    ///
464    /// Resolution order:
465    /// 1. `[e2e.registry] github_repo`
466    /// 2. `[scaffold] repository`
467    /// 3. Default: `https://github.com/kreuzberg-dev/{crate.name}`
468    pub fn github_repo(&self) -> String {
469        if let Some(e2e) = &self.e2e {
470            if let Some(url) = &e2e.registry.github_repo {
471                return url.clone();
472            }
473        }
474        self.scaffold
475            .as_ref()
476            .and_then(|s| s.repository.as_ref())
477            .cloned()
478            .unwrap_or_else(|| format!("https://github.com/kreuzberg-dev/{}", self.crate_config.name))
479    }
480
481    /// Get the Java package name.
482    pub fn java_package(&self) -> String {
483        self.java
484            .as_ref()
485            .and_then(|j| j.package.as_ref())
486            .cloned()
487            .unwrap_or_else(|| "dev.kreuzberg".to_string())
488    }
489
490    /// Get the Java Maven groupId.
491    ///
492    /// Uses the full Java package as the groupId, matching Maven convention
493    /// where groupId equals the package declaration.
494    pub fn java_group_id(&self) -> String {
495        self.java_package()
496    }
497
498    /// Get the C# namespace.
499    pub fn csharp_namespace(&self) -> String {
500        self.csharp
501            .as_ref()
502            .and_then(|c| c.namespace.as_ref())
503            .cloned()
504            .unwrap_or_else(|| {
505                use heck::ToPascalCase;
506                self.crate_config.name.to_pascal_case()
507            })
508    }
509
510    /// Get the directory name of the core crate (derived from sources or falling back to name).
511    ///
512    /// For example, if `sources` contains `"crates/html-to-markdown/src/lib.rs"`, this returns
513    /// `"html-to-markdown"`.  Used by the scaffold to generate correct `path = "../../crates/…"`
514    /// references in binding-crate `Cargo.toml` files.
515    pub fn core_crate_dir(&self) -> String {
516        // Try to derive from first source path: "crates/foo/src/types/config.rs" → "foo"
517        // Walk up from the file until we find the "src" directory, then take its parent.
518        if let Some(first_source) = self.crate_config.sources.first() {
519            let path = std::path::Path::new(first_source);
520            let mut current = path.parent();
521            while let Some(dir) = current {
522                if dir.file_name().is_some_and(|n| n == "src") {
523                    if let Some(crate_dir) = dir.parent() {
524                        if let Some(dir_name) = crate_dir.file_name() {
525                            return dir_name.to_string_lossy().into_owned();
526                        }
527                    }
528                    break;
529                }
530                current = dir.parent();
531            }
532        }
533        self.crate_config.name.clone()
534    }
535
536    /// Get the WASM type name prefix (e.g. "Wasm" produces `WasmConversionOptions`).
537    /// Defaults to `"Wasm"`.
538    pub fn wasm_type_prefix(&self) -> String {
539        self.wasm
540            .as_ref()
541            .and_then(|w| w.type_prefix.as_ref())
542            .cloned()
543            .unwrap_or_else(|| "Wasm".to_string())
544    }
545
546    /// Get the Node/NAPI type name prefix (e.g. "Js" produces `JsConversionOptions`).
547    /// Defaults to `"Js"`.
548    pub fn node_type_prefix(&self) -> String {
549        self.node
550            .as_ref()
551            .and_then(|n| n.type_prefix.as_ref())
552            .cloned()
553            .unwrap_or_else(|| "Js".to_string())
554    }
555
556    /// Get the R package name.
557    pub fn r_package_name(&self) -> String {
558        self.r
559            .as_ref()
560            .and_then(|r| r.package_name.as_ref())
561            .cloned()
562            .unwrap_or_else(|| self.crate_config.name.clone())
563    }
564
565    /// Attempt to read the resolved version string from the configured `version_from` file.
566    /// Returns `None` if the file cannot be read or the version cannot be found.
567    pub fn resolved_version(&self) -> Option<String> {
568        let content = std::fs::read_to_string(&self.crate_config.version_from).ok()?;
569        let value: toml::Value = toml::from_str(&content).ok()?;
570        if let Some(v) = value
571            .get("workspace")
572            .and_then(|w| w.get("package"))
573            .and_then(|p| p.get("version"))
574            .and_then(|v| v.as_str())
575        {
576            return Some(v.to_string());
577        }
578        value
579            .get("package")
580            .and_then(|p| p.get("version"))
581            .and_then(|v| v.as_str())
582            .map(|v| v.to_string())
583    }
584
585    /// Get the effective serde rename_all strategy for a given language.
586    ///
587    /// Resolution order:
588    /// 1. Per-language config override (`[python] serde_rename_all = "..."`)
589    /// 2. Language default:
590    ///    - camelCase: node, wasm, java, csharp
591    ///    - snake_case: python, ruby, php, go, ffi, elixir, r
592    pub fn serde_rename_all_for_language(&self, lang: extras::Language) -> String {
593        // 1. Check per-language config override.
594        let override_val = match lang {
595            extras::Language::Python => self.python.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
596            extras::Language::Node => self.node.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
597            extras::Language::Ruby => self.ruby.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
598            extras::Language::Php => self.php.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
599            extras::Language::Elixir => self.elixir.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
600            extras::Language::Wasm => self.wasm.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
601            extras::Language::Ffi => self.ffi.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
602            extras::Language::Go => self.go.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
603            extras::Language::Java => self.java.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
604            extras::Language::Csharp => self.csharp.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
605            extras::Language::R => self.r.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
606            extras::Language::Rust => None, // Rust uses native naming (snake_case)
607        };
608
609        if let Some(val) = override_val {
610            return val.to_string();
611        }
612
613        // 2. Language defaults.
614        match lang {
615            extras::Language::Node | extras::Language::Wasm | extras::Language::Java | extras::Language::Csharp => {
616                "camelCase".to_string()
617            }
618            extras::Language::Python
619            | extras::Language::Ruby
620            | extras::Language::Php
621            | extras::Language::Go
622            | extras::Language::Ffi
623            | extras::Language::Elixir
624            | extras::Language::R
625            | extras::Language::Rust => "snake_case".to_string(),
626        }
627    }
628
629    /// Rewrite a rust_path using path_mappings.
630    /// Matches the longest prefix first.
631    pub fn rewrite_path(&self, rust_path: &str) -> String {
632        // Sort mappings by key length descending (longest prefix first)
633        let mut mappings: Vec<_> = self.crate_config.path_mappings.iter().collect();
634        mappings.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
635
636        for (from, to) in &mappings {
637            if rust_path.starts_with(from.as_str()) {
638                return format!("{}{}", to, &rust_path[from.len()..]);
639            }
640        }
641        rust_path.to_string()
642    }
643
644    /// Return the effective path mappings for this config.
645    ///
646    /// When `auto_path_mappings` is true, automatically derives a mapping from each source
647    /// crate to the configured `core_import` facade.  For each source file whose path contains
648    /// `crates/{crate-name}/src/`, a mapping `{crate_name}` → `{core_import}` is added
649    /// (hyphens in the crate name are converted to underscores).  Source crates that already
650    /// equal `core_import` are skipped.
651    ///
652    /// Explicit entries in `path_mappings` always override auto-derived ones.
653    pub fn effective_path_mappings(&self) -> HashMap<String, String> {
654        let mut mappings = HashMap::new();
655
656        if self.crate_config.auto_path_mappings {
657            let core_import = self.core_import();
658
659            for source in &self.crate_config.sources {
660                let source_str = source.to_string_lossy();
661                // Match `crates/{name}/src/` pattern in the path.
662                if let Some(after_crates) = find_after_crates_prefix(&source_str) {
663                    // Extract the crate directory name (everything before the next `/`).
664                    if let Some(slash_pos) = after_crates.find('/') {
665                        let crate_dir = &after_crates[..slash_pos];
666                        let crate_ident = crate_dir.replace('-', "_");
667                        // Only add a mapping when the source crate differs from the facade.
668                        if crate_ident != core_import && !mappings.contains_key(&crate_ident) {
669                            mappings.insert(crate_ident, core_import.clone());
670                        }
671                    }
672                }
673            }
674        }
675
676        // Explicit path_mappings always win — insert last so they overwrite auto entries.
677        for (from, to) in &self.crate_config.path_mappings {
678            mappings.insert(from.clone(), to.clone());
679        }
680
681        mappings
682    }
683}
684
685/// Find the path segment that comes after a `crates/` component.
686///
687/// Handles both absolute paths (e.g., `/workspace/repo/crates/foo/src/lib.rs`)
688/// and relative paths (e.g., `crates/foo/src/lib.rs`).  Returns the slice
689/// starting immediately after the `crates/` prefix, or `None` if the path
690/// does not contain such a component.
691fn find_after_crates_prefix(path: &str) -> Option<&str> {
692    // Normalise to forward slashes for cross-platform matching.
693    // We search for `/crates/` (with leading slash) first, then fall back to
694    // a leading `crates/` for relative paths that start with that component.
695    if let Some(pos) = path.find("/crates/") {
696        return Some(&path[pos + "/crates/".len()..]);
697    }
698    if let Some(stripped) = path.strip_prefix("crates/") {
699        return Some(stripped);
700    }
701    None
702}
703
704/// Helper function to resolve output directory path from config.
705/// Replaces {name} placeholder with the crate name.
706pub fn resolve_output_dir(config_path: Option<&PathBuf>, crate_name: &str, default: &str) -> String {
707    config_path
708        .map(|p| p.to_string_lossy().replace("{name}", crate_name))
709        .unwrap_or_else(|| default.replace("{name}", crate_name))
710}
711
712/// Detect whether `serde` and `serde_json` are available in a binding crate's Cargo.toml.
713///
714/// `output_dir` is the generated source directory (e.g., `crates/spikard-py/src/`).
715/// The function walks up to find the crate's Cargo.toml and checks its `[dependencies]`
716/// for both `serde` and `serde_json`.
717pub fn detect_serde_available(output_dir: &str) -> bool {
718    let src_path = std::path::Path::new(output_dir);
719    // Walk up from the output dir to find Cargo.toml (usually output_dir is `crates/foo/src/`)
720    let mut dir = src_path;
721    loop {
722        let cargo_toml = dir.join("Cargo.toml");
723        if cargo_toml.exists() {
724            return cargo_toml_has_serde(&cargo_toml);
725        }
726        match dir.parent() {
727            Some(parent) if !parent.as_os_str().is_empty() => dir = parent,
728            _ => break,
729        }
730    }
731    false
732}
733
734/// Check if a Cargo.toml has both `serde` (with derive feature) and `serde_json` in its dependencies.
735///
736/// The `serde::Serialize` derive macro requires `serde` as a direct dependency with the `derive`
737/// feature enabled. Having only `serde_json` is not sufficient since it only pulls in `serde`
738/// transitively without the derive proc-macro.
739fn cargo_toml_has_serde(path: &std::path::Path) -> bool {
740    let content = match std::fs::read_to_string(path) {
741        Ok(c) => c,
742        Err(_) => return false,
743    };
744
745    let has_serde_json = content.contains("serde_json");
746    // Check for `serde` as a direct dependency (not just serde_json).
747    // Must match "serde" as a TOML key, not as a substring of "serde_json".
748    // Valid patterns: `serde = `, `serde.`, `[dependencies.serde]`
749    let has_serde_dep = content.lines().any(|line| {
750        let trimmed = line.trim();
751        // Match `serde = ...` or `serde.workspace = true` etc., but not `serde_json`
752        trimmed.starts_with("serde ")
753            || trimmed.starts_with("serde=")
754            || trimmed.starts_with("serde.")
755            || trimmed == "[dependencies.serde]"
756    });
757
758    has_serde_json && has_serde_dep
759}