Skip to main content

weaveffi_core/
pkg.rs

1//! Shared package-identity resolution.
2//!
3//! A single `package:` block in the IDL ([`weaveffi_ir::ir::Package`]) is the
4//! source of truth for the name, version, and metadata stamped into every
5//! ecosystem manifest (`package.json`, `pyproject.toml`, `*.gemspec`,
6//! `*.csproj`, `pubspec.yaml`, `Package.swift`, `build.gradle`, `go.mod`).
7//!
8//! This module centralizes the *resolution* rules (precedence and defaults)
9//! so all eleven generators agree on the package identity instead of each one
10//! hardcoding `weaveffi` / `0.1.0`. Generators read [`resolve`] in their
11//! manifest code and map the [`ResolvedPackage`] fields onto whatever their
12//! ecosystem's manifest format requires.
13
14use weaveffi_ir::ir::Api;
15
16/// Fallback package version when the IDL omits `package.version`.
17pub const DEFAULT_VERSION: &str = "0.1.0";
18
19/// Fallback package name when the IDL omits `package.name` and no per-target
20/// override or input basename is available.
21pub const DEFAULT_NAME: &str = "weaveffi";
22
23/// Package identity resolved for one generator/manifest.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ResolvedPackage {
26    /// Distribution name (npm/PyPI/gem/NuGet/pub/etc.).
27    pub name: String,
28    /// Semantic version stamped into the manifest.
29    pub version: String,
30    /// Short package description, or `None` when the IDL omits it. See
31    /// [`description_or_default`](Self::description_or_default) for the fallback.
32    pub description: Option<String>,
33    /// License identifier (typically an SPDX expression), or `None` when unset.
34    pub license: Option<String>,
35    /// Package authors, taken verbatim from the `package:` block (empty when
36    /// none are declared).
37    pub authors: Vec<String>,
38    /// Project homepage URL, or `None` when unset.
39    pub homepage: Option<String>,
40    /// Source repository URL, or `None` when unset.
41    pub repository: Option<String>,
42}
43
44impl ResolvedPackage {
45    /// Human-readable description, or a generated default when the IDL omits it.
46    pub fn description_or_default(&self) -> String {
47        self.description
48            .clone()
49            .filter(|s| !s.is_empty())
50            .unwrap_or_else(|| format!("{} bindings generated by WeaveFFI", self.name))
51    }
52
53    /// The `name` rewritten so it is a legal lower-snake identifier (e.g. a
54    /// Python import package or a Ruby `require` path): non-alphanumerics
55    /// collapse to `_`. `"my-kv.store"` → `"my_kv_store"`.
56    pub fn ident_name(&self) -> String {
57        sanitize_ident(&self.name)
58    }
59
60    /// The `name` rewritten as an UpperCamelCase identifier suitable for a
61    /// language-level module or namespace (Ruby `module`, .NET `namespace`,
62    /// Swift module, Dart class prefix). `"my-kv.store"` → `"MyKvStore"`.
63    pub fn module_name(&self) -> String {
64        pascal_ident(&self.name)
65    }
66}
67
68/// UpperCamelCase identifier-safe form of an arbitrary package name. Word
69/// boundaries are any run of non-alphanumerics (and existing camel humps are
70/// preserved). `"my-kv.store"` → `"MyKvStore"`, `"kvstore"` → `"Kvstore"`.
71pub fn pascal_ident(name: &str) -> String {
72    let mut out = String::with_capacity(name.len());
73    let mut start_word = true;
74    for ch in name.chars() {
75        if ch.is_ascii_alphanumeric() {
76            if start_word {
77                out.push(ch.to_ascii_uppercase());
78            } else {
79                out.push(ch);
80            }
81            start_word = false;
82        } else {
83            start_word = true;
84        }
85    }
86    if out.is_empty() {
87        pascal_ident(DEFAULT_NAME)
88    } else {
89        out
90    }
91}
92
93/// Lower-case identifier-safe form of an arbitrary package name.
94pub fn sanitize_ident(name: &str) -> String {
95    let mut out = String::with_capacity(name.len());
96    let mut prev_us = false;
97    for ch in name.chars() {
98        if ch.is_ascii_alphanumeric() {
99            out.push(ch.to_ascii_lowercase());
100            prev_us = false;
101        } else if !prev_us && !out.is_empty() {
102            out.push('_');
103            prev_us = true;
104        }
105    }
106    let trimmed = out.trim_end_matches('_');
107    if trimmed.is_empty() {
108        DEFAULT_NAME.to_string()
109    } else {
110        trimmed.to_string()
111    }
112}
113
114/// Strip directory and extension from an IDL basename to use as a fallback
115/// package name. `"path/kvstore.yml"` → `"kvstore"`, `None`/empty →
116/// [`DEFAULT_NAME`].
117pub fn name_from_basename(basename: Option<&str>) -> String {
118    basename
119        .and_then(|b| b.rsplit(['/', '\\']).next())
120        .map(|b| b.split('.').next().unwrap_or(b))
121        .filter(|s| !s.is_empty())
122        .unwrap_or(DEFAULT_NAME)
123        .to_string()
124}
125
126/// Resolve package identity for a generator.
127///
128/// Name precedence (first non-empty wins):
129/// 1. explicit per-target `name_override` (e.g. `python.package_name`),
130/// 2. `api.package.name`,
131/// 3. the IDL file stem (`input_basename`),
132/// 4. [`DEFAULT_NAME`].
133///
134/// Version: `api.package.version` → [`DEFAULT_VERSION`]. All other metadata is
135/// taken verbatim from the `package:` block (absent → `None`/empty).
136pub fn resolve(
137    api: &Api,
138    name_override: Option<&str>,
139    input_basename: Option<&str>,
140) -> ResolvedPackage {
141    let pkg = api.package.as_ref();
142    let name = name_override
143        .map(str::trim)
144        .filter(|s| !s.is_empty())
145        .map(str::to_string)
146        .or_else(|| {
147            pkg.map(|p| p.name.trim().to_string())
148                .filter(|s| !s.is_empty())
149        })
150        .unwrap_or_else(|| name_from_basename(input_basename));
151    let version = pkg
152        .map(|p| p.version.trim().to_string())
153        .filter(|s| !s.is_empty())
154        .unwrap_or_else(|| DEFAULT_VERSION.to_string());
155    ResolvedPackage {
156        name,
157        version,
158        description: pkg
159            .and_then(|p| p.description.clone())
160            .filter(|s| !s.is_empty()),
161        license: pkg
162            .and_then(|p| p.license.clone())
163            .filter(|s| !s.is_empty()),
164        authors: pkg.map(|p| p.authors.clone()).unwrap_or_default(),
165        homepage: pkg
166            .and_then(|p| p.homepage.clone())
167            .filter(|s| !s.is_empty()),
168        repository: pkg
169            .and_then(|p| p.repository.clone())
170            .filter(|s| !s.is_empty()),
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use weaveffi_ir::ir::Package;
178
179    fn api_with(pkg: Option<Package>) -> Api {
180        Api {
181            version: "0.4.0".into(),
182            package: pkg,
183            modules: vec![],
184            generators: None,
185        }
186    }
187
188    fn full_pkg() -> Package {
189        Package {
190            name: "kvstore".into(),
191            version: "1.2.0".into(),
192            description: Some("KV store".into()),
193            license: Some("MIT".into()),
194            authors: vec!["Ada".into()],
195            homepage: Some("https://example.com".into()),
196            repository: Some("https://github.com/x/kvstore".into()),
197        }
198    }
199
200    #[test]
201    fn package_block_drives_identity() {
202        let api = api_with(Some(full_pkg()));
203        let r = resolve(&api, None, Some("ignored.yml"));
204        assert_eq!(r.name, "kvstore");
205        assert_eq!(r.version, "1.2.0");
206        assert_eq!(r.license.as_deref(), Some("MIT"));
207        assert_eq!(r.authors, vec!["Ada".to_string()]);
208    }
209
210    #[test]
211    fn target_override_beats_package_name() {
212        let api = api_with(Some(full_pkg()));
213        let r = resolve(&api, Some("kvstore_py"), Some("kvstore.yml"));
214        assert_eq!(r.name, "kvstore_py");
215        // Version still comes from the package block.
216        assert_eq!(r.version, "1.2.0");
217    }
218
219    #[test]
220    fn falls_back_to_file_stem_then_default() {
221        let api = api_with(None);
222        let r = resolve(&api, None, Some("path/to/contacts.yml"));
223        assert_eq!(r.name, "contacts");
224        assert_eq!(r.version, DEFAULT_VERSION);
225
226        let r2 = resolve(&api, None, None);
227        assert_eq!(r2.name, DEFAULT_NAME);
228    }
229
230    #[test]
231    fn description_default_is_generated() {
232        let api = api_with(None);
233        let r = resolve(&api, Some("widgets"), None);
234        assert_eq!(
235            r.description_or_default(),
236            "widgets bindings generated by WeaveFFI"
237        );
238    }
239
240    #[test]
241    fn ident_name_sanitizes() {
242        assert_eq!(sanitize_ident("my-kv.store"), "my_kv_store");
243        assert_eq!(sanitize_ident("Kvstore"), "kvstore");
244        assert_eq!(sanitize_ident("--"), DEFAULT_NAME);
245    }
246
247    #[test]
248    fn pascal_ident_upper_camels() {
249        assert_eq!(pascal_ident("my-kv.store"), "MyKvStore");
250        assert_eq!(pascal_ident("kvstore"), "Kvstore");
251        assert_eq!(pascal_ident("contacts"), "Contacts");
252        assert_eq!(pascal_ident("--"), "Weaveffi");
253    }
254}