use serde::{Deserialize, Serialize};
pub const DEFAULT_VERSION: &str = "0.1.0";
pub const DEFAULT_NAME: &str = "api";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Package {
pub name: Option<String>,
pub version: Option<String>,
pub description: Option<String>,
pub license: Option<String>,
pub authors: Vec<String>,
pub homepage: Option<String>,
pub repository: Option<String>,
pub c_prefix: Option<String>,
pub library: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct Identity {
pub name: String,
pub prefix: String,
pub library: String,
pub version: String,
pub description: Option<String>,
pub license: Option<String>,
pub authors: Vec<String>,
pub homepage: Option<String>,
pub repository: Option<String>,
}
impl Default for Identity {
fn default() -> Self {
Self::named(DEFAULT_NAME)
}
}
impl Identity {
#[must_use]
pub fn named(name: &str) -> Self {
Self::new(name, &Package::default())
}
#[must_use]
pub fn new(name: &str, package: &Package) -> Self {
let name = non_empty(package.name.as_ref()).unwrap_or_else(|| name.trim().to_string());
let name = if name.is_empty() {
DEFAULT_NAME.to_string()
} else {
name
};
let snake = c_ident(&name);
Self {
prefix: non_empty(package.c_prefix.as_ref())
.map(|p| c_ident(&p))
.unwrap_or_else(|| snake.clone()),
library: non_empty(package.library.as_ref()).unwrap_or(snake),
version: non_empty(package.version.as_ref())
.unwrap_or_else(|| DEFAULT_VERSION.to_string()),
description: non_empty(package.description.as_ref()),
license: non_empty(package.license.as_ref()),
authors: package.authors.clone(),
homepage: non_empty(package.homepage.as_ref()),
repository: non_empty(package.repository.as_ref()),
name,
}
}
#[must_use]
pub fn macro_prefix(&self) -> String {
self.prefix.to_ascii_uppercase()
}
#[must_use]
pub fn snake_name(&self) -> String {
c_ident(&self.name)
}
#[must_use]
pub fn pascal_name(&self) -> String {
pascal_ident(&self.name)
}
#[must_use]
pub fn description_or_default(&self) -> String {
self.description
.clone()
.unwrap_or_else(|| format!("Native bindings for {}", self.name))
}
#[must_use]
pub fn library_env_var(&self) -> String {
format!("{}_LIBRARY", self.macro_prefix())
}
#[must_use]
pub fn library_files(&self) -> (String, String, String) {
let l = &self.library;
(
format!("lib{l}.dylib"),
format!("lib{l}.so"),
format!("{l}.dll"),
)
}
}
#[must_use]
pub fn pascal_ident(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut start_word = true;
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
if start_word {
out.push(ch.to_ascii_uppercase());
} else {
out.push(ch);
}
start_word = false;
} else {
start_word = true;
}
}
if out.starts_with(|c: char| c.is_ascii_digit()) {
out.insert(0, 'N');
}
if out.is_empty() {
pascal_ident(DEFAULT_NAME)
} else {
out
}
}
#[must_use]
pub fn c_ident(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut prev_us = false;
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_us = false;
} else if !prev_us && !out.is_empty() {
out.push('_');
prev_us = true;
}
}
let trimmed = out.trim_end_matches('_');
if trimmed.is_empty() {
DEFAULT_NAME.to_string()
} else if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
format!("lib_{trimmed}")
} else {
trimmed.to_string()
}
}
#[must_use]
pub fn name_from_basename(basename: &str) -> String {
let file = basename.rsplit(['/', '\\']).next().unwrap_or(basename);
file.split('.').next().unwrap_or(file).to_string()
}
fn non_empty(s: Option<&String>) -> Option<String> {
s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_drives_prefix_and_library() {
let id = Identity::named("my-kv.store");
assert_eq!(id.name, "my-kv.store");
assert_eq!(id.prefix, "my_kv_store");
assert_eq!(id.library, "my_kv_store");
assert_eq!(id.macro_prefix(), "MY_KV_STORE");
assert_eq!(id.pascal_name(), "MyKvStore");
assert_eq!(id.library_env_var(), "MY_KV_STORE_LIBRARY");
assert_eq!(id.version, DEFAULT_VERSION);
}
#[test]
fn package_table_wins() {
let pkg = Package {
name: Some("kvstore".into()),
version: Some("1.2.0".into()),
c_prefix: Some("kv".into()),
library: Some("kvstore_core".into()),
..Package::default()
};
let id = Identity::new("ignored", &pkg);
assert_eq!(id.name, "kvstore");
assert_eq!(id.prefix, "kv");
assert_eq!(id.library, "kvstore_core");
assert_eq!(id.version, "1.2.0");
assert_eq!(id.description_or_default(), "Native bindings for kvstore");
}
#[test]
fn identifiers_sanitize() {
assert_eq!(c_ident("Kvstore"), "kvstore");
assert_eq!(c_ident("--"), DEFAULT_NAME);
assert_eq!(c_ident("3d-engine"), "lib_3d_engine");
assert_eq!(pascal_ident("kvstore"), "Kvstore");
assert_eq!(pascal_ident("3d"), "N3d");
assert_eq!(name_from_basename("path/to/contacts.yml"), "contacts");
}
#[test]
fn package_table_parses_from_toml() {
let pkg: Package =
toml::from_str("name = \"kv\"\nversion = \"2.0.0\"\nauthors = [\"A\", \"B\"]\n")
.unwrap();
assert_eq!(pkg.name.as_deref(), Some("kv"));
assert_eq!(pkg.authors.len(), 2);
assert!(toml::from_str::<Package>("bogus = 1").is_err());
}
}