1use serde::{Deserialize, Serialize};
14
15pub const DEFAULT_VERSION: &str = "0.1.0";
17
18pub const DEFAULT_NAME: &str = "api";
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(default, deny_unknown_fields)]
29pub struct Package {
30 pub name: Option<String>,
33 pub version: Option<String>,
35 pub description: Option<String>,
37 pub license: Option<String>,
39 pub authors: Vec<String>,
41 pub homepage: Option<String>,
43 pub repository: Option<String>,
45 pub c_prefix: Option<String>,
49 pub library: Option<String>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
60pub struct Identity {
61 pub name: String,
63 pub prefix: String,
66 pub library: String,
69 pub version: String,
71 pub description: Option<String>,
73 pub license: Option<String>,
75 pub authors: Vec<String>,
77 pub homepage: Option<String>,
79 pub repository: Option<String>,
81}
82
83impl Default for Identity {
84 fn default() -> Self {
85 Self::named(DEFAULT_NAME)
86 }
87}
88
89impl Identity {
90 #[must_use]
93 pub fn named(name: &str) -> Self {
94 Self::new(name, &Package::default())
95 }
96
97 #[must_use]
101 pub fn new(name: &str, package: &Package) -> Self {
102 let name = non_empty(package.name.as_ref()).unwrap_or_else(|| name.trim().to_string());
103 let name = if name.is_empty() {
104 DEFAULT_NAME.to_string()
105 } else {
106 name
107 };
108 let snake = c_ident(&name);
109 Self {
110 prefix: non_empty(package.c_prefix.as_ref())
111 .map(|p| c_ident(&p))
112 .unwrap_or_else(|| snake.clone()),
113 library: non_empty(package.library.as_ref()).unwrap_or(snake),
114 version: non_empty(package.version.as_ref())
115 .unwrap_or_else(|| DEFAULT_VERSION.to_string()),
116 description: non_empty(package.description.as_ref()),
117 license: non_empty(package.license.as_ref()),
118 authors: package.authors.clone(),
119 homepage: non_empty(package.homepage.as_ref()),
120 repository: non_empty(package.repository.as_ref()),
121 name,
122 }
123 }
124
125 #[must_use]
128 pub fn macro_prefix(&self) -> String {
129 self.prefix.to_ascii_uppercase()
130 }
131
132 #[must_use]
136 pub fn snake_name(&self) -> String {
137 c_ident(&self.name)
138 }
139
140 #[must_use]
143 pub fn pascal_name(&self) -> String {
144 pascal_ident(&self.name)
145 }
146
147 #[must_use]
149 pub fn description_or_default(&self) -> String {
150 self.description
151 .clone()
152 .unwrap_or_else(|| format!("Native bindings for {}", self.name))
153 }
154
155 #[must_use]
158 pub fn library_env_var(&self) -> String {
159 format!("{}_LIBRARY", self.macro_prefix())
160 }
161
162 #[must_use]
164 pub fn library_files(&self) -> (String, String, String) {
165 let l = &self.library;
166 (
167 format!("lib{l}.dylib"),
168 format!("lib{l}.so"),
169 format!("{l}.dll"),
170 )
171 }
172}
173
174#[must_use]
178pub fn pascal_ident(name: &str) -> String {
179 let mut out = String::with_capacity(name.len());
180 let mut start_word = true;
181 for ch in name.chars() {
182 if ch.is_ascii_alphanumeric() {
183 if start_word {
184 out.push(ch.to_ascii_uppercase());
185 } else {
186 out.push(ch);
187 }
188 start_word = false;
189 } else {
190 start_word = true;
191 }
192 }
193 if out.starts_with(|c: char| c.is_ascii_digit()) {
194 out.insert(0, 'N');
195 }
196 if out.is_empty() {
197 pascal_ident(DEFAULT_NAME)
198 } else {
199 out
200 }
201}
202
203#[must_use]
207pub fn c_ident(name: &str) -> String {
208 let mut out = String::with_capacity(name.len());
209 let mut prev_us = false;
210 for ch in name.chars() {
211 if ch.is_ascii_alphanumeric() {
212 out.push(ch.to_ascii_lowercase());
213 prev_us = false;
214 } else if !prev_us && !out.is_empty() {
215 out.push('_');
216 prev_us = true;
217 }
218 }
219 let trimmed = out.trim_end_matches('_');
220 if trimmed.is_empty() {
221 DEFAULT_NAME.to_string()
222 } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
223 format!("lib_{trimmed}")
224 } else {
225 trimmed.to_string()
226 }
227}
228
229#[must_use]
232pub fn name_from_basename(basename: &str) -> String {
233 let file = basename.rsplit(['/', '\\']).next().unwrap_or(basename);
234 file.split('.').next().unwrap_or(file).to_string()
235}
236
237fn non_empty(s: Option<&String>) -> Option<String> {
238 s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn name_drives_prefix_and_library() {
247 let id = Identity::named("my-kv.store");
248 assert_eq!(id.name, "my-kv.store");
249 assert_eq!(id.prefix, "my_kv_store");
250 assert_eq!(id.library, "my_kv_store");
251 assert_eq!(id.macro_prefix(), "MY_KV_STORE");
252 assert_eq!(id.pascal_name(), "MyKvStore");
253 assert_eq!(id.library_env_var(), "MY_KV_STORE_LIBRARY");
254 assert_eq!(id.version, DEFAULT_VERSION);
255 }
256
257 #[test]
258 fn package_table_wins() {
259 let pkg = Package {
260 name: Some("kvstore".into()),
261 version: Some("1.2.0".into()),
262 c_prefix: Some("kv".into()),
263 library: Some("kvstore_core".into()),
264 ..Package::default()
265 };
266 let id = Identity::new("ignored", &pkg);
267 assert_eq!(id.name, "kvstore");
268 assert_eq!(id.prefix, "kv");
269 assert_eq!(id.library, "kvstore_core");
270 assert_eq!(id.version, "1.2.0");
271 assert_eq!(id.description_or_default(), "Native bindings for kvstore");
272 }
273
274 #[test]
275 fn identifiers_sanitize() {
276 assert_eq!(c_ident("Kvstore"), "kvstore");
277 assert_eq!(c_ident("--"), DEFAULT_NAME);
278 assert_eq!(c_ident("3d-engine"), "lib_3d_engine");
279 assert_eq!(pascal_ident("kvstore"), "Kvstore");
280 assert_eq!(pascal_ident("3d"), "N3d");
281 assert_eq!(name_from_basename("path/to/contacts.yml"), "contacts");
282 }
283
284 #[test]
285 fn package_table_parses_from_toml() {
286 let pkg: Package =
287 toml::from_str("name = \"kv\"\nversion = \"2.0.0\"\nauthors = [\"A\", \"B\"]\n")
288 .unwrap();
289 assert_eq!(pkg.name.as_deref(), Some("kv"));
290 assert_eq!(pkg.authors.len(), 2);
291 assert!(toml::from_str::<Package>("bogus = 1").is_err());
292 }
293}