Skip to main content

openapi_to_rust/
type_mapping.rs

1//! Centralized OpenAPI type → Rust type mapping.
2//!
3//! [`TypeMapper`] is the single chokepoint for every `(openapi_type,
4//! format)` → Rust-type decision. Q2.0 introduced the chokepoint with
5//! pass-through behavior; Q2 (quq) flips the defaults so common string
6//! formats (`date-time`, `uuid`, `uri`, …) become typed Rust scalars
7//! out of the box.
8//!
9//! # Design
10//! - Per-format **strategy enums** (e.g. [`DateStrategy`]) drive the
11//!   mapping. Defaults are opt-out: typed by default, set the
12//!   strategy to `String` to recover plain `String`.
13//! - [`MappedType`] carries the Rust type **plus** an optional
14//!   `#[serde(with = "...")]` codec hint. Codec hints flow through
15//!   [`SchemaType::Primitive`](crate::analysis::SchemaType::Primitive)
16//!   to the field-emission site in `generator.rs`, which wraps them
17//!   in a `#[serde(with = …)]` attribute.
18//! - [`UsedFeatures`] records typed-scalar crate usage for helper emission and
19//!   compatibility APIs. The complete generated dependency manifest is
20//!   collected from the exact emitted files after operation/model pruning.
21//!
22//! # Conservative mode
23//! Pass `TypeMappingConfig::conservative()` (CLI: `--types-conservative`)
24//! to recover pre-Q2 behavior — every format renders as `String`. Useful
25//! for bisecting regressions caused by typed-scalar adoption.
26
27use std::cell::RefCell;
28use std::collections::BTreeMap;
29use std::collections::BTreeSet;
30
31use serde::{Deserialize, Serialize};
32
33use crate::openapi::{SchemaDetails, SchemaType as OpenApiSchemaType};
34
35/// Result of mapping an OpenAPI `(type, format)` pair to a Rust type.
36#[derive(Debug, Clone)]
37pub struct MappedType {
38    /// The Rust type as a string, e.g. `"String"`,
39    /// `"chrono::DateTime<chrono::Utc>"`.
40    pub rust_type: String,
41    /// Optional `#[serde(with = "...")]` codec path. The generator
42    /// wraps this in a `with = "<value>"` field attribute.
43    pub serde_with: Option<String>,
44    /// Optional crate this mapping introduced, tracked in [`UsedFeatures`].
45    pub feature: Option<TypeFeature>,
46}
47
48impl MappedType {
49    /// Construct a plain mapping with no codec and no external crate.
50    pub fn plain(rust_type: impl Into<String>) -> Self {
51        Self {
52            rust_type: rust_type.into(),
53            serde_with: None,
54            feature: None,
55        }
56    }
57
58    /// Plain mapping that records a feature crate (e.g. for types like
59    /// `std::net::Ipv4Addr` we don't need a codec but we don't need a
60    /// crate either — this helper is for crates that derive `serde`
61    /// directly on the type).
62    pub fn with_feature(rust_type: impl Into<String>, feature: TypeFeature) -> Self {
63        Self {
64            rust_type: rust_type.into(),
65            serde_with: None,
66            feature: Some(feature),
67        }
68    }
69
70    /// Mapping that requires a `#[serde(with = ...)]` codec.
71    pub fn with_codec(
72        rust_type: impl Into<String>,
73        codec_path: impl Into<String>,
74        feature: TypeFeature,
75    ) -> Self {
76        Self {
77            rust_type: rust_type.into(),
78            serde_with: Some(codec_path.into()),
79            feature: Some(feature),
80        }
81    }
82
83    /// Mapping with an inlined generated codec and no crate dependency.
84    pub fn with_inline_codec(rust_type: impl Into<String>, codec_path: impl Into<String>) -> Self {
85        Self {
86            rust_type: rust_type.into(),
87            serde_with: Some(codec_path.into()),
88            feature: None,
89        }
90    }
91}
92
93/// Identifies an optional crate a mapping introduced.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
95pub enum TypeFeature {
96    Chrono,
97    Time,
98    /// `time::Date` via the generated `time_date_format` codec.
99    /// Tracked separately from [`TypeFeature::Time`] so the
100    /// generator only emits the `time::serde::format_description!`
101    /// helper when a `format: date` field actually exists.
102    TimeDate,
103    /// `time::Time` via the generated `time_time_format` codec.
104    TimeTime,
105    Iso8601,
106    Uuid,
107    Bytes,
108    Base64,
109    Url,
110    EmailAddress,
111}
112
113impl TypeFeature {
114    /// Canonical dependency requirement for this typed scalar.
115    pub fn dep_requirement(self) -> DepRequirement {
116        match self {
117            Self::Chrono => DepRequirement::new("chrono", "0.4").with_features(&["serde"]),
118            // `serde` alone doesn't enable `time::serde::rfc3339`;
119            // the codec modules are gated on formatting/parsing.
120            Self::Time => DepRequirement::new("time", "0.3").with_features(&[
121                "serde",
122                "formatting",
123                "parsing",
124            ]),
125            // `macros` on top: Date/Time have no built-in serde
126            // codec, so the generated code declares one via
127            // `time::serde::format_description!`.
128            Self::TimeDate | Self::TimeTime => DepRequirement::new("time", "0.3").with_features(&[
129                "serde",
130                "formatting",
131                "parsing",
132                "macros",
133            ]),
134            Self::Iso8601 => DepRequirement::new("iso8601", "0.6").with_features(&["serde"]),
135            Self::Uuid => DepRequirement::new("uuid", "1").with_features(&["serde"]),
136            Self::Bytes => DepRequirement::new("bytes", "1").with_features(&["serde"]),
137            Self::Base64 => DepRequirement::new("base64", "0.22"),
138            Self::Url => DepRequirement::new("url", "2").with_features(&["serde"]),
139            Self::EmailAddress => DepRequirement::new("email_address", "0.2"),
140        }
141    }
142}
143
144/// One crate the generated code needs in its `Cargo.toml`.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct DepRequirement {
147    pub crate_name: &'static str,
148    pub version: &'static str,
149    pub features: Vec<&'static str>,
150    pub default_features: bool,
151    pub optional: bool,
152}
153
154impl DepRequirement {
155    pub fn new(crate_name: &'static str, version: &'static str) -> Self {
156        Self {
157            crate_name,
158            version,
159            features: Vec::new(),
160            default_features: true,
161            optional: false,
162        }
163    }
164
165    pub fn with_features(mut self, features: &[&'static str]) -> Self {
166        self.features = features.to_vec();
167        self.features.sort_unstable();
168        self.features.dedup();
169        self
170    }
171
172    pub fn without_default_features(mut self) -> Self {
173        self.default_features = false;
174        self
175    }
176
177    pub fn optional(mut self) -> Self {
178        self.optional = true;
179        self
180    }
181
182    /// Render as a single TOML `[dependencies]` line. Picks the
183    /// most compact form that still expresses the required features.
184    pub fn to_toml_line(&self) -> String {
185        if self.features.is_empty() && self.default_features && !self.optional {
186            format!("{} = \"{}\"", self.crate_name, self.version)
187        } else {
188            let feats = self
189                .features
190                .iter()
191                .map(|f| format!("\"{f}\""))
192                .collect::<Vec<_>>()
193                .join(", ");
194            let mut attributes = vec![format!("version = \"{}\"", self.version)];
195            if !self.default_features {
196                attributes.push("default-features = false".to_string());
197            }
198            if !self.features.is_empty() {
199                attributes.push(format!("features = [{feats}]"));
200            }
201            if self.optional {
202                attributes.push("optional = true".to_string());
203            }
204            format!("{} = {{ {} }}", self.crate_name, attributes.join(", "))
205        }
206    }
207}
208
209/// Render `REQUIRED_DEPS.toml` content from a sorted set of
210/// requirements. Returns `None` when the input is empty so the
211/// caller can skip writing the file when no generated Rust files need
212/// external crates.
213pub fn render_required_deps_toml(deps: &[DepRequirement]) -> Option<String> {
214    if deps.is_empty() {
215        return None;
216    }
217    let mut out = String::new();
218    out.push_str(
219        "# Generated by openapi-to-rust.\n\
220         # Complete direct dependencies for this generated output.\n\
221         # Append this fragment to the consuming crate's Cargo.toml, or\n\
222         # merge it with existing dependency and feature sections.\n\
223         \n\
224         [dependencies]\n",
225    );
226    for dep in deps {
227        out.push_str(&dep.to_toml_line());
228        out.push('\n');
229    }
230    if deps.iter().any(|dep| dep.crate_name == "specta") {
231        out.push_str("\n[features]\nspecta = [\"dep:specta\"]\n");
232    }
233    Some(out)
234}
235
236/// Merge requirements by crate name, unioning features deterministically.
237/// A dependency is optional only when every occurrence is optional, and
238/// default features are enabled when any occurrence needs them.
239pub fn merge_dep_requirements(
240    requirements: impl IntoIterator<Item = DepRequirement>,
241) -> Vec<DepRequirement> {
242    let mut merged: std::collections::BTreeMap<&'static str, DepRequirement> =
243        std::collections::BTreeMap::new();
244    for mut dependency in requirements {
245        dependency.features.sort_unstable();
246        dependency.features.dedup();
247        match merged.get_mut(dependency.crate_name) {
248            Some(existing) => {
249                debug_assert_eq!(existing.version, dependency.version);
250                existing.default_features |= dependency.default_features;
251                existing.optional &= dependency.optional;
252                existing.features.extend(dependency.features);
253                existing.features.sort_unstable();
254                existing.features.dedup();
255            }
256            None => {
257                merged.insert(dependency.crate_name, dependency);
258            }
259        }
260    }
261    merged.into_values().collect()
262}
263
264/// Collect the complete direct dependency set from the exact Rust files that
265/// will be written. Scanning emitted paths keeps model pruning and operation
266/// selection authoritative: dependencies cannot leak in from schemas or
267/// operations that were analyzed but not generated.
268pub fn collect_generated_dep_requirements<'a>(
269    contents: impl IntoIterator<Item = &'a str>,
270    enable_specta: bool,
271) -> Vec<DepRequirement> {
272    let generated = contents.into_iter().collect::<Vec<_>>().join("\n");
273    let mut dependencies = Vec::new();
274    let uses = |needle: &str| generated.contains(needle);
275
276    if uses("serde::") {
277        dependencies.push(DepRequirement::new("serde", "1").with_features(&["derive"]));
278    }
279    if uses("serde_json::") {
280        dependencies.push(DepRequirement::new("serde_json", "1"));
281    }
282    if uses("serde_urlencoded::") {
283        dependencies.push(DepRequirement::new("serde_urlencoded", "0.7"));
284    }
285    if uses("chrono::") {
286        dependencies.push(TypeFeature::Chrono.dep_requirement());
287    }
288    let uses_time = uses("time::OffsetDateTime") || uses("time::Date") || uses("time::Time");
289    if uses_time {
290        let feature = if uses("time::Date") || uses("time::Time") {
291            TypeFeature::TimeDate
292        } else {
293            TypeFeature::Time
294        };
295        dependencies.push(feature.dep_requirement());
296    }
297    if uses("iso8601::") {
298        dependencies.push(TypeFeature::Iso8601.dep_requirement());
299    }
300    if uses("uuid::") {
301        dependencies.push(TypeFeature::Uuid.dep_requirement());
302    }
303    if uses("bytes::") {
304        dependencies.push(TypeFeature::Bytes.dep_requirement());
305    }
306    if uses("base64::") {
307        dependencies.push(TypeFeature::Base64.dep_requirement());
308    }
309    if uses("url::") {
310        let dependency = if uses("url::Url") {
311            TypeFeature::Url.dep_requirement()
312        } else {
313            DepRequirement::new("url", "2")
314        };
315        dependencies.push(dependency);
316    }
317    if uses("email_address::") {
318        dependencies.push(TypeFeature::EmailAddress.dep_requirement());
319    }
320
321    if uses("reqwest::") {
322        let mut features = vec!["rustls"];
323        if uses(".json(&") {
324            features.push("json");
325        }
326        if uses(".query(&") {
327            features.push("query");
328        }
329        if uses(".form(&") {
330            features.push("form");
331        }
332        // Multipart operations reference `reqwest::multipart::Form` directly.
333        // reqwest is pinned with default-features off, so the feature has to be
334        // requested explicitly or the generated client fails to compile with
335        // "cannot find `multipart` in `reqwest`". The reqwest-middleware side
336        // of this was already handled below; reqwest itself was missed
337        // (openapi-generator-upz). Hits any spec with a file upload.
338        if uses("reqwest::multipart") {
339            features.push("multipart");
340        }
341        // Incremental response streaming APIs used by generated SSE operations
342        // are gated behind reqwest's `stream` feature.
343        if uses(".bytes_stream()") || uses(".chunk().await") {
344            features.push("stream");
345        }
346        dependencies.push(
347            DepRequirement::new("reqwest", "0.13")
348                .without_default_features()
349                .with_features(&features),
350        );
351    }
352    if uses("reqwest_middleware::") {
353        let mut features = Vec::new();
354        if uses(".json(&") {
355            features.push("json");
356        }
357        if uses(".query(&") {
358            features.push("query");
359        }
360        if uses(".form(&") {
361            features.push("form");
362        }
363        if uses(".multipart(form)") {
364            features.push("multipart");
365        }
366        dependencies
367            .push(DepRequirement::new("reqwest-middleware", "0.5").with_features(&features));
368    }
369    if uses("reqwest_retry::") {
370        let dependency = DepRequirement::new("reqwest-retry", "0.9");
371        dependencies.push(if uses("reqwest_tracing::") {
372            dependency
373        } else {
374            dependency.without_default_features()
375        });
376    }
377    if uses("reqwest_tracing::") {
378        dependencies.push(DepRequirement::new("reqwest-tracing", "0.7"));
379    }
380    if uses("thiserror::") || uses("use thiserror::") {
381        dependencies.push(DepRequirement::new("thiserror", "2"));
382    }
383    if uses("async_trait::") {
384        dependencies.push(DepRequirement::new("async-trait", "0.1"));
385    }
386    if uses("futures_util::") {
387        dependencies.push(DepRequirement::new("futures-util", "0.3"));
388    }
389    if uses("futures_timer::") {
390        dependencies.push(DepRequirement::new("futures-timer", "3"));
391    }
392    if uses("futures_core::") {
393        dependencies.push(DepRequirement::new("futures-core", "0.3"));
394    }
395    if uses("use tracing::") {
396        dependencies.push(DepRequirement::new("tracing", "0.1"));
397    }
398    if uses("axum::") {
399        let mut features = vec!["json"];
400        if uses("axum::extract::Multipart") {
401            features.push("multipart");
402        }
403        if uses("axum::response::sse::") {
404            features.push("tokio");
405        }
406        dependencies.push(
407            DepRequirement::new("axum", "0.8")
408                .without_default_features()
409                .with_features(&features),
410        );
411    }
412    if uses("jsonschema::") {
413        dependencies.push(DepRequirement::new("jsonschema", "0.49").without_default_features());
414    }
415    if uses("http_body_util::") {
416        dependencies.push(DepRequirement::new("http-body-util", "0.1"));
417    }
418    if uses("mime::") {
419        dependencies.push(DepRequirement::new("mime", "0.3"));
420    }
421    if enable_specta {
422        let mut features = vec!["derive"];
423        for (needle, feature) in [
424            ("bytes::", "bytes"),
425            ("chrono::", "chrono"),
426            ("time::OffsetDateTime", "time"),
427            ("url::Url", "url"),
428            ("uuid::", "uuid"),
429        ] {
430            if uses(needle) {
431                features.push(feature);
432            }
433        }
434        if uses_time {
435            features.push("time");
436        }
437        dependencies.push(
438            DepRequirement::new("specta", "2.0.0-rc.25")
439                .with_features(&features)
440                .optional(),
441        );
442    }
443
444    merge_dep_requirements(dependencies)
445}
446
447/// Snapshot a `UsedFeatures` set as a sorted, de-duplicated list of
448/// `DepRequirement`s. Sorting by crate name keeps the emitted file
449/// deterministic so it can be checked in or diffed.
450pub fn collect_dep_requirements(used: &UsedFeatures) -> Vec<DepRequirement> {
451    merge_dep_requirements(used.iter().map(|feature| feature.dep_requirement()))
452}
453
454/// Tracks which optional crates the generator emitted code for.
455#[derive(Debug, Default, Clone)]
456pub struct UsedFeatures {
457    set: BTreeSet<TypeFeature>,
458}
459
460impl UsedFeatures {
461    pub fn insert(&mut self, feature: TypeFeature) {
462        self.set.insert(feature);
463    }
464
465    pub fn contains(&self, feature: TypeFeature) -> bool {
466        self.set.contains(&feature)
467    }
468
469    pub fn iter(&self) -> impl Iterator<Item = &TypeFeature> {
470        self.set.iter()
471    }
472
473    pub fn is_empty(&self) -> bool {
474        self.set.is_empty()
475    }
476}
477
478// =====================================================================
479// Strategy enums
480// =====================================================================
481
482/// Strategy for `format: date-time | date | time`.
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
484#[serde(rename_all = "lowercase")]
485pub enum DateStrategy {
486    /// Plain `String`. Pre-Q2 behavior; pick this to opt out.
487    String,
488    /// `chrono::DateTime<Utc>` / `NaiveDate` (default). JSON Schema
489    /// `format: time` remains a String because RFC 3339 full-time carries an
490    /// offset that `NaiveTime` cannot represent.
491    #[default]
492    Chrono,
493    /// `time::OffsetDateTime` / `Date`. JSON Schema `format: time` remains a
494    /// String because `time::Time` cannot represent its required offset.
495    Time,
496}
497
498/// Strategy for `format: duration` (ISO 8601 durations).
499#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
500#[serde(rename_all = "lowercase")]
501pub enum DurationStrategy {
502    // Off by default — `format: duration` is ISO 8601 (e.g.
503    // "PT1H30M") but `chrono::Duration`'s native serde encodes
504    // seconds. Round-tripping requires a custom parser that we'll
505    // land in a follow-up; for now `duration` stays String so
506    // default-on doesn't break specs that emit ISO 8601 strings
507    // the chrono codec couldn't decode.
508    #[default]
509    String,
510    /// `chrono::Duration`. Round-trips ISO 8601 durations via a
511    /// small custom serde module emitted into the generated crate.
512    Chrono,
513    /// `iso8601::Duration` from the `iso8601` crate.
514    Iso8601,
515}
516
517/// Strategy for `format: uuid` (or normalized aliases).
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
519#[serde(rename_all = "lowercase")]
520pub enum UuidStrategy {
521    String,
522    /// `uuid::Uuid` (default).
523    #[default]
524    Uuid,
525}
526
527/// Strategy for `format: byte` (base64-encoded binary on the wire).
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
529#[serde(rename_all = "snake_case")]
530pub enum ByteStrategy {
531    String,
532    /// `Vec<u8>` round-tripped via an inlined `base64_serde` module
533    /// using the standard padded alphabet (default).
534    #[default]
535    Base64,
536    /// `Vec<u8>` round-tripped with the URL-safe, unpadded alphabet
537    /// from RFC 7515 section 2. This setting applies to every
538    /// `format: byte` field in the generated module.
539    Base64UrlUnpadded,
540    /// `Vec<u8>` with no codec (caller responsible for encoding).
541    VecU8,
542}
543
544/// Strategy for `format: binary` (raw octets).
545#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
546#[serde(rename_all = "snake_case")]
547pub enum BinaryStrategy {
548    String,
549    /// `bytes::Bytes` (default).
550    #[default]
551    Bytes,
552    VecU8,
553}
554
555/// Strategy for `format: ipv4 | ipv6`.
556#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
557#[serde(rename_all = "lowercase")]
558pub enum IpStrategy {
559    String,
560    /// `std::net::Ipv4Addr` / `Ipv6Addr` (default; pure std, no deps).
561    #[default]
562    Std,
563}
564
565/// Strategy for `format: uri | url`.
566#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
567#[serde(rename_all = "lowercase")]
568pub enum UriStrategy {
569    String,
570    /// `url::Url` (default).
571    #[default]
572    Url,
573}
574
575/// Strategy for `format: email`.
576///
577/// Email is **off by default** — the `email_address` crate is more
578/// opinionated than the wire ever guarantees, and most APIs treat
579/// emails as opaque strings.
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
581#[serde(rename_all = "snake_case")]
582pub enum EmailStrategy {
583    #[default]
584    String,
585    EmailAddress,
586}
587
588// =====================================================================
589// Top-level config
590// =====================================================================
591
592/// Configuration for [`TypeMapper`]. Mirrors the `[generator.types]`
593/// TOML section. Defaults flip on every common typed scalar; opt out
594/// per format by setting the strategy to `string` in TOML.
595#[derive(Debug, Clone, Deserialize, Serialize)]
596#[serde(default, rename_all = "snake_case", deny_unknown_fields)]
597pub struct TypeMappingConfig {
598    pub date_time: DateStrategy,
599    pub date: DateStrategy,
600    pub time: DateStrategy,
601    pub duration: DurationStrategy,
602    pub uuid: UuidStrategy,
603    pub byte: ByteStrategy,
604    pub binary: BinaryStrategy,
605    pub ipv4: IpStrategy,
606    pub ipv6: IpStrategy,
607    pub uri: UriStrategy,
608    pub email: EmailStrategy,
609
610    /// Q2.1: honor `format: uint32` / `uint64` integer formats and
611    /// map them to `u32` / `u64` respectively. Default `true` (cheap,
612    /// no extra crate). Set `false` to revert to the pre-Q2.1
613    /// behavior where unsigned formats degraded to `i64`.
614    #[serde(default = "default_true")]
615    pub unsigned: bool,
616
617    /// Q2.2: user-extensible format aliases applied before standard
618    /// format dispatch (e.g. `"uuid4" -> "uuid"`,
619    /// `"unix-time" -> "int64"`). Built-in defaults are merged with
620    /// user-supplied entries; user entries win on collision.
621    #[serde(default)]
622    pub format_aliases: BTreeMap<String, String>,
623
624    /// Object/array shape toggles. Filled in by Q2.3, Q2.5, Q2.7.
625    pub shape: Option<TypeShapeConfig>,
626
627    /// Constraint annotation mode. Filled in by Q2.4.
628    pub constraints: Option<TypeConstraintsConfig>,
629
630    /// Vendor-extension toggles for enums. Filled in by Q2.6.
631    pub enums: Option<TypeEnumsConfig>,
632
633    /// Rust type for `format: float`. Defaults to `f64`, which round-trips the
634    /// JSON number the server actually sent; `f32` maps strictly by declared
635    /// format at the cost of precision.
636    #[serde(default)]
637    pub float_precision: FloatPrecision,
638}
639
640/// How `format: float` is mapped.
641#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
642#[serde(rename_all = "lowercase")]
643pub enum FloatPrecision {
644    /// Map to `f64` (default). JSON carries no binary32, so widening preserves
645    /// the transmitted value exactly.
646    #[default]
647    F64,
648    /// Map to `f32`, matching the declared format literally. Values that are
649    /// not representable in binary32 lose precision — `0.03` becomes
650    /// `0.029999999329447746`.
651    F32,
652}
653
654fn default_true() -> bool {
655    true
656}
657
658impl Default for TypeMappingConfig {
659    fn default() -> Self {
660        Self {
661            float_precision: FloatPrecision::default(),
662            date_time: DateStrategy::default(),
663            date: DateStrategy::default(),
664            time: DateStrategy::default(),
665            duration: DurationStrategy::default(),
666            uuid: UuidStrategy::default(),
667            byte: ByteStrategy::default(),
668            binary: BinaryStrategy::default(),
669            ipv4: IpStrategy::default(),
670            ipv6: IpStrategy::default(),
671            uri: UriStrategy::default(),
672            email: EmailStrategy::default(),
673            unsigned: true,
674            format_aliases: BTreeMap::new(),
675            shape: None,
676            constraints: None,
677            enums: None,
678        }
679    }
680}
681
682/// Built-in format aliases applied before user-supplied
683/// [`TypeMappingConfig::format_aliases`]. These normalize common
684/// vendor-isms found in real-world specs so the standard format
685/// dispatch in [`TypeMapper::string_format`] /
686/// [`TypeMapper::integer_format`] sees canonical names.
687fn builtin_format_aliases() -> &'static [(&'static str, &'static str)] {
688    &[
689        ("uuid4", "uuid"),
690        ("uuid_v4", "uuid"),
691        ("UUID", "uuid"),
692        ("unix-time", "int64"),
693        ("unix_time", "int64"),
694        ("unixtime", "int64"),
695        ("timestamp", "int64"),
696    ]
697}
698
699/// Normalize a built-in format alias without applying user configuration.
700///
701/// Internal schema tooling uses this alongside [`TypeMapper`] so synthesis,
702/// validation, and Rust type selection agree on vendor spellings.
703pub(crate) fn normalize_builtin_format(format: &str) -> &str {
704    builtin_format_aliases()
705        .iter()
706        .find_map(|(from, to)| (*from == format).then_some(*to))
707        .unwrap_or(format)
708}
709
710impl TypeMappingConfig {
711    /// Q2.4: constraint-doc emission mode. Defaults to
712    /// [`ConstraintMode::Doc`] when the
713    /// `[generator.types.constraints]` block is absent or its
714    /// `mode` field is unset.
715    pub fn constraint_mode(&self) -> ConstraintMode {
716        self.constraints
717            .as_ref()
718            .and_then(|c| c.mode)
719            .unwrap_or_default()
720    }
721
722    /// Q2.6: should `x-enum-varnames` override the heuristic
723    /// PascalCase variant naming? Default true.
724    pub fn x_enum_varnames_enabled(&self) -> bool {
725        self.enums
726            .as_ref()
727            .and_then(|e| e.x_enum_varnames)
728            .unwrap_or(true)
729    }
730
731    /// Q2.6: should `x-enum-descriptions` emit per-variant doc
732    /// comments? Default true.
733    pub fn x_enum_descriptions_enabled(&self) -> bool {
734        self.enums
735            .as_ref()
736            .and_then(|e| e.x_enum_descriptions)
737            .unwrap_or(true)
738    }
739
740    /// Pre-Q2 behavior — every format renders as `String` and
741    /// integer formats degrade to `i64`. Users opt in via
742    /// `--types-conservative` when bisecting regressions introduced
743    /// by typed-scalar adoption.
744    pub fn conservative() -> Self {
745        Self {
746            // Conservative mode reproduces pre-Q2 output, which mapped
747            // `format: float` literally to `f32`.
748            float_precision: FloatPrecision::F32,
749            date_time: DateStrategy::String,
750            date: DateStrategy::String,
751            time: DateStrategy::String,
752            duration: DurationStrategy::String,
753            uuid: UuidStrategy::String,
754            byte: ByteStrategy::String,
755            binary: BinaryStrategy::String,
756            ipv4: IpStrategy::String,
757            ipv6: IpStrategy::String,
758            uri: UriStrategy::String,
759            email: EmailStrategy::String,
760            unsigned: false,
761            format_aliases: BTreeMap::new(),
762            shape: None,
763            constraints: None,
764            enums: None,
765        }
766    }
767}
768
769#[derive(Debug, Clone, Default, Deserialize, Serialize)]
770#[serde(default, rename_all = "snake_case", deny_unknown_fields)]
771pub struct TypeShapeConfig {
772    pub additional_properties_typed: Option<bool>,
773    pub unique_items_to_set: Option<bool>,
774    pub primitive_unions: Option<bool>,
775}
776
777#[derive(Debug, Clone, Default, Deserialize, Serialize)]
778#[serde(default, rename_all = "snake_case", deny_unknown_fields)]
779pub struct TypeConstraintsConfig {
780    /// Q2.4 constraint annotation mode. Defaults to `Doc` when the
781    /// `[generator.types.constraints]` block is absent (see
782    /// [`TypeMapper::config_constraint_mode`]).
783    pub mode: Option<ConstraintMode>,
784}
785
786/// Q2.4 — what to emit for OpenAPI constraint keywords
787/// (`minimum`/`maximum`/`minLength`/`maxLength`/`pattern`/etc.).
788///
789/// **No client-side validation.** Constraints belong to the wire
790/// contract; the server is the source of truth. The generator
791/// surfaces them only as doc-comments so callers see the rules
792/// without the SDK duplicating server logic and going brittle
793/// when the rules drift.
794#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
795#[serde(rename_all = "snake_case")]
796pub enum ConstraintMode {
797    /// Drop constraints entirely (pre-Q2.4 behavior).
798    Off,
799    /// Emit `/// Constraint: ...` doc comments on each field.
800    /// Cheap, no extra crate dependency. Default.
801    #[default]
802    Doc,
803}
804
805#[derive(Debug, Clone, Default, Deserialize, Serialize)]
806#[serde(default, rename_all = "snake_case", deny_unknown_fields)]
807pub struct TypeEnumsConfig {
808    pub x_enum_varnames: Option<bool>,
809    pub x_enum_descriptions: Option<bool>,
810}
811
812// =====================================================================
813// TypeMapper
814// =====================================================================
815
816pub struct TypeMapper {
817    config: TypeMappingConfig,
818    used: RefCell<UsedFeatures>,
819}
820
821impl Default for TypeMapper {
822    fn default() -> Self {
823        Self::new(TypeMappingConfig::default())
824    }
825}
826
827impl TypeMapper {
828    pub fn new(config: TypeMappingConfig) -> Self {
829        Self {
830            config,
831            used: RefCell::new(UsedFeatures::default()),
832        }
833    }
834
835    /// Snapshot of typed-scalar crates this mapper has referenced.
836    pub fn used_features(&self) -> UsedFeatures {
837        self.used.borrow().clone()
838    }
839
840    /// Borrow the underlying type-mapping config — useful for
841    /// non-format-mapping toggles (`shape`, `enums`, `constraints`)
842    /// that other modules need to inspect.
843    pub fn config(&self) -> &TypeMappingConfig {
844        &self.config
845    }
846
847    /// Q2.7 helper: should `anyOf` of primitives become an untagged
848    /// enum with primitive variant types directly (true), or fall
849    /// back to the pre-Q2.7 type-alias-per-variant shape (false)?
850    /// Default: true.
851    pub fn config_shape_primitive_unions(&self) -> Option<bool> {
852        self.config.shape.as_ref().and_then(|s| s.primitive_unions)
853    }
854
855    /// Q2.3 helper: should `additionalProperties: <schema>` produce
856    /// `BTreeMap<String, T>` (true) or degrade to `BTreeMap<String,
857    /// serde_json::Value>` (false)? Default: true.
858    pub fn config_shape_additional_properties_typed(&self) -> Option<bool> {
859        self.config
860            .shape
861            .as_ref()
862            .and_then(|s| s.additional_properties_typed)
863    }
864
865    /// Q2.4 helper: which constraint-annotation mode is active?
866    /// Defaults to [`ConstraintMode::Doc`] when the
867    /// `[generator.types.constraints]` block is absent or its `mode`
868    /// field is unset.
869    pub fn config_constraint_mode(&self) -> ConstraintMode {
870        self.config
871            .constraints
872            .as_ref()
873            .and_then(|c| c.mode)
874            .unwrap_or_default()
875    }
876
877    fn record(&self, feature: TypeFeature) {
878        self.used.borrow_mut().insert(feature);
879    }
880
881    /// Map `string` + optional `format` → typed Rust scalar.
882    ///
883    /// Routing:
884    /// 1. Apply user-provided + built-in `format_aliases`.
885    /// 2. Dispatch on the normalized format.
886    /// 3. Honor each format's strategy in `self.config`.
887    /// 4. Record any introduced crate in `used_features`.
888    pub fn string_format(&self, format: Option<&str>) -> MappedType {
889        let normalized = self.normalize_format(format);
890        match normalized.as_deref() {
891            Some("date-time") => self.map_date_time(self.config.date_time),
892            Some("date") => self.map_date(self.config.date),
893            Some("time") => self.map_time(self.config.time),
894            Some("duration") => self.map_duration(self.config.duration),
895            Some("uuid") => self.map_uuid(self.config.uuid),
896            Some("byte") => self.map_byte(self.config.byte),
897            Some("binary") => self.map_binary(self.config.binary),
898            Some("ipv4") => self.map_ipv4(self.config.ipv4),
899            Some("ipv6") => self.map_ipv6(self.config.ipv6),
900            Some("uri") | Some("url") => self.map_uri(self.config.uri),
901            Some("email") => self.map_email(self.config.email),
902            // Unknown formats (hostname, password, idn-email, etc.)
903            // and the no-format case fall through to plain String.
904            _ => MappedType::plain("String"),
905        }
906    }
907
908    /// Apply user + built-in format aliases (in that order — user
909    /// entries win on collision). Built-ins normalize common
910    /// vendor-isms like `uuid4` → `uuid` and `unix-time` → `int64`
911    /// so the standard format dispatch below sees canonical names.
912    fn normalize_format(&self, format: Option<&str>) -> Option<String> {
913        let raw = format?;
914        if let Some(target) = self.config.format_aliases.get(raw) {
915            return Some(target.clone());
916        }
917        Some(normalize_builtin_format(raw).to_string())
918    }
919
920    fn map_date_time(&self, strat: DateStrategy) -> MappedType {
921        match strat {
922            DateStrategy::String => MappedType::plain("String"),
923            DateStrategy::Chrono => {
924                self.record(TypeFeature::Chrono);
925                // chrono::DateTime<Utc> with the `serde` feature
926                // serializes as RFC 3339 by default and parses both
927                // `Z` and `+HH:MM` offsets on input. No `with`
928                // attribute required.
929                MappedType::with_feature("chrono::DateTime<chrono::Utc>", TypeFeature::Chrono)
930            }
931            DateStrategy::Time => {
932                self.record(TypeFeature::Time);
933                MappedType::with_codec(
934                    "time::OffsetDateTime",
935                    "time::serde::rfc3339",
936                    TypeFeature::Time,
937                )
938            }
939        }
940    }
941
942    fn map_date(&self, strat: DateStrategy) -> MappedType {
943        match strat {
944            DateStrategy::String => MappedType::plain("String"),
945            DateStrategy::Chrono => {
946                self.record(TypeFeature::Chrono);
947                // chrono derives serde via the `serde` feature; no
948                // codec needed for NaiveDate (ISO 8601 by default).
949                MappedType::with_feature("chrono::NaiveDate", TypeFeature::Chrono)
950            }
951            DateStrategy::Time => {
952                self.record(TypeFeature::TimeDate);
953                // `time::serde::iso8601` only supports
954                // OffsetDateTime; `time_date_format` is a codec
955                // module the generator emits into the file via
956                // `time::serde::format_description!` (GH #25).
957                MappedType::with_codec("time::Date", "time_date_format", TypeFeature::TimeDate)
958            }
959        }
960    }
961
962    fn map_time(&self, _strat: DateStrategy) -> MappedType {
963        // JSON Schema's `time` format is RFC 3339 `full-time`, not a local
964        // wall-clock time: `03:04:05Z` and `03:04:05.123-07:00` are canonical
965        // values. Neither chrono::NaiveTime nor time::Time has a UTC-offset
966        // component, so mapping to either type rejects or loses valid wire
967        // data. Preserve the exact string for every strategy; `date` and
968        // `date-time` continue to honor their configured typed mappings.
969        MappedType::plain("String")
970    }
971
972    fn map_duration(&self, strat: DurationStrategy) -> MappedType {
973        match strat {
974            DurationStrategy::String => MappedType::plain("String"),
975            DurationStrategy::Chrono => {
976                // Placeholder: chrono::Duration's native serde
977                // encodes seconds (not ISO 8601). A follow-up will
978                // emit an iso8601_duration_serde helper module and
979                // wire it via with_codec; for now downgrade to the
980                // String mapping so this strategy is safe to enable
981                // even before the helper exists.
982                MappedType::plain("String")
983            }
984            DurationStrategy::Iso8601 => {
985                self.record(TypeFeature::Iso8601);
986                MappedType::with_feature("iso8601::Duration", TypeFeature::Iso8601)
987            }
988        }
989    }
990
991    fn map_uuid(&self, strat: UuidStrategy) -> MappedType {
992        match strat {
993            UuidStrategy::String => MappedType::plain("String"),
994            UuidStrategy::Uuid => {
995                self.record(TypeFeature::Uuid);
996                MappedType::with_feature("uuid::Uuid", TypeFeature::Uuid)
997            }
998        }
999    }
1000
1001    fn map_byte(&self, strat: ByteStrategy) -> MappedType {
1002        match strat {
1003            ByteStrategy::String => MappedType::plain("String"),
1004            ByteStrategy::VecU8 => MappedType::plain("Vec<u8>"),
1005            ByteStrategy::Base64 | ByteStrategy::Base64UrlUnpadded => {
1006                self.record(TypeFeature::Base64);
1007                // Path is resolved relative to the generated
1008                // module; the helper module is emitted as
1009                // `base64_serde` at the top of `types.rs`. Its
1010                // alphabet is selected once during code generation.
1011                MappedType::with_codec("Vec<u8>", "base64_serde", TypeFeature::Base64)
1012            }
1013        }
1014    }
1015
1016    fn map_binary(&self, strat: BinaryStrategy) -> MappedType {
1017        match strat {
1018            BinaryStrategy::String => MappedType::plain("String"),
1019            BinaryStrategy::VecU8 => MappedType::with_inline_codec("Vec<u8>", "binary_vec_serde"),
1020            BinaryStrategy::Bytes => {
1021                self.record(TypeFeature::Bytes);
1022                MappedType::with_codec("bytes::Bytes", "binary_bytes_serde", TypeFeature::Bytes)
1023            }
1024        }
1025    }
1026
1027    fn map_ipv4(&self, strat: IpStrategy) -> MappedType {
1028        match strat {
1029            IpStrategy::String => MappedType::plain("String"),
1030            IpStrategy::Std => MappedType::plain("std::net::Ipv4Addr"),
1031        }
1032    }
1033
1034    fn map_ipv6(&self, strat: IpStrategy) -> MappedType {
1035        match strat {
1036            IpStrategy::String => MappedType::plain("String"),
1037            IpStrategy::Std => MappedType::plain("std::net::Ipv6Addr"),
1038        }
1039    }
1040
1041    fn map_uri(&self, strat: UriStrategy) -> MappedType {
1042        match strat {
1043            UriStrategy::String => MappedType::plain("String"),
1044            UriStrategy::Url => {
1045                self.record(TypeFeature::Url);
1046                MappedType::with_feature("url::Url", TypeFeature::Url)
1047            }
1048        }
1049    }
1050
1051    fn map_email(&self, strat: EmailStrategy) -> MappedType {
1052        match strat {
1053            EmailStrategy::String => MappedType::plain("String"),
1054            EmailStrategy::EmailAddress => {
1055                self.record(TypeFeature::EmailAddress);
1056                MappedType::with_feature("email_address::EmailAddress", TypeFeature::EmailAddress)
1057            }
1058        }
1059    }
1060
1061    /// Map `integer` + optional `format` → Rust type.
1062    ///
1063    /// Q2.1: honors `uint32` / `uint64` (and a few vendor variants
1064    /// like `uint`) when `config.unsigned` is true (default).
1065    /// Setting `unsigned = false` reverts to the pre-Q2.1 behavior
1066    /// where unsigned formats degrade to `i64`.
1067    pub fn integer_format(&self, format: Option<&str>) -> MappedType {
1068        let normalized = self.normalize_format(format);
1069        match normalized.as_deref() {
1070            Some("int32") => MappedType::plain("i32"),
1071            Some("int64") => MappedType::plain("i64"),
1072            Some("uint32") if self.config.unsigned => MappedType::plain("u32"),
1073            Some("uint64") if self.config.unsigned => MappedType::plain("u64"),
1074            // OAS-adjacent specs sometimes use bare `uint` — treat
1075            // it as 64-bit unsigned to match the broadest intended
1076            // domain.
1077            Some("uint") if self.config.unsigned => MappedType::plain("u64"),
1078            _ => MappedType::plain("i64"),
1079        }
1080    }
1081
1082    /// Map `number` + optional `format` → Rust type.
1083    ///
1084    /// `format: float` maps to `f64` by default rather than `f32`. JSON has no
1085    /// binary32: a value written on the wire as `0.03` parses losslessly into
1086    /// `f64`, but through `f32` it becomes `0.029999999329447746`. The declared
1087    /// format describes the server's internal storage, not the transport, so
1088    /// `f32` discards precision the response actually carried. Observed live on
1089    /// RunPod's catalog prices, which declare `float` while the billing
1090    /// endpoints declare `double`.
1091    ///
1092    /// Set `float_precision = "f32"` under `[generator.types]` to map strictly
1093    /// by declared format instead.
1094    pub fn number_format(&self, format: Option<&str>) -> MappedType {
1095        let normalized = self.normalize_format(format);
1096        match normalized.as_deref() {
1097            Some("float") if self.config.float_precision == FloatPrecision::F32 => {
1098                MappedType::plain("f32")
1099            }
1100            Some("float") => MappedType::plain("f64"),
1101            Some("double") => MappedType::plain("f64"),
1102            _ => MappedType::plain("f64"),
1103        }
1104    }
1105
1106    pub fn boolean(&self) -> MappedType {
1107        MappedType::plain("bool")
1108    }
1109
1110    pub fn untyped_array(&self) -> MappedType {
1111        MappedType::plain("Vec<serde_json::Value>")
1112    }
1113
1114    pub fn dynamic_json(&self) -> MappedType {
1115        MappedType::plain("serde_json::Value")
1116    }
1117
1118    pub fn null_unit(&self) -> MappedType {
1119        MappedType::plain("()")
1120    }
1121
1122    /// One-shot dispatch from `(OpenApiSchemaType, &SchemaDetails)`.
1123    pub fn map(&self, ty: OpenApiSchemaType, details: &SchemaDetails) -> MappedType {
1124        let format = details.format.as_deref();
1125        match ty {
1126            OpenApiSchemaType::String => self.string_format(format),
1127            OpenApiSchemaType::Integer => self.integer_format(format),
1128            OpenApiSchemaType::Number => self.number_format(format),
1129            OpenApiSchemaType::Boolean => self.boolean(),
1130            OpenApiSchemaType::Array => self.untyped_array(),
1131            OpenApiSchemaType::Object => self.dynamic_json(),
1132            OpenApiSchemaType::Null => self.null_unit(),
1133        }
1134    }
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139    use super::*;
1140
1141    fn details_with_format(format: Option<&str>) -> SchemaDetails {
1142        SchemaDetails {
1143            format: format.map(str::to_string),
1144            ..Default::default()
1145        }
1146    }
1147
1148    #[test]
1149    fn default_mapper_emits_typed_scalars_for_common_formats() {
1150        let m = TypeMapper::default();
1151        assert_eq!(
1152            m.string_format(Some("date-time")).rust_type,
1153            "chrono::DateTime<chrono::Utc>"
1154        );
1155        assert_eq!(m.string_format(Some("date")).rust_type, "chrono::NaiveDate");
1156        assert_eq!(m.string_format(Some("time")).rust_type, "String");
1157        assert_eq!(m.string_format(Some("uuid")).rust_type, "uuid::Uuid");
1158        assert_eq!(m.string_format(Some("uri")).rust_type, "url::Url");
1159        assert_eq!(
1160            m.string_format(Some("ipv4")).rust_type,
1161            "std::net::Ipv4Addr"
1162        );
1163        assert_eq!(m.string_format(Some("byte")).rust_type, "Vec<u8>");
1164        assert_eq!(m.string_format(Some("binary")).rust_type, "bytes::Bytes");
1165    }
1166
1167    #[test]
1168    fn date_time_uses_default_chrono_serde() {
1169        // chrono::DateTime<Utc> with the `serde` feature serializes
1170        // as RFC 3339 by default — no `with = ...` codec required.
1171        let m = TypeMapper::default();
1172        let mt = m.string_format(Some("date-time"));
1173        assert_eq!(mt.rust_type, "chrono::DateTime<chrono::Utc>");
1174        assert!(mt.serde_with.is_none());
1175        assert_eq!(mt.feature, Some(TypeFeature::Chrono));
1176    }
1177
1178    #[test]
1179    fn byte_emits_base64_codec() {
1180        let m = TypeMapper::default();
1181        let mt = m.string_format(Some("byte"));
1182        assert_eq!(mt.rust_type, "Vec<u8>");
1183        assert_eq!(mt.serde_with.as_deref(), Some("base64_serde"));
1184        assert_eq!(mt.feature, Some(TypeFeature::Base64));
1185    }
1186
1187    #[test]
1188    fn byte_url_unpadded_reuses_base64_codec() {
1189        let mapper = TypeMapper::new(TypeMappingConfig {
1190            byte: ByteStrategy::Base64UrlUnpadded,
1191            ..TypeMappingConfig::default()
1192        });
1193        let mapped = mapper.string_format(Some("byte"));
1194        assert_eq!(mapped.rust_type, "Vec<u8>");
1195        assert_eq!(mapped.serde_with.as_deref(), Some("base64_serde"));
1196        assert_eq!(mapped.feature, Some(TypeFeature::Base64));
1197    }
1198
1199    #[test]
1200    fn byte_url_unpadded_parses_from_toml() {
1201        let config: TypeMappingConfig =
1202            toml::from_str(r#"byte = "base64_url_unpadded""#).expect("parse type config");
1203        assert_eq!(config.byte, ByteStrategy::Base64UrlUnpadded);
1204    }
1205
1206    #[test]
1207    fn conservative_config_collapses_everything_to_string() {
1208        let m = TypeMapper::new(TypeMappingConfig::conservative());
1209        for fmt in [
1210            Some("date-time"),
1211            Some("uuid"),
1212            Some("uri"),
1213            Some("byte"),
1214            Some("binary"),
1215            Some("ipv4"),
1216            Some("ipv6"),
1217            Some("date"),
1218            None,
1219        ] {
1220            let mt = m.string_format(fmt);
1221            assert_eq!(mt.rust_type, "String", "format = {fmt:?}");
1222            assert!(mt.serde_with.is_none(), "format = {fmt:?}");
1223        }
1224    }
1225
1226    #[test]
1227    fn unknown_formats_fall_through_to_string() {
1228        let m = TypeMapper::default();
1229        for fmt in [Some("hostname"), Some("password"), Some("idn-email")] {
1230            assert_eq!(m.string_format(fmt).rust_type, "String");
1231        }
1232    }
1233
1234    #[test]
1235    fn integer_formats_match_pre_refactor_behavior() {
1236        let m = TypeMapper::default();
1237        assert_eq!(m.integer_format(Some("int32")).rust_type, "i32");
1238        assert_eq!(m.integer_format(Some("int64")).rust_type, "i64");
1239        assert_eq!(m.integer_format(None).rust_type, "i64");
1240    }
1241
1242    #[test]
1243    fn integer_formats_default_handles_unsigned_q21() {
1244        let m = TypeMapper::default();
1245        assert_eq!(m.integer_format(Some("uint32")).rust_type, "u32");
1246        assert_eq!(m.integer_format(Some("uint64")).rust_type, "u64");
1247        // Non-standard `uint` falls into the broader uint64 bucket.
1248        assert_eq!(m.integer_format(Some("uint")).rust_type, "u64");
1249    }
1250
1251    #[test]
1252    fn unsigned_off_degrades_uint_to_i64() {
1253        let mut cfg = TypeMappingConfig::default();
1254        cfg.unsigned = false;
1255        let m = TypeMapper::new(cfg);
1256        assert_eq!(m.integer_format(Some("uint32")).rust_type, "i64");
1257        assert_eq!(m.integer_format(Some("uint64")).rust_type, "i64");
1258    }
1259
1260    #[test]
1261    fn conservative_disables_unsigned() {
1262        let m = TypeMapper::new(TypeMappingConfig::conservative());
1263        assert_eq!(m.integer_format(Some("uint64")).rust_type, "i64");
1264    }
1265
1266    #[test]
1267    fn builtin_aliases_normalize_uuid_variants_to_uuid() {
1268        let m = TypeMapper::default();
1269        for fmt in ["uuid4", "uuid_v4", "UUID"] {
1270            assert_eq!(normalize_builtin_format(fmt), "uuid", "format = {fmt}");
1271            let mt = m.string_format(Some(fmt));
1272            assert_eq!(mt.rust_type, "uuid::Uuid", "format = {fmt}");
1273        }
1274        assert_eq!(normalize_builtin_format("vendor-id"), "vendor-id");
1275    }
1276
1277    #[test]
1278    fn builtin_aliases_normalize_unix_time_to_int64() {
1279        let m = TypeMapper::default();
1280        for fmt in ["unix-time", "unix_time", "unixtime", "timestamp"] {
1281            let mt = m.integer_format(Some(fmt));
1282            assert_eq!(mt.rust_type, "i64", "format = {fmt}");
1283        }
1284    }
1285
1286    #[test]
1287    fn user_alias_overrides_builtin() {
1288        let mut cfg = TypeMappingConfig::default();
1289        // User wants `uuid4` to mean plain string instead of uuid.
1290        cfg.format_aliases
1291            .insert("uuid4".to_string(), "hostname".to_string());
1292        let m = TypeMapper::new(cfg);
1293        // hostname is unmapped → falls through to String.
1294        assert_eq!(m.string_format(Some("uuid4")).rust_type, "String");
1295    }
1296
1297    #[test]
1298    fn used_features_records_referenced_crates() {
1299        let m = TypeMapper::default();
1300        let _ = m.string_format(Some("date-time"));
1301        let _ = m.string_format(Some("uuid"));
1302        let used = m.used_features();
1303        assert!(used.contains(TypeFeature::Chrono));
1304        assert!(used.contains(TypeFeature::Uuid));
1305        assert!(!used.contains(TypeFeature::Bytes));
1306    }
1307
1308    #[test]
1309    fn format_alias_normalizes_before_dispatch() {
1310        let mut cfg = TypeMappingConfig::default();
1311        cfg.format_aliases
1312            .insert("uuid4".to_string(), "uuid".to_string());
1313        let m = TypeMapper::new(cfg);
1314        assert_eq!(m.string_format(Some("uuid4")).rust_type, "uuid::Uuid");
1315    }
1316
1317    #[test]
1318    fn conservative_helper_round_trips() {
1319        let cfg = TypeMappingConfig::conservative();
1320        assert!(matches!(cfg.date_time, DateStrategy::String));
1321        assert!(matches!(cfg.uuid, UuidStrategy::String));
1322    }
1323
1324    #[test]
1325    fn dep_requirement_renders_features_list() {
1326        let dep = TypeFeature::Chrono.dep_requirement();
1327        assert_eq!(dep.crate_name, "chrono");
1328        assert_eq!(dep.features, vec!["serde"]);
1329        assert_eq!(
1330            dep.to_toml_line(),
1331            r#"chrono = { version = "0.4", features = ["serde"] }"#
1332        );
1333    }
1334
1335    #[test]
1336    fn dep_requirement_omits_features_when_none() {
1337        let dep = TypeFeature::Base64.dep_requirement();
1338        assert_eq!(dep.to_toml_line(), r#"base64 = "0.22""#);
1339    }
1340
1341    #[test]
1342    fn collect_dep_requirements_is_sorted_and_unique() {
1343        let mut used = UsedFeatures::default();
1344        used.insert(TypeFeature::Url);
1345        used.insert(TypeFeature::Chrono);
1346        used.insert(TypeFeature::Chrono); // duplicate
1347        used.insert(TypeFeature::Uuid);
1348        let deps = collect_dep_requirements(&used);
1349        assert_eq!(
1350            deps.iter().map(|d| d.crate_name).collect::<Vec<_>>(),
1351            vec!["chrono", "url", "uuid"]
1352        );
1353    }
1354
1355    #[test]
1356    fn render_required_deps_toml_is_none_when_empty() {
1357        let deps: Vec<DepRequirement> = Vec::new();
1358        assert!(render_required_deps_toml(&deps).is_none());
1359    }
1360
1361    #[test]
1362    fn render_required_deps_toml_includes_dependencies_block() {
1363        let deps = vec![
1364            TypeFeature::Chrono.dep_requirement(),
1365            TypeFeature::Uuid.dep_requirement(),
1366        ];
1367        let toml = render_required_deps_toml(&deps).expect("non-empty");
1368        assert!(toml.contains("[dependencies]"));
1369        assert!(toml.contains("chrono = "));
1370        assert!(toml.contains("uuid = "));
1371        assert!(toml.contains("# Generated by openapi-to-rust"));
1372    }
1373
1374    #[test]
1375    fn map_dispatches_through_helpers() {
1376        let m = TypeMapper::default();
1377        assert_eq!(
1378            m.map(
1379                OpenApiSchemaType::String,
1380                &details_with_format(Some("uuid"))
1381            )
1382            .rust_type,
1383            "uuid::Uuid"
1384        );
1385        assert_eq!(
1386            m.map(
1387                OpenApiSchemaType::Integer,
1388                &details_with_format(Some("int32"))
1389            )
1390            .rust_type,
1391            "i32"
1392        );
1393    }
1394}