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