use std::cell::RefCell;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::openapi::{SchemaDetails, SchemaType as OpenApiSchemaType};
#[derive(Debug, Clone)]
pub struct MappedType {
pub rust_type: String,
pub serde_with: Option<String>,
pub feature: Option<TypeFeature>,
}
impl MappedType {
pub fn plain(rust_type: impl Into<String>) -> Self {
Self {
rust_type: rust_type.into(),
serde_with: None,
feature: None,
}
}
pub fn with_feature(rust_type: impl Into<String>, feature: TypeFeature) -> Self {
Self {
rust_type: rust_type.into(),
serde_with: None,
feature: Some(feature),
}
}
pub fn with_codec(
rust_type: impl Into<String>,
codec_path: impl Into<String>,
feature: TypeFeature,
) -> Self {
Self {
rust_type: rust_type.into(),
serde_with: Some(codec_path.into()),
feature: Some(feature),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TypeFeature {
Chrono,
Time,
TimeDate,
TimeTime,
Iso8601,
Uuid,
Bytes,
Base64,
Url,
EmailAddress,
}
impl TypeFeature {
pub fn dep_requirement(self) -> DepRequirement {
match self {
Self::Chrono => DepRequirement::new("chrono", "0.4").with_features(&["serde"]),
Self::Time => DepRequirement::new("time", "0.3").with_features(&[
"serde",
"formatting",
"parsing",
]),
Self::TimeDate | Self::TimeTime => DepRequirement::new("time", "0.3").with_features(&[
"serde",
"formatting",
"parsing",
"macros",
]),
Self::Iso8601 => DepRequirement::new("iso8601", "0.6").with_features(&["serde"]),
Self::Uuid => DepRequirement::new("uuid", "1").with_features(&["serde"]),
Self::Bytes => DepRequirement::new("bytes", "1").with_features(&["serde"]),
Self::Base64 => DepRequirement::new("base64", "0.22"),
Self::Url => DepRequirement::new("url", "2").with_features(&["serde"]),
Self::EmailAddress => DepRequirement::new("email_address", "0.2"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DepRequirement {
pub crate_name: &'static str,
pub version: &'static str,
pub features: Vec<&'static str>,
pub default_features: bool,
pub optional: bool,
}
impl DepRequirement {
pub fn new(crate_name: &'static str, version: &'static str) -> Self {
Self {
crate_name,
version,
features: Vec::new(),
default_features: true,
optional: false,
}
}
pub fn with_features(mut self, features: &[&'static str]) -> Self {
self.features = features.to_vec();
self.features.sort_unstable();
self.features.dedup();
self
}
pub fn without_default_features(mut self) -> Self {
self.default_features = false;
self
}
pub fn optional(mut self) -> Self {
self.optional = true;
self
}
pub fn to_toml_line(&self) -> String {
if self.features.is_empty() && self.default_features && !self.optional {
format!("{} = \"{}\"", self.crate_name, self.version)
} else {
let feats = self
.features
.iter()
.map(|f| format!("\"{f}\""))
.collect::<Vec<_>>()
.join(", ");
let mut attributes = vec![format!("version = \"{}\"", self.version)];
if !self.default_features {
attributes.push("default-features = false".to_string());
}
if !self.features.is_empty() {
attributes.push(format!("features = [{feats}]"));
}
if self.optional {
attributes.push("optional = true".to_string());
}
format!("{} = {{ {} }}", self.crate_name, attributes.join(", "))
}
}
}
pub fn render_required_deps_toml(deps: &[DepRequirement]) -> Option<String> {
if deps.is_empty() {
return None;
}
let mut out = String::new();
out.push_str(
"# Generated by openapi-to-rust.\n\
# Complete direct dependencies for this generated output.\n\
# Append this fragment to the consuming crate's Cargo.toml, or\n\
# merge it with existing dependency and feature sections.\n\
\n\
[dependencies]\n",
);
for dep in deps {
out.push_str(&dep.to_toml_line());
out.push('\n');
}
if deps.iter().any(|dep| dep.crate_name == "specta") {
out.push_str("\n[features]\nspecta = [\"dep:specta\"]\n");
}
Some(out)
}
pub fn merge_dep_requirements(
requirements: impl IntoIterator<Item = DepRequirement>,
) -> Vec<DepRequirement> {
let mut merged: std::collections::BTreeMap<&'static str, DepRequirement> =
std::collections::BTreeMap::new();
for mut dependency in requirements {
dependency.features.sort_unstable();
dependency.features.dedup();
match merged.get_mut(dependency.crate_name) {
Some(existing) => {
debug_assert_eq!(existing.version, dependency.version);
existing.default_features |= dependency.default_features;
existing.optional &= dependency.optional;
existing.features.extend(dependency.features);
existing.features.sort_unstable();
existing.features.dedup();
}
None => {
merged.insert(dependency.crate_name, dependency);
}
}
}
merged.into_values().collect()
}
pub fn collect_generated_dep_requirements<'a>(
contents: impl IntoIterator<Item = &'a str>,
enable_specta: bool,
) -> Vec<DepRequirement> {
let generated = contents.into_iter().collect::<Vec<_>>().join("\n");
let mut dependencies = Vec::new();
let uses = |needle: &str| generated.contains(needle);
if uses("serde::") {
dependencies.push(DepRequirement::new("serde", "1").with_features(&["derive"]));
}
if uses("serde_json::") {
dependencies.push(DepRequirement::new("serde_json", "1"));
}
if uses("serde_urlencoded::") {
dependencies.push(DepRequirement::new("serde_urlencoded", "0.7"));
}
if uses("chrono::") {
dependencies.push(TypeFeature::Chrono.dep_requirement());
}
let uses_time = uses("time::OffsetDateTime") || uses("time::Date") || uses("time::Time");
if uses_time {
let feature = if uses("time::Date") || uses("time::Time") {
TypeFeature::TimeDate
} else {
TypeFeature::Time
};
dependencies.push(feature.dep_requirement());
}
if uses("iso8601::") {
dependencies.push(TypeFeature::Iso8601.dep_requirement());
}
if uses("uuid::") {
dependencies.push(TypeFeature::Uuid.dep_requirement());
}
if uses("bytes::") {
dependencies.push(TypeFeature::Bytes.dep_requirement());
}
if uses("base64::") {
dependencies.push(TypeFeature::Base64.dep_requirement());
}
if uses("url::") {
let dependency = if uses("url::Url") {
TypeFeature::Url.dep_requirement()
} else {
DepRequirement::new("url", "2")
};
dependencies.push(dependency);
}
if uses("email_address::") {
dependencies.push(TypeFeature::EmailAddress.dep_requirement());
}
if uses("reqwest::") {
let mut features = vec!["rustls-tls"];
if uses(".json(&") {
features.push("json");
}
dependencies.push(
DepRequirement::new("reqwest", "0.12")
.without_default_features()
.with_features(&features),
);
}
if uses("reqwest_middleware::") {
let dependency = if uses(".multipart(form)") {
DepRequirement::new("reqwest-middleware", "0.4").with_features(&["multipart"])
} else {
DepRequirement::new("reqwest-middleware", "0.4")
};
dependencies.push(dependency);
}
if uses("reqwest_retry::") {
let dependency = DepRequirement::new("reqwest-retry", "0.7");
dependencies.push(if uses("reqwest_tracing::") {
dependency
} else {
dependency.without_default_features()
});
}
if uses("reqwest_tracing::") {
dependencies.push(DepRequirement::new("reqwest-tracing", "0.5"));
}
if uses("reqwest_eventsource::") {
dependencies.push(DepRequirement::new("reqwest-eventsource", "0.6"));
}
if uses("thiserror::") || uses("use thiserror::") {
dependencies.push(DepRequirement::new("thiserror", "1"));
}
if uses("async_trait::") {
dependencies.push(DepRequirement::new("async-trait", "0.1"));
}
if uses("futures_util::") {
dependencies.push(DepRequirement::new("futures-util", "0.3"));
}
if uses("futures_core::") {
dependencies.push(DepRequirement::new("futures-core", "0.3"));
}
if uses("use tracing::") {
dependencies.push(DepRequirement::new("tracing", "0.1"));
}
if uses("axum::") {
let mut features = vec!["json"];
if uses("axum::response::sse::") {
features.push("tokio");
}
dependencies.push(
DepRequirement::new("axum", "0.7")
.without_default_features()
.with_features(&features),
);
}
if enable_specta {
let mut features = vec!["derive"];
for (needle, feature) in [
("bytes::", "bytes"),
("chrono::", "chrono"),
("time::OffsetDateTime", "time"),
("url::Url", "url"),
("uuid::", "uuid"),
] {
if uses(needle) {
features.push(feature);
}
}
if uses_time {
features.push("time");
}
dependencies.push(
DepRequirement::new("specta", "2.0.0-rc.25")
.with_features(&features)
.optional(),
);
}
merge_dep_requirements(dependencies)
}
pub fn collect_dep_requirements(used: &UsedFeatures) -> Vec<DepRequirement> {
merge_dep_requirements(used.iter().map(|feature| feature.dep_requirement()))
}
#[derive(Debug, Default, Clone)]
pub struct UsedFeatures {
set: BTreeSet<TypeFeature>,
}
impl UsedFeatures {
pub fn insert(&mut self, feature: TypeFeature) {
self.set.insert(feature);
}
pub fn contains(&self, feature: TypeFeature) -> bool {
self.set.contains(&feature)
}
pub fn iter(&self) -> impl Iterator<Item = &TypeFeature> {
self.set.iter()
}
pub fn is_empty(&self) -> bool {
self.set.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DateStrategy {
String,
#[default]
Chrono,
Time,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DurationStrategy {
#[default]
String,
Chrono,
Iso8601,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum UuidStrategy {
String,
#[default]
Uuid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ByteStrategy {
String,
#[default]
Base64,
Base64UrlUnpadded,
VecU8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BinaryStrategy {
String,
#[default]
Bytes,
VecU8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum IpStrategy {
String,
#[default]
Std,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum UriStrategy {
String,
#[default]
Url,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EmailStrategy {
#[default]
String,
EmailAddress,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, rename_all = "snake_case", deny_unknown_fields)]
pub struct TypeMappingConfig {
pub date_time: DateStrategy,
pub date: DateStrategy,
pub time: DateStrategy,
pub duration: DurationStrategy,
pub uuid: UuidStrategy,
pub byte: ByteStrategy,
pub binary: BinaryStrategy,
pub ipv4: IpStrategy,
pub ipv6: IpStrategy,
pub uri: UriStrategy,
pub email: EmailStrategy,
#[serde(default = "default_true")]
pub unsigned: bool,
#[serde(default)]
pub format_aliases: BTreeMap<String, String>,
pub shape: Option<TypeShapeConfig>,
pub constraints: Option<TypeConstraintsConfig>,
pub enums: Option<TypeEnumsConfig>,
}
fn default_true() -> bool {
true
}
impl Default for TypeMappingConfig {
fn default() -> Self {
Self {
date_time: DateStrategy::default(),
date: DateStrategy::default(),
time: DateStrategy::default(),
duration: DurationStrategy::default(),
uuid: UuidStrategy::default(),
byte: ByteStrategy::default(),
binary: BinaryStrategy::default(),
ipv4: IpStrategy::default(),
ipv6: IpStrategy::default(),
uri: UriStrategy::default(),
email: EmailStrategy::default(),
unsigned: true,
format_aliases: BTreeMap::new(),
shape: None,
constraints: None,
enums: None,
}
}
}
fn builtin_format_aliases() -> &'static [(&'static str, &'static str)] {
&[
("uuid4", "uuid"),
("uuid_v4", "uuid"),
("UUID", "uuid"),
("unix-time", "int64"),
("unix_time", "int64"),
("unixtime", "int64"),
("timestamp", "int64"),
]
}
impl TypeMappingConfig {
pub fn constraint_mode(&self) -> ConstraintMode {
self.constraints
.as_ref()
.and_then(|c| c.mode)
.unwrap_or_default()
}
pub fn x_enum_varnames_enabled(&self) -> bool {
self.enums
.as_ref()
.and_then(|e| e.x_enum_varnames)
.unwrap_or(true)
}
pub fn x_enum_descriptions_enabled(&self) -> bool {
self.enums
.as_ref()
.and_then(|e| e.x_enum_descriptions)
.unwrap_or(true)
}
pub fn conservative() -> Self {
Self {
date_time: DateStrategy::String,
date: DateStrategy::String,
time: DateStrategy::String,
duration: DurationStrategy::String,
uuid: UuidStrategy::String,
byte: ByteStrategy::String,
binary: BinaryStrategy::String,
ipv4: IpStrategy::String,
ipv6: IpStrategy::String,
uri: UriStrategy::String,
email: EmailStrategy::String,
unsigned: false,
format_aliases: BTreeMap::new(),
shape: None,
constraints: None,
enums: None,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, rename_all = "snake_case", deny_unknown_fields)]
pub struct TypeShapeConfig {
pub additional_properties_typed: Option<bool>,
pub unique_items_to_set: Option<bool>,
pub primitive_unions: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, rename_all = "snake_case", deny_unknown_fields)]
pub struct TypeConstraintsConfig {
pub mode: Option<ConstraintMode>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ConstraintMode {
Off,
#[default]
Doc,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, rename_all = "snake_case", deny_unknown_fields)]
pub struct TypeEnumsConfig {
pub x_enum_varnames: Option<bool>,
pub x_enum_descriptions: Option<bool>,
}
pub struct TypeMapper {
config: TypeMappingConfig,
used: RefCell<UsedFeatures>,
}
impl Default for TypeMapper {
fn default() -> Self {
Self::new(TypeMappingConfig::default())
}
}
impl TypeMapper {
pub fn new(config: TypeMappingConfig) -> Self {
Self {
config,
used: RefCell::new(UsedFeatures::default()),
}
}
pub fn used_features(&self) -> UsedFeatures {
self.used.borrow().clone()
}
pub fn config(&self) -> &TypeMappingConfig {
&self.config
}
pub fn config_shape_primitive_unions(&self) -> Option<bool> {
self.config.shape.as_ref().and_then(|s| s.primitive_unions)
}
pub fn config_shape_additional_properties_typed(&self) -> Option<bool> {
self.config
.shape
.as_ref()
.and_then(|s| s.additional_properties_typed)
}
pub fn config_constraint_mode(&self) -> ConstraintMode {
self.config
.constraints
.as_ref()
.and_then(|c| c.mode)
.unwrap_or_default()
}
fn record(&self, feature: TypeFeature) {
self.used.borrow_mut().insert(feature);
}
pub fn string_format(&self, format: Option<&str>) -> MappedType {
let normalized = self.normalize_format(format);
match normalized.as_deref() {
Some("date-time") => self.map_date_time(self.config.date_time),
Some("date") => self.map_date(self.config.date),
Some("time") => self.map_time(self.config.time),
Some("duration") => self.map_duration(self.config.duration),
Some("uuid") => self.map_uuid(self.config.uuid),
Some("byte") => self.map_byte(self.config.byte),
Some("binary") => self.map_binary(self.config.binary),
Some("ipv4") => self.map_ipv4(self.config.ipv4),
Some("ipv6") => self.map_ipv6(self.config.ipv6),
Some("uri") | Some("url") => self.map_uri(self.config.uri),
Some("email") => self.map_email(self.config.email),
_ => MappedType::plain("String"),
}
}
fn normalize_format(&self, format: Option<&str>) -> Option<String> {
let raw = format?;
if let Some(target) = self.config.format_aliases.get(raw) {
return Some(target.clone());
}
for (from, to) in builtin_format_aliases() {
if *from == raw {
return Some((*to).to_string());
}
}
Some(raw.to_string())
}
fn map_date_time(&self, strat: DateStrategy) -> MappedType {
match strat {
DateStrategy::String => MappedType::plain("String"),
DateStrategy::Chrono => {
self.record(TypeFeature::Chrono);
MappedType::with_feature("chrono::DateTime<chrono::Utc>", TypeFeature::Chrono)
}
DateStrategy::Time => {
self.record(TypeFeature::Time);
MappedType::with_codec(
"time::OffsetDateTime",
"time::serde::rfc3339",
TypeFeature::Time,
)
}
}
}
fn map_date(&self, strat: DateStrategy) -> MappedType {
match strat {
DateStrategy::String => MappedType::plain("String"),
DateStrategy::Chrono => {
self.record(TypeFeature::Chrono);
MappedType::with_feature("chrono::NaiveDate", TypeFeature::Chrono)
}
DateStrategy::Time => {
self.record(TypeFeature::TimeDate);
MappedType::with_codec("time::Date", "time_date_format", TypeFeature::TimeDate)
}
}
}
fn map_time(&self, strat: DateStrategy) -> MappedType {
match strat {
DateStrategy::String => MappedType::plain("String"),
DateStrategy::Chrono => {
self.record(TypeFeature::Chrono);
MappedType::with_feature("chrono::NaiveTime", TypeFeature::Chrono)
}
DateStrategy::Time => {
self.record(TypeFeature::TimeTime);
MappedType::with_codec("time::Time", "time_time_format", TypeFeature::TimeTime)
}
}
}
fn map_duration(&self, strat: DurationStrategy) -> MappedType {
match strat {
DurationStrategy::String => MappedType::plain("String"),
DurationStrategy::Chrono => {
MappedType::plain("String")
}
DurationStrategy::Iso8601 => {
self.record(TypeFeature::Iso8601);
MappedType::with_feature("iso8601::Duration", TypeFeature::Iso8601)
}
}
}
fn map_uuid(&self, strat: UuidStrategy) -> MappedType {
match strat {
UuidStrategy::String => MappedType::plain("String"),
UuidStrategy::Uuid => {
self.record(TypeFeature::Uuid);
MappedType::with_feature("uuid::Uuid", TypeFeature::Uuid)
}
}
}
fn map_byte(&self, strat: ByteStrategy) -> MappedType {
match strat {
ByteStrategy::String => MappedType::plain("String"),
ByteStrategy::VecU8 => MappedType::plain("Vec<u8>"),
ByteStrategy::Base64 | ByteStrategy::Base64UrlUnpadded => {
self.record(TypeFeature::Base64);
MappedType::with_codec("Vec<u8>", "base64_serde", TypeFeature::Base64)
}
}
}
fn map_binary(&self, strat: BinaryStrategy) -> MappedType {
match strat {
BinaryStrategy::String => MappedType::plain("String"),
BinaryStrategy::VecU8 => MappedType::plain("Vec<u8>"),
BinaryStrategy::Bytes => {
self.record(TypeFeature::Bytes);
MappedType::with_feature("bytes::Bytes", TypeFeature::Bytes)
}
}
}
fn map_ipv4(&self, strat: IpStrategy) -> MappedType {
match strat {
IpStrategy::String => MappedType::plain("String"),
IpStrategy::Std => MappedType::plain("std::net::Ipv4Addr"),
}
}
fn map_ipv6(&self, strat: IpStrategy) -> MappedType {
match strat {
IpStrategy::String => MappedType::plain("String"),
IpStrategy::Std => MappedType::plain("std::net::Ipv6Addr"),
}
}
fn map_uri(&self, strat: UriStrategy) -> MappedType {
match strat {
UriStrategy::String => MappedType::plain("String"),
UriStrategy::Url => {
self.record(TypeFeature::Url);
MappedType::with_feature("url::Url", TypeFeature::Url)
}
}
}
fn map_email(&self, strat: EmailStrategy) -> MappedType {
match strat {
EmailStrategy::String => MappedType::plain("String"),
EmailStrategy::EmailAddress => {
self.record(TypeFeature::EmailAddress);
MappedType::with_feature("email_address::EmailAddress", TypeFeature::EmailAddress)
}
}
}
pub fn integer_format(&self, format: Option<&str>) -> MappedType {
let normalized = self.normalize_format(format);
match normalized.as_deref() {
Some("int32") => MappedType::plain("i32"),
Some("int64") => MappedType::plain("i64"),
Some("uint32") if self.config.unsigned => MappedType::plain("u32"),
Some("uint64") if self.config.unsigned => MappedType::plain("u64"),
Some("uint") if self.config.unsigned => MappedType::plain("u64"),
_ => MappedType::plain("i64"),
}
}
pub fn number_format(&self, format: Option<&str>) -> MappedType {
let normalized = self.normalize_format(format);
match normalized.as_deref() {
Some("float") => MappedType::plain("f32"),
Some("double") => MappedType::plain("f64"),
_ => MappedType::plain("f64"),
}
}
pub fn boolean(&self) -> MappedType {
MappedType::plain("bool")
}
pub fn untyped_array(&self) -> MappedType {
MappedType::plain("Vec<serde_json::Value>")
}
pub fn dynamic_json(&self) -> MappedType {
MappedType::plain("serde_json::Value")
}
pub fn null_unit(&self) -> MappedType {
MappedType::plain("()")
}
pub fn map(&self, ty: OpenApiSchemaType, details: &SchemaDetails) -> MappedType {
let format = details.format.as_deref();
match ty {
OpenApiSchemaType::String => self.string_format(format),
OpenApiSchemaType::Integer => self.integer_format(format),
OpenApiSchemaType::Number => self.number_format(format),
OpenApiSchemaType::Boolean => self.boolean(),
OpenApiSchemaType::Array => self.untyped_array(),
OpenApiSchemaType::Object => self.dynamic_json(),
OpenApiSchemaType::Null => self.null_unit(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn details_with_format(format: Option<&str>) -> SchemaDetails {
SchemaDetails {
format: format.map(str::to_string),
..Default::default()
}
}
#[test]
fn default_mapper_emits_typed_scalars_for_common_formats() {
let m = TypeMapper::default();
assert_eq!(
m.string_format(Some("date-time")).rust_type,
"chrono::DateTime<chrono::Utc>"
);
assert_eq!(m.string_format(Some("date")).rust_type, "chrono::NaiveDate");
assert_eq!(m.string_format(Some("uuid")).rust_type, "uuid::Uuid");
assert_eq!(m.string_format(Some("uri")).rust_type, "url::Url");
assert_eq!(
m.string_format(Some("ipv4")).rust_type,
"std::net::Ipv4Addr"
);
assert_eq!(m.string_format(Some("byte")).rust_type, "Vec<u8>");
assert_eq!(m.string_format(Some("binary")).rust_type, "bytes::Bytes");
}
#[test]
fn date_time_uses_default_chrono_serde() {
let m = TypeMapper::default();
let mt = m.string_format(Some("date-time"));
assert_eq!(mt.rust_type, "chrono::DateTime<chrono::Utc>");
assert!(mt.serde_with.is_none());
assert_eq!(mt.feature, Some(TypeFeature::Chrono));
}
#[test]
fn byte_emits_base64_codec() {
let m = TypeMapper::default();
let mt = m.string_format(Some("byte"));
assert_eq!(mt.rust_type, "Vec<u8>");
assert_eq!(mt.serde_with.as_deref(), Some("base64_serde"));
assert_eq!(mt.feature, Some(TypeFeature::Base64));
}
#[test]
fn byte_url_unpadded_reuses_base64_codec() {
let mapper = TypeMapper::new(TypeMappingConfig {
byte: ByteStrategy::Base64UrlUnpadded,
..TypeMappingConfig::default()
});
let mapped = mapper.string_format(Some("byte"));
assert_eq!(mapped.rust_type, "Vec<u8>");
assert_eq!(mapped.serde_with.as_deref(), Some("base64_serde"));
assert_eq!(mapped.feature, Some(TypeFeature::Base64));
}
#[test]
fn byte_url_unpadded_parses_from_toml() {
let config: TypeMappingConfig =
toml::from_str(r#"byte = "base64_url_unpadded""#).expect("parse type config");
assert_eq!(config.byte, ByteStrategy::Base64UrlUnpadded);
}
#[test]
fn conservative_config_collapses_everything_to_string() {
let m = TypeMapper::new(TypeMappingConfig::conservative());
for fmt in [
Some("date-time"),
Some("uuid"),
Some("uri"),
Some("byte"),
Some("binary"),
Some("ipv4"),
Some("ipv6"),
Some("date"),
None,
] {
let mt = m.string_format(fmt);
assert_eq!(mt.rust_type, "String", "format = {fmt:?}");
assert!(mt.serde_with.is_none(), "format = {fmt:?}");
}
}
#[test]
fn unknown_formats_fall_through_to_string() {
let m = TypeMapper::default();
for fmt in [Some("hostname"), Some("password"), Some("idn-email")] {
assert_eq!(m.string_format(fmt).rust_type, "String");
}
}
#[test]
fn integer_formats_match_pre_refactor_behavior() {
let m = TypeMapper::default();
assert_eq!(m.integer_format(Some("int32")).rust_type, "i32");
assert_eq!(m.integer_format(Some("int64")).rust_type, "i64");
assert_eq!(m.integer_format(None).rust_type, "i64");
}
#[test]
fn integer_formats_default_handles_unsigned_q21() {
let m = TypeMapper::default();
assert_eq!(m.integer_format(Some("uint32")).rust_type, "u32");
assert_eq!(m.integer_format(Some("uint64")).rust_type, "u64");
assert_eq!(m.integer_format(Some("uint")).rust_type, "u64");
}
#[test]
fn unsigned_off_degrades_uint_to_i64() {
let mut cfg = TypeMappingConfig::default();
cfg.unsigned = false;
let m = TypeMapper::new(cfg);
assert_eq!(m.integer_format(Some("uint32")).rust_type, "i64");
assert_eq!(m.integer_format(Some("uint64")).rust_type, "i64");
}
#[test]
fn conservative_disables_unsigned() {
let m = TypeMapper::new(TypeMappingConfig::conservative());
assert_eq!(m.integer_format(Some("uint64")).rust_type, "i64");
}
#[test]
fn builtin_aliases_normalize_uuid_variants_to_uuid() {
let m = TypeMapper::default();
for fmt in ["uuid4", "uuid_v4", "UUID"] {
let mt = m.string_format(Some(fmt));
assert_eq!(mt.rust_type, "uuid::Uuid", "format = {fmt}");
}
}
#[test]
fn builtin_aliases_normalize_unix_time_to_int64() {
let m = TypeMapper::default();
for fmt in ["unix-time", "unix_time", "unixtime", "timestamp"] {
let mt = m.integer_format(Some(fmt));
assert_eq!(mt.rust_type, "i64", "format = {fmt}");
}
}
#[test]
fn user_alias_overrides_builtin() {
let mut cfg = TypeMappingConfig::default();
cfg.format_aliases
.insert("uuid4".to_string(), "hostname".to_string());
let m = TypeMapper::new(cfg);
assert_eq!(m.string_format(Some("uuid4")).rust_type, "String");
}
#[test]
fn used_features_records_referenced_crates() {
let m = TypeMapper::default();
let _ = m.string_format(Some("date-time"));
let _ = m.string_format(Some("uuid"));
let used = m.used_features();
assert!(used.contains(TypeFeature::Chrono));
assert!(used.contains(TypeFeature::Uuid));
assert!(!used.contains(TypeFeature::Bytes));
}
#[test]
fn format_alias_normalizes_before_dispatch() {
let mut cfg = TypeMappingConfig::default();
cfg.format_aliases
.insert("uuid4".to_string(), "uuid".to_string());
let m = TypeMapper::new(cfg);
assert_eq!(m.string_format(Some("uuid4")).rust_type, "uuid::Uuid");
}
#[test]
fn conservative_helper_round_trips() {
let cfg = TypeMappingConfig::conservative();
assert!(matches!(cfg.date_time, DateStrategy::String));
assert!(matches!(cfg.uuid, UuidStrategy::String));
}
#[test]
fn dep_requirement_renders_features_list() {
let dep = TypeFeature::Chrono.dep_requirement();
assert_eq!(dep.crate_name, "chrono");
assert_eq!(dep.features, vec!["serde"]);
assert_eq!(
dep.to_toml_line(),
r#"chrono = { version = "0.4", features = ["serde"] }"#
);
}
#[test]
fn dep_requirement_omits_features_when_none() {
let dep = TypeFeature::Base64.dep_requirement();
assert_eq!(dep.to_toml_line(), r#"base64 = "0.22""#);
}
#[test]
fn collect_dep_requirements_is_sorted_and_unique() {
let mut used = UsedFeatures::default();
used.insert(TypeFeature::Url);
used.insert(TypeFeature::Chrono);
used.insert(TypeFeature::Chrono); used.insert(TypeFeature::Uuid);
let deps = collect_dep_requirements(&used);
assert_eq!(
deps.iter().map(|d| d.crate_name).collect::<Vec<_>>(),
vec!["chrono", "url", "uuid"]
);
}
#[test]
fn render_required_deps_toml_is_none_when_empty() {
let deps: Vec<DepRequirement> = Vec::new();
assert!(render_required_deps_toml(&deps).is_none());
}
#[test]
fn render_required_deps_toml_includes_dependencies_block() {
let deps = vec![
TypeFeature::Chrono.dep_requirement(),
TypeFeature::Uuid.dep_requirement(),
];
let toml = render_required_deps_toml(&deps).expect("non-empty");
assert!(toml.contains("[dependencies]"));
assert!(toml.contains("chrono = "));
assert!(toml.contains("uuid = "));
assert!(toml.contains("# Generated by openapi-to-rust"));
}
#[test]
fn map_dispatches_through_helpers() {
let m = TypeMapper::default();
assert_eq!(
m.map(
OpenApiSchemaType::String,
&details_with_format(Some("uuid"))
)
.rust_type,
"uuid::Uuid"
);
assert_eq!(
m.map(
OpenApiSchemaType::Integer,
&details_with_format(Some("int32"))
)
.rust_type,
"i32"
);
}
}