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