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