1use serde::{Deserialize, Serialize};
13
14pub const DEFAULT_VERSION: &str = "0.1.0";
16
17pub const DEFAULT_NAME: &str = "api";
20
21#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(default, deny_unknown_fields)]
28pub struct Package {
29 pub name: Option<String>,
32 pub version: Option<String>,
34 pub description: Option<String>,
36 pub license: Option<String>,
38 pub authors: Vec<String>,
40 pub homepage: Option<String>,
42 pub repository: Option<String>,
44 pub c_prefix: Option<String>,
48 pub library: Option<String>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
59pub struct Identity {
60 pub name: String,
62 pub prefix: String,
65 pub library: String,
68 pub version: String,
70 pub description: Option<String>,
72 pub license: Option<String>,
74 pub authors: Vec<String>,
76 pub homepage: Option<String>,
78 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 #[must_use]
92 pub fn named(name: &str) -> Self {
93 Self::new(name, &Package::default())
94 }
95
96 #[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 #[must_use]
127 pub fn macro_prefix(&self) -> String {
128 self.prefix.to_ascii_uppercase()
129 }
130
131 #[must_use]
135 pub fn snake_name(&self) -> String {
136 c_ident(&self.name)
137 }
138
139 #[must_use]
142 pub fn pascal_name(&self) -> String {
143 pascal_ident(&self.name)
144 }
145
146 #[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 #[must_use]
157 pub fn library_env_var(&self) -> String {
158 format!("{}_LIBRARY", self.macro_prefix())
159 }
160
161 #[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#[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#[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#[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}