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