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            _ => None,
257        };
258        if let Some(lang_deps) = lang_deps {
259            deps.extend(lang_deps.iter().map(|(k, v)| (k.clone(), v.clone())));
260        }
261        deps
262    }
263
264    /// Get the core crate import path (e.g., "liter_llm"). Used by codegen to call into the core crate.
265    pub fn core_import(&self) -> String {
266        self.crate_config
267            .core_import
268            .clone()
269            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
270    }
271
272    /// Get the crate error type name (e.g., "KreuzbergError"). Defaults to "Error".
273    pub fn error_type(&self) -> String {
274        self.crate_config
275            .error_type
276            .clone()
277            .unwrap_or_else(|| "Error".to_string())
278    }
279
280    /// Get the error constructor pattern. `{msg}` is replaced with the message expression.
281    /// Defaults to `"{core_import}::{error_type}::from({msg})"`.
282    pub fn error_constructor(&self) -> String {
283        self.crate_config
284            .error_constructor
285            .clone()
286            .unwrap_or_else(|| format!("{}::{}::from({{msg}})", self.core_import(), self.error_type()))
287    }
288
289    /// Get the FFI prefix (e.g., "kreuzberg"). Used by FFI, Go, Java, C# backends.
290    pub fn ffi_prefix(&self) -> String {
291        self.ffi
292            .as_ref()
293            .and_then(|f| f.prefix.as_ref())
294            .cloned()
295            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
296    }
297
298    /// Get the FFI native library name (for Go cgo, Java Panama, C# P/Invoke).
299    ///
300    /// Resolution order:
301    /// 1. `[ffi] lib_name` explicit override
302    /// 2. Directory name of `output.ffi` path with hyphens replaced by underscores
303    ///    (e.g. `crates/html-to-markdown-ffi/src/` → `html_to_markdown_ffi`)
304    /// 3. `{ffi_prefix}_ffi` fallback
305    pub fn ffi_lib_name(&self) -> String {
306        // 1. Explicit override in [ffi] section.
307        if let Some(name) = self.ffi.as_ref().and_then(|f| f.lib_name.as_ref()) {
308            return name.clone();
309        }
310
311        // 2. Derive from output.ffi path: take the last meaningful directory component
312        //    (skip trailing "src" or similar), then replace hyphens with underscores.
313        if let Some(ffi_path) = self.output.ffi.as_ref() {
314            let path = std::path::Path::new(ffi_path);
315            // Walk components from the end to find the crate directory name.
316            // Skip components like "src" that are inside the crate dir.
317            let components: Vec<_> = path
318                .components()
319                .filter_map(|c| {
320                    if let std::path::Component::Normal(s) = c {
321                        s.to_str()
322                    } else {
323                        None
324                    }
325                })
326                .collect();
327            // The crate name is typically the last component that looks like a crate dir
328            // (i.e. not "src", "lib", or similar). Search from the end.
329            let crate_dir = components
330                .iter()
331                .rev()
332                .find(|&&s| s != "src" && s != "lib" && s != "include")
333                .copied();
334            if let Some(dir) = crate_dir {
335                return dir.replace('-', "_");
336            }
337        }
338
339        // 3. Default fallback.
340        format!("{}_ffi", self.ffi_prefix())
341    }
342
343    /// Get the FFI header name.
344    pub fn ffi_header_name(&self) -> String {
345        self.ffi
346            .as_ref()
347            .and_then(|f| f.header_name.as_ref())
348            .cloned()
349            .unwrap_or_else(|| format!("{}.h", self.ffi_prefix()))
350    }
351
352    /// Get the Python module name.
353    pub fn python_module_name(&self) -> String {
354        self.python
355            .as_ref()
356            .and_then(|p| p.module_name.as_ref())
357            .cloned()
358            .unwrap_or_else(|| format!("_{}", self.crate_config.name.replace('-', "_")))
359    }
360
361    /// Get the PyPI package name used as `[project] name` in `pyproject.toml`.
362    ///
363    /// Returns `[python] pip_name` if set, otherwise falls back to the crate name.
364    pub fn python_pip_name(&self) -> String {
365        self.python
366            .as_ref()
367            .and_then(|p| p.pip_name.as_ref())
368            .cloned()
369            .unwrap_or_else(|| self.crate_config.name.clone())
370    }
371
372    /// Get the PHP Composer autoload namespace derived from the extension name.
373    ///
374    /// Converts the extension name (e.g. `html_to_markdown_rs`) into a
375    /// PSR-4 namespace string (e.g. `Html\\To\\Markdown\\Rs`).
376    pub fn php_autoload_namespace(&self) -> String {
377        use heck::ToPascalCase;
378        let ext = self.php_extension_name();
379        if ext.contains('_') {
380            ext.split('_')
381                .map(|p| p.to_pascal_case())
382                .collect::<Vec<_>>()
383                .join("\\")
384        } else {
385            ext.to_pascal_case()
386        }
387    }
388
389    /// Get the Node package name.
390    pub fn node_package_name(&self) -> String {
391        self.node
392            .as_ref()
393            .and_then(|n| n.package_name.as_ref())
394            .cloned()
395            .unwrap_or_else(|| self.crate_config.name.clone())
396    }
397
398    /// Get the Ruby gem name.
399    pub fn ruby_gem_name(&self) -> String {
400        self.ruby
401            .as_ref()
402            .and_then(|r| r.gem_name.as_ref())
403            .cloned()
404            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
405    }
406
407    /// Get the PHP extension name.
408    pub fn php_extension_name(&self) -> String {
409        self.php
410            .as_ref()
411            .and_then(|p| p.extension_name.as_ref())
412            .cloned()
413            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
414    }
415
416    /// Get the Elixir app name.
417    pub fn elixir_app_name(&self) -> String {
418        self.elixir
419            .as_ref()
420            .and_then(|e| e.app_name.as_ref())
421            .cloned()
422            .unwrap_or_else(|| self.crate_config.name.replace('-', "_"))
423    }
424
425    /// Get the Go module path.
426    pub fn go_module(&self) -> String {
427        self.go
428            .as_ref()
429            .and_then(|g| g.module.as_ref())
430            .cloned()
431            .unwrap_or_else(|| format!("github.com/kreuzberg-dev/{}", self.crate_config.name))
432    }
433
434    /// Get the GitHub repository URL.
435    ///
436    /// Resolution order:
437    /// 1. `[e2e.registry] github_repo`
438    /// 2. `[scaffold] repository`
439    /// 3. Default: `https://github.com/kreuzberg-dev/{crate.name}`
440    pub fn github_repo(&self) -> String {
441        if let Some(e2e) = &self.e2e {
442            if let Some(url) = &e2e.registry.github_repo {
443                return url.clone();
444            }
445        }
446        self.scaffold
447            .as_ref()
448            .and_then(|s| s.repository.as_ref())
449            .cloned()
450            .unwrap_or_else(|| format!("https://github.com/kreuzberg-dev/{}", self.crate_config.name))
451    }
452
453    /// Get the Java package name.
454    pub fn java_package(&self) -> String {
455        self.java
456            .as_ref()
457            .and_then(|j| j.package.as_ref())
458            .cloned()
459            .unwrap_or_else(|| "dev.kreuzberg".to_string())
460    }
461
462    /// Get the Java Maven groupId.
463    ///
464    /// Uses the full Java package as the groupId, matching Maven convention
465    /// where groupId equals the package declaration.
466    pub fn java_group_id(&self) -> String {
467        self.java_package()
468    }
469
470    /// Get the C# namespace.
471    pub fn csharp_namespace(&self) -> String {
472        self.csharp
473            .as_ref()
474            .and_then(|c| c.namespace.as_ref())
475            .cloned()
476            .unwrap_or_else(|| {
477                use heck::ToPascalCase;
478                self.crate_config.name.to_pascal_case()
479            })
480    }
481
482    /// Get the directory name of the core crate (derived from sources or falling back to name).
483    ///
484    /// For example, if `sources` contains `"crates/html-to-markdown/src/lib.rs"`, this returns
485    /// `"html-to-markdown"`.  Used by the scaffold to generate correct `path = "../../crates/…"`
486    /// references in binding-crate `Cargo.toml` files.
487    pub fn core_crate_dir(&self) -> String {
488        // Try to derive from first source path: "crates/foo/src/types/config.rs" → "foo"
489        // Walk up from the file until we find the "src" directory, then take its parent.
490        if let Some(first_source) = self.crate_config.sources.first() {
491            let path = std::path::Path::new(first_source);
492            let mut current = path.parent();
493            while let Some(dir) = current {
494                if dir.file_name().is_some_and(|n| n == "src") {
495                    if let Some(crate_dir) = dir.parent() {
496                        if let Some(dir_name) = crate_dir.file_name() {
497                            return dir_name.to_string_lossy().into_owned();
498                        }
499                    }
500                    break;
501                }
502                current = dir.parent();
503            }
504        }
505        self.crate_config.name.clone()
506    }
507
508    /// Get the WASM type name prefix (e.g. "Wasm" produces `WasmConversionOptions`).
509    /// Defaults to `"Wasm"`.
510    pub fn wasm_type_prefix(&self) -> String {
511        self.wasm
512            .as_ref()
513            .and_then(|w| w.type_prefix.as_ref())
514            .cloned()
515            .unwrap_or_else(|| "Wasm".to_string())
516    }
517
518    /// Get the Node/NAPI type name prefix (e.g. "Js" produces `JsConversionOptions`).
519    /// Defaults to `"Js"`.
520    pub fn node_type_prefix(&self) -> String {
521        self.node
522            .as_ref()
523            .and_then(|n| n.type_prefix.as_ref())
524            .cloned()
525            .unwrap_or_else(|| "Js".to_string())
526    }
527
528    /// Get the R package name.
529    pub fn r_package_name(&self) -> String {
530        self.r
531            .as_ref()
532            .and_then(|r| r.package_name.as_ref())
533            .cloned()
534            .unwrap_or_else(|| self.crate_config.name.clone())
535    }
536
537    /// Attempt to read the resolved version string from the configured `version_from` file.
538    /// Returns `None` if the file cannot be read or the version cannot be found.
539    pub fn resolved_version(&self) -> Option<String> {
540        let content = std::fs::read_to_string(&self.crate_config.version_from).ok()?;
541        let value: toml::Value = toml::from_str(&content).ok()?;
542        if let Some(v) = value
543            .get("workspace")
544            .and_then(|w| w.get("package"))
545            .and_then(|p| p.get("version"))
546            .and_then(|v| v.as_str())
547        {
548            return Some(v.to_string());
549        }
550        value
551            .get("package")
552            .and_then(|p| p.get("version"))
553            .and_then(|v| v.as_str())
554            .map(|v| v.to_string())
555    }
556
557    /// Get the effective serde rename_all strategy for a given language.
558    ///
559    /// Resolution order:
560    /// 1. Per-language config override (`[python] serde_rename_all = "..."`)
561    /// 2. Language default:
562    ///    - camelCase: node, wasm, java, csharp
563    ///    - snake_case: python, ruby, php, go, ffi, elixir, r
564    pub fn serde_rename_all_for_language(&self, lang: extras::Language) -> String {
565        // 1. Check per-language config override.
566        let override_val = match lang {
567            extras::Language::Python => self.python.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
568            extras::Language::Node => self.node.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
569            extras::Language::Ruby => self.ruby.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
570            extras::Language::Php => self.php.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
571            extras::Language::Elixir => self.elixir.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
572            extras::Language::Wasm => self.wasm.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
573            extras::Language::Ffi => self.ffi.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
574            extras::Language::Go => self.go.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
575            extras::Language::Java => self.java.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
576            extras::Language::Csharp => self.csharp.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
577            extras::Language::R => self.r.as_ref().and_then(|c| c.serde_rename_all.as_deref()),
578            extras::Language::Rust => None, // Rust uses native naming (snake_case)
579        };
580
581        if let Some(val) = override_val {
582            return val.to_string();
583        }
584
585        // 2. Language defaults.
586        match lang {
587            extras::Language::Node | extras::Language::Wasm | extras::Language::Java | extras::Language::Csharp => {
588                "camelCase".to_string()
589            }
590            extras::Language::Python
591            | extras::Language::Ruby
592            | extras::Language::Php
593            | extras::Language::Go
594            | extras::Language::Ffi
595            | extras::Language::Elixir
596            | extras::Language::R
597            | extras::Language::Rust => "snake_case".to_string(),
598        }
599    }
600
601    /// Rewrite a rust_path using path_mappings.
602    /// Matches the longest prefix first.
603    pub fn rewrite_path(&self, rust_path: &str) -> String {
604        // Sort mappings by key length descending (longest prefix first)
605        let mut mappings: Vec<_> = self.crate_config.path_mappings.iter().collect();
606        mappings.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
607
608        for (from, to) in &mappings {
609            if rust_path.starts_with(from.as_str()) {
610                return format!("{}{}", to, &rust_path[from.len()..]);
611            }
612        }
613        rust_path.to_string()
614    }
615
616    /// Return the effective path mappings for this config.
617    ///
618    /// When `auto_path_mappings` is true, automatically derives a mapping from each source
619    /// crate to the configured `core_import` facade.  For each source file whose path contains
620    /// `crates/{crate-name}/src/`, a mapping `{crate_name}` → `{core_import}` is added
621    /// (hyphens in the crate name are converted to underscores).  Source crates that already
622    /// equal `core_import` are skipped.
623    ///
624    /// Explicit entries in `path_mappings` always override auto-derived ones.
625    pub fn effective_path_mappings(&self) -> HashMap<String, String> {
626        let mut mappings = HashMap::new();
627
628        if self.crate_config.auto_path_mappings {
629            let core_import = self.core_import();
630
631            for source in &self.crate_config.sources {
632                let source_str = source.to_string_lossy();
633                // Match `crates/{name}/src/` pattern in the path.
634                if let Some(after_crates) = find_after_crates_prefix(&source_str) {
635                    // Extract the crate directory name (everything before the next `/`).
636                    if let Some(slash_pos) = after_crates.find('/') {
637                        let crate_dir = &after_crates[..slash_pos];
638                        let crate_ident = crate_dir.replace('-', "_");
639                        // Only add a mapping when the source crate differs from the facade.
640                        if crate_ident != core_import && !mappings.contains_key(&crate_ident) {
641                            mappings.insert(crate_ident, core_import.clone());
642                        }
643                    }
644                }
645            }
646        }
647
648        // Explicit path_mappings always win — insert last so they overwrite auto entries.
649        for (from, to) in &self.crate_config.path_mappings {
650            mappings.insert(from.clone(), to.clone());
651        }
652
653        mappings
654    }
655}
656
657/// Find the path segment that comes after a `crates/` component.
658///
659/// Handles both absolute paths (e.g., `/workspace/repo/crates/foo/src/lib.rs`)
660/// and relative paths (e.g., `crates/foo/src/lib.rs`).  Returns the slice
661/// starting immediately after the `crates/` prefix, or `None` if the path
662/// does not contain such a component.
663fn find_after_crates_prefix(path: &str) -> Option<&str> {
664    // Normalise to forward slashes for cross-platform matching.
665    // We search for `/crates/` (with leading slash) first, then fall back to
666    // a leading `crates/` for relative paths that start with that component.
667    if let Some(pos) = path.find("/crates/") {
668        return Some(&path[pos + "/crates/".len()..]);
669    }
670    if let Some(stripped) = path.strip_prefix("crates/") {
671        return Some(stripped);
672    }
673    None
674}
675
676/// Helper function to resolve output directory path from config.
677/// Replaces {name} placeholder with the crate name.
678pub fn resolve_output_dir(config_path: Option<&PathBuf>, crate_name: &str, default: &str) -> String {
679    config_path
680        .map(|p| p.to_string_lossy().replace("{name}", crate_name))
681        .unwrap_or_else(|| default.replace("{name}", crate_name))
682}
683
684/// Detect whether `serde` and `serde_json` are available in a binding crate's Cargo.toml.
685///
686/// `output_dir` is the generated source directory (e.g., `crates/spikard-py/src/`).
687/// The function walks up to find the crate's Cargo.toml and checks its `[dependencies]`
688/// for both `serde` and `serde_json`.
689pub fn detect_serde_available(output_dir: &str) -> bool {
690    let src_path = std::path::Path::new(output_dir);
691    // Walk up from the output dir to find Cargo.toml (usually output_dir is `crates/foo/src/`)
692    let mut dir = src_path;
693    loop {
694        let cargo_toml = dir.join("Cargo.toml");
695        if cargo_toml.exists() {
696            return cargo_toml_has_serde(&cargo_toml);
697        }
698        match dir.parent() {
699            Some(parent) if !parent.as_os_str().is_empty() => dir = parent,
700            _ => break,
701        }
702    }
703    false
704}
705
706/// Check if a Cargo.toml has both `serde` (with derive feature) and `serde_json` in its dependencies.
707///
708/// The `serde::Serialize` derive macro requires `serde` as a direct dependency with the `derive`
709/// feature enabled. Having only `serde_json` is not sufficient since it only pulls in `serde`
710/// transitively without the derive proc-macro.
711fn cargo_toml_has_serde(path: &std::path::Path) -> bool {
712    let content = match std::fs::read_to_string(path) {
713        Ok(c) => c,
714        Err(_) => return false,
715    };
716
717    let has_serde_json = content.contains("serde_json");
718    // Check for `serde` as a direct dependency (not just serde_json).
719    // Must match "serde" as a TOML key, not as a substring of "serde_json".
720    // Valid patterns: `serde = `, `serde.`, `[dependencies.serde]`
721    let has_serde_dep = content.lines().any(|line| {
722        let trimmed = line.trim();
723        // Match `serde = ...` or `serde.workspace = true` etc., but not `serde_json`
724        trimmed.starts_with("serde ")
725            || trimmed.starts_with("serde=")
726            || trimmed.starts_with("serde.")
727            || trimmed == "[dependencies.serde]"
728    });
729
730    has_serde_json && has_serde_dep
731}