Skip to main content

weaveffi_model/
pkg.rs

1//! Package identity: the one source of every name a generated package uses.
2//!
3//! The `[package]` table of a project's `weaveffi.toml` ([`Package`]) declares
4//! what the library is called and how it is published. The CLI resolves it
5//! once into an [`Identity`], and validation builds it into the
6//! [`Model`](crate::model::Model), so the C symbol prefix, the native library
7//! name, and every ecosystem's package, module, and namespace name derive
8//! from the same values. Nothing a generator emits is named after WeaveFFI
9//! itself, so any number of WeaveFFI-built libraries can coexist in one
10//! process and one application.
11
12use serde::{Deserialize, Serialize};
13
14/// Fallback package version when the project omits `package.version`.
15pub const DEFAULT_VERSION: &str = "0.1.0";
16
17/// Fallback package name for an API that was never given an identity (unit
18/// tests and other in-memory uses). The CLI always derives a real one.
19pub const DEFAULT_NAME: &str = "api";
20
21/// The `[package]` table of `weaveffi.toml`.
22///
23/// Every field is optional in the file; the CLI fills the gaps from the
24/// producer's `Cargo.toml` (for a Rust producer) or the input file name (for
25/// an IDL) before calling [`Identity::new`].
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(default, deny_unknown_fields)]
28pub struct Package {
29    /// Distribution name used by every ecosystem (npm, PyPI, NuGet, and so
30    /// on) unless a target's own configuration overrides it.
31    pub name: Option<String>,
32    /// Semantic version stamped into every manifest.
33    pub version: Option<String>,
34    /// Short package description.
35    pub description: Option<String>,
36    /// License identifier, typically an SPDX expression.
37    pub license: Option<String>,
38    /// Package authors.
39    pub authors: Vec<String>,
40    /// Project homepage URL.
41    pub homepage: Option<String>,
42    /// Source repository URL.
43    pub repository: Option<String>,
44    /// C symbol prefix for an IDL-defined library. Defaults to the snake-case
45    /// package name. A Rust producer's prefix is always its crate's library
46    /// name, so setting this for a Rust producer is an error.
47    pub c_prefix: Option<String>,
48    /// Base name of the native library (`lib{library}.so`, `{library}.dll`)
49    /// for an IDL-defined library. Defaults to the snake-case package name.
50    /// A Rust producer's library is always its crate's library name.
51    pub library: Option<String>,
52}
53
54/// The resolved identity of the library being bound.
55///
56/// Every field is final: generators read names from here (and from the
57/// target's own configuration overrides) and never invent their own.
58#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
59pub struct Identity {
60    /// Distribution name as published (may contain `-` or `.`).
61    pub name: String,
62    /// C symbol prefix: a lower-snake C identifier that begins every exported
63    /// symbol and every C type name (`{prefix}_error`, `{prefix}_kv_get`).
64    pub prefix: String,
65    /// Native library base name: the producer is `lib{library}.so`,
66    /// `lib{library}.dylib`, or `{library}.dll`.
67    pub library: String,
68    /// Semantic version.
69    pub version: String,
70    /// Short description, if declared.
71    pub description: Option<String>,
72    /// License identifier, if declared.
73    pub license: Option<String>,
74    /// Package authors.
75    pub authors: Vec<String>,
76    /// Project homepage URL, if declared.
77    pub homepage: Option<String>,
78    /// Source repository URL, if declared.
79    pub repository: Option<String>,
80}
81
82impl Default for Identity {
83    fn default() -> Self {
84        Self::named(DEFAULT_NAME)
85    }
86}
87
88impl Identity {
89    /// An identity with only a name: the prefix and library are the
90    /// snake-case name and every metadata field is unset.
91    #[must_use]
92    pub fn named(name: &str) -> Self {
93        Self::new(name, &Package::default())
94    }
95
96    /// Resolve `package` (whose own `name`, if set, wins over `name`) into a
97    /// complete identity. The prefix and library default to the snake-case
98    /// form of the resolved name.
99    #[must_use]
100    pub fn new(name: &str, package: &Package) -> Self {
101        let name = non_empty(package.name.as_ref()).unwrap_or_else(|| name.trim().to_string());
102        let name = if name.is_empty() {
103            DEFAULT_NAME.to_string()
104        } else {
105            name
106        };
107        let snake = c_ident(&name);
108        Self {
109            prefix: non_empty(package.c_prefix.as_ref())
110                .map(|p| c_ident(&p))
111                .unwrap_or_else(|| snake.clone()),
112            library: non_empty(package.library.as_ref()).unwrap_or(snake),
113            version: non_empty(package.version.as_ref())
114                .unwrap_or_else(|| DEFAULT_VERSION.to_string()),
115            description: non_empty(package.description.as_ref()),
116            license: non_empty(package.license.as_ref()),
117            authors: package.authors.clone(),
118            homepage: non_empty(package.homepage.as_ref()),
119            repository: non_empty(package.repository.as_ref()),
120            name,
121        }
122    }
123
124    /// The uppercase prefix used for C preprocessor macros (`KVSTORE_API`,
125    /// `KVSTORE_ABI_VERSION`).
126    #[must_use]
127    pub fn macro_prefix(&self) -> String {
128        self.prefix.to_ascii_uppercase()
129    }
130
131    /// The name as a lower-snake identifier (a Python import package, a Ruby
132    /// `require` path, a Dart package). `"my-kv.store"` becomes
133    /// `"my_kv_store"`.
134    #[must_use]
135    pub fn snake_name(&self) -> String {
136        c_ident(&self.name)
137    }
138
139    /// The name as an UpperCamelCase identifier (a Swift module, a .NET
140    /// namespace, a Ruby module). `"my-kv.store"` becomes `"MyKvStore"`.
141    #[must_use]
142    pub fn pascal_name(&self) -> String {
143        pascal_ident(&self.name)
144    }
145
146    /// The description, or a generated one when unset.
147    #[must_use]
148    pub fn description_or_default(&self) -> String {
149        self.description
150            .clone()
151            .unwrap_or_else(|| format!("Native bindings for {}", self.name))
152    }
153
154    /// The environment variable a generated loader consults for an explicit
155    /// library path (`KVSTORE_LIBRARY`).
156    #[must_use]
157    pub fn library_env_var(&self) -> String {
158        format!("{}_LIBRARY", self.macro_prefix())
159    }
160
161    /// Platform file names for the native library: `(macOS, Linux, Windows)`.
162    #[must_use]
163    pub fn library_files(&self) -> (String, String, String) {
164        let l = &self.library;
165        (
166            format!("lib{l}.dylib"),
167            format!("lib{l}.so"),
168            format!("{l}.dll"),
169        )
170    }
171}
172
173/// UpperCamelCase identifier form of an arbitrary name. Word boundaries are
174/// runs of non-alphanumerics; existing camel humps are preserved.
175/// `"my-kv.store"` becomes `"MyKvStore"`.
176#[must_use]
177pub fn pascal_ident(name: &str) -> String {
178    let mut out = String::with_capacity(name.len());
179    let mut start_word = true;
180    for ch in name.chars() {
181        if ch.is_ascii_alphanumeric() {
182            if start_word {
183                out.push(ch.to_ascii_uppercase());
184            } else {
185                out.push(ch);
186            }
187            start_word = false;
188        } else {
189            start_word = true;
190        }
191    }
192    if out.starts_with(|c: char| c.is_ascii_digit()) {
193        out.insert(0, 'N');
194    }
195    if out.is_empty() {
196        pascal_ident(DEFAULT_NAME)
197    } else {
198        out
199    }
200}
201
202/// Lower-snake C identifier form of an arbitrary name: non-alphanumerics
203/// collapse to `_`, and a leading digit gets a `lib_` prefix.
204/// `"my-kv.store"` becomes `"my_kv_store"`.
205#[must_use]
206pub fn c_ident(name: &str) -> String {
207    let mut out = String::with_capacity(name.len());
208    let mut prev_us = false;
209    for ch in name.chars() {
210        if ch.is_ascii_alphanumeric() {
211            out.push(ch.to_ascii_lowercase());
212            prev_us = false;
213        } else if !prev_us && !out.is_empty() {
214            out.push('_');
215            prev_us = true;
216        }
217    }
218    let trimmed = out.trim_end_matches('_');
219    if trimmed.is_empty() {
220        DEFAULT_NAME.to_string()
221    } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
222        format!("lib_{trimmed}")
223    } else {
224        trimmed.to_string()
225    }
226}
227
228/// The file stem of a path-like input name: `"path/to/kv.store.yml"` becomes
229/// `"kv"`.
230#[must_use]
231pub fn name_from_basename(basename: &str) -> String {
232    let file = basename.rsplit(['/', '\\']).next().unwrap_or(basename);
233    file.split('.').next().unwrap_or(file).to_string()
234}
235
236fn non_empty(s: Option<&String>) -> Option<String> {
237    s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn name_drives_prefix_and_library() {
246        let id = Identity::named("my-kv.store");
247        assert_eq!(id.name, "my-kv.store");
248        assert_eq!(id.prefix, "my_kv_store");
249        assert_eq!(id.library, "my_kv_store");
250        assert_eq!(id.macro_prefix(), "MY_KV_STORE");
251        assert_eq!(id.pascal_name(), "MyKvStore");
252        assert_eq!(id.library_env_var(), "MY_KV_STORE_LIBRARY");
253        assert_eq!(id.version, DEFAULT_VERSION);
254    }
255
256    #[test]
257    fn package_table_wins() {
258        let pkg = Package {
259            name: Some("kvstore".into()),
260            version: Some("1.2.0".into()),
261            c_prefix: Some("kv".into()),
262            library: Some("kvstore_core".into()),
263            ..Package::default()
264        };
265        let id = Identity::new("ignored", &pkg);
266        assert_eq!(id.name, "kvstore");
267        assert_eq!(id.prefix, "kv");
268        assert_eq!(id.library, "kvstore_core");
269        assert_eq!(id.version, "1.2.0");
270        assert_eq!(id.description_or_default(), "Native bindings for kvstore");
271    }
272
273    #[test]
274    fn identifiers_sanitize() {
275        assert_eq!(c_ident("Kvstore"), "kvstore");
276        assert_eq!(c_ident("--"), DEFAULT_NAME);
277        assert_eq!(c_ident("3d-engine"), "lib_3d_engine");
278        assert_eq!(pascal_ident("kvstore"), "Kvstore");
279        assert_eq!(pascal_ident("3d"), "N3d");
280        assert_eq!(name_from_basename("path/to/contacts.yml"), "contacts");
281    }
282
283    #[test]
284    fn package_table_parses_from_toml() {
285        let pkg: Package =
286            toml::from_str("name = \"kv\"\nversion = \"2.0.0\"\nauthors = [\"A\", \"B\"]\n")
287                .unwrap();
288        assert_eq!(pkg.name.as_deref(), Some("kv"));
289        assert_eq!(pkg.authors.len(), 2);
290        assert!(toml::from_str::<Package>("bogus = 1").is_err());
291    }
292}