Skip to main content

alef_core/config/resolved/
ffi.rs

1//! FFI-related methods for `ResolvedCrateConfig`.
2
3use super::ResolvedCrateConfig;
4
5impl ResolvedCrateConfig {
6    /// Get the FFI prefix (e.g., `"kreuzberg"`). Used by FFI, Go, Java, C# backends.
7    ///
8    /// Returns `[ffi] prefix` if set, otherwise derives from the crate name by
9    /// replacing hyphens with underscores.
10    pub fn ffi_prefix(&self) -> String {
11        self.ffi
12            .as_ref()
13            .and_then(|f| f.prefix.as_ref())
14            .cloned()
15            .unwrap_or_else(|| self.name.replace('-', "_"))
16    }
17
18    /// Get the FFI native library name (for Go cgo, Java Panama, C# P/Invoke).
19    ///
20    /// Resolution order:
21    /// 1. `[ffi] lib_name` explicit override
22    /// 2. Directory name of the user-supplied `[crates.output] ffi` path with
23    ///    hyphens replaced by underscores (e.g. `crates/html-to-markdown-ffi/src/`
24    ///    → `html_to_markdown_ffi`). Walks components from the end and skips
25    ///    `src`/`lib`/`include` to find the crate directory.
26    /// 3. `{ffi_prefix}_ffi` fallback
27    pub fn ffi_lib_name(&self) -> String {
28        // 1. Explicit override in [ffi] section.
29        if let Some(name) = self.ffi.as_ref().and_then(|f| f.lib_name.as_ref()) {
30            return name.clone();
31        }
32
33        // 2. Derive from the user-supplied `[crates.output] ffi` path. We use
34        //    `explicit_output` (the raw user input) — NOT `output_paths` — so a
35        //    template-derived FFI dir does not accidentally drive the lib name.
36        if let Some(ffi_path) = self.explicit_output.ffi.as_ref() {
37            let crate_dir = ffi_path
38                .components()
39                .filter_map(|c| {
40                    if let std::path::Component::Normal(s) = c {
41                        s.to_str()
42                    } else {
43                        None
44                    }
45                })
46                .rev()
47                .find(|&s| s != "src" && s != "lib" && s != "include");
48            if let Some(dir) = crate_dir {
49                return dir.replace('-', "_");
50            }
51        }
52
53        // 3. Default fallback.
54        format!("{}_ffi", self.ffi_prefix())
55    }
56
57    /// Get the FFI header name.
58    ///
59    /// Returns `[ffi] header_name` if set, otherwise `"{ffi_prefix}.h"`.
60    pub fn ffi_header_name(&self) -> String {
61        self.ffi
62            .as_ref()
63            .and_then(|f| f.header_name.as_ref())
64            .cloned()
65            .unwrap_or_else(|| format!("{}.h", self.ffi_prefix()))
66    }
67
68    /// Get the relative path to the FFI crate from the e2e test directory.
69    ///
70    /// Used by C e2e tests to locate the compiled FFI library when building
71    /// against a local checkout rather than a downloaded release.
72    ///
73    /// Resolution order:
74    /// 1. Directory name of the user-supplied `[crates.output] ffi` path,
75    ///    skipping trailing `src`/`lib`/`include` components, prefixed with
76    ///    `../../` so the path resolves from `e2e/c/` back to the repo root.
77    ///    E.g. `crates/my-lib-ffi/src/` → `../../crates/my-lib-ffi`.
78    /// 2. `../../crates/{name}-ffi` fallback derived from the crate name.
79    pub fn ffi_crate_path(&self) -> String {
80        if let Some(ffi_path) = self.explicit_output.ffi.as_ref() {
81            // Walk path components from the end, skipping src/lib/include.
82            let components: Vec<&str> = ffi_path
83                .components()
84                .filter_map(|c| {
85                    if let std::path::Component::Normal(s) = c {
86                        s.to_str()
87                    } else {
88                        None
89                    }
90                })
91                .collect();
92            // Find the crate directory component (first non-leaf from the right
93            // after skipping src/lib/include).
94            if let Some(idx) = components
95                .iter()
96                .rposition(|&s| s != "src" && s != "lib" && s != "include")
97            {
98                // Reconstruct the path up to and including this component.
99                let meaningful = &components[..=idx];
100                return format!("../../{}", meaningful.join("/"));
101            }
102        }
103        format!("../../crates/{}-ffi", self.name)
104    }
105
106    /// Get the relative path to the WASM crate's `pkg/` directory from the
107    /// e2e test directory.
108    ///
109    /// Used by WASM e2e tests to import the wasm-pack build output when
110    /// working against a local checkout rather than a published npm package.
111    ///
112    /// Resolution order:
113    /// 1. Directory name of the user-supplied `[crates.output] wasm` path,
114    ///    skipping trailing `src`/`lib`/`include` components, prefixed with
115    ///    `../../` and suffixed with `/pkg`.
116    ///    E.g. `crates/my-lib-wasm/src/` → `../../crates/my-lib-wasm/pkg`.
117    /// 2. `../../crates/{name}-wasm/pkg` fallback derived from the crate name.
118    pub fn wasm_crate_path(&self) -> String {
119        if let Some(wasm_path) = self.explicit_output.wasm.as_ref() {
120            let components: Vec<&str> = wasm_path
121                .components()
122                .filter_map(|c| {
123                    if let std::path::Component::Normal(s) = c {
124                        s.to_str()
125                    } else {
126                        None
127                    }
128                })
129                .collect();
130            if let Some(idx) = components
131                .iter()
132                .rposition(|&s| s != "src" && s != "lib" && s != "include")
133            {
134                let meaningful = &components[..=idx];
135                return format!("../../{}/pkg", meaningful.join("/"));
136            }
137        }
138        format!("../../crates/{}-wasm/pkg", self.name)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use crate::config::new_config::NewAlefConfig;
145
146    fn resolved_one(toml: &str) -> super::super::ResolvedCrateConfig {
147        let cfg: NewAlefConfig = toml::from_str(toml).unwrap();
148        cfg.resolve().unwrap().remove(0)
149    }
150
151    fn minimal_ffi() -> super::super::ResolvedCrateConfig {
152        resolved_one(
153            r#"
154[workspace]
155languages = ["ffi"]
156
157[[crates]]
158name = "my-lib"
159sources = ["src/lib.rs"]
160"#,
161        )
162    }
163
164    #[test]
165    fn ffi_prefix_defaults_to_snake_case_name() {
166        let r = minimal_ffi();
167        assert_eq!(r.ffi_prefix(), "my_lib");
168    }
169
170    #[test]
171    fn ffi_prefix_explicit_wins() {
172        let r = resolved_one(
173            r#"
174[workspace]
175languages = ["ffi"]
176
177[[crates]]
178name = "my-lib"
179sources = ["src/lib.rs"]
180
181[crates.ffi]
182prefix = "custom_prefix"
183"#,
184        );
185        assert_eq!(r.ffi_prefix(), "custom_prefix");
186    }
187
188    #[test]
189    fn ffi_lib_name_falls_back_to_prefix_ffi() {
190        let r = minimal_ffi();
191        assert_eq!(r.ffi_lib_name(), "my_lib_ffi");
192    }
193
194    #[test]
195    fn ffi_lib_name_explicit_wins() {
196        let r = resolved_one(
197            r#"
198[workspace]
199languages = ["ffi"]
200
201[[crates]]
202name = "my-lib"
203sources = ["src/lib.rs"]
204
205[crates.ffi]
206lib_name = "libmy_custom"
207"#,
208        );
209        assert_eq!(r.ffi_lib_name(), "libmy_custom");
210    }
211
212    #[test]
213    fn ffi_header_name_defaults_to_prefix_h() {
214        let r = minimal_ffi();
215        assert_eq!(r.ffi_header_name(), "my_lib.h");
216    }
217
218    #[test]
219    fn ffi_lib_name_derives_from_explicit_output_path() {
220        let r = resolved_one(
221            r#"
222[workspace]
223languages = ["ffi"]
224
225[[crates]]
226name = "my-lib"
227sources = ["src/lib.rs"]
228
229[crates.output]
230ffi = "crates/html-to-markdown-ffi/src/"
231"#,
232        );
233        // Step 2 of resolution: derive from `[crates.output] ffi` path,
234        // skipping `src`/`lib`/`include` and replacing hyphens with underscores.
235        assert_eq!(r.ffi_lib_name(), "html_to_markdown_ffi");
236    }
237
238    #[test]
239    fn ffi_lib_name_explicit_lib_name_overrides_output_path_derivation() {
240        let r = resolved_one(
241            r#"
242[workspace]
243languages = ["ffi"]
244
245[[crates]]
246name = "my-lib"
247sources = ["src/lib.rs"]
248
249[crates.ffi]
250lib_name = "explicit_wins"
251
252[crates.output]
253ffi = "crates/html-to-markdown-ffi/src/"
254"#,
255        );
256        // Step 1 (explicit lib_name) takes precedence over step 2 (output path).
257        assert_eq!(r.ffi_lib_name(), "explicit_wins");
258    }
259
260    #[test]
261    fn ffi_lib_name_template_derived_output_does_not_drive_lib_name() {
262        // No explicit `[crates.output] ffi`. The template-derived path
263        // (e.g. `packages/ffi/my-lib/`) must NOT drive the lib_name —
264        // it falls through to step 3 (`{ffi_prefix}_ffi`).
265        let r = minimal_ffi();
266        assert_eq!(r.ffi_lib_name(), "my_lib_ffi");
267    }
268
269    #[test]
270    fn ffi_header_name_explicit_wins() {
271        let r = resolved_one(
272            r#"
273[workspace]
274languages = ["ffi"]
275
276[[crates]]
277name = "my-lib"
278sources = ["src/lib.rs"]
279
280[crates.ffi]
281header_name = "custom.h"
282"#,
283        );
284        assert_eq!(r.ffi_header_name(), "custom.h");
285    }
286}