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`] tracks which optional crates the mapper
19//!   actually emitted references to. Q2.8 will read this after
20//!   generation and write a `REQUIRED_DEPS.toml`.
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
45    /// [`UsedFeatures`] for the dep advisory (Q2.8).
46    pub feature: Option<TypeFeature>,
47}
48
49impl MappedType {
50    /// Construct a plain mapping with no codec and no external crate.
51    pub fn plain(rust_type: impl Into<String>) -> Self {
52        Self {
53            rust_type: rust_type.into(),
54            serde_with: None,
55            feature: None,
56        }
57    }
58
59    /// Plain mapping that records a feature crate (e.g. for types like
60    /// `std::net::Ipv4Addr` we don't need a codec but we don't need a
61    /// crate either — this helper is for crates that derive `serde`
62    /// directly on the type).
63    pub fn with_feature(rust_type: impl Into<String>, feature: TypeFeature) -> Self {
64        Self {
65            rust_type: rust_type.into(),
66            serde_with: None,
67            feature: Some(feature),
68        }
69    }
70
71    /// Mapping that requires a `#[serde(with = ...)]` codec.
72    pub fn with_codec(
73        rust_type: impl Into<String>,
74        codec_path: impl Into<String>,
75        feature: TypeFeature,
76    ) -> Self {
77        Self {
78            rust_type: rust_type.into(),
79            serde_with: Some(codec_path.into()),
80            feature: Some(feature),
81        }
82    }
83}
84
85/// Identifies an optional crate a mapping introduced.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
87pub enum TypeFeature {
88    Chrono,
89    Time,
90    /// `time::Date` via the generated `time_date_format` codec.
91    /// Tracked separately from [`TypeFeature::Time`] so the
92    /// generator only emits the `time::serde::format_description!`
93    /// helper when a `format: date` field actually exists.
94    TimeDate,
95    /// `time::Time` via the generated `time_time_format` codec.
96    TimeTime,
97    Iso8601,
98    Uuid,
99    Bytes,
100    Base64,
101    Url,
102    EmailAddress,
103}
104
105impl TypeFeature {
106    /// Canonical dependency line for this feature. Q2.8 uses this to
107    /// emit `REQUIRED_DEPS.toml` next to the generated code so users
108    /// know exactly which crates to add to their Cargo.toml.
109    pub fn dep_requirement(self) -> DepRequirement {
110        match self {
111            Self::Chrono => DepRequirement::new("chrono", "0.4").with_features(&["serde"]),
112            // `serde` alone doesn't enable `time::serde::rfc3339`;
113            // the codec modules are gated on formatting/parsing.
114            Self::Time => DepRequirement::new("time", "0.3").with_features(&[
115                "serde",
116                "formatting",
117                "parsing",
118            ]),
119            // `macros` on top: Date/Time have no built-in serde
120            // codec, so the generated code declares one via
121            // `time::serde::format_description!`.
122            Self::TimeDate | Self::TimeTime => DepRequirement::new("time", "0.3").with_features(&[
123                "serde",
124                "formatting",
125                "parsing",
126                "macros",
127            ]),
128            Self::Iso8601 => DepRequirement::new("iso8601", "0.6"),
129            Self::Uuid => DepRequirement::new("uuid", "1").with_features(&["serde", "v4"]),
130            Self::Bytes => DepRequirement::new("bytes", "1").with_features(&["serde"]),
131            Self::Base64 => DepRequirement::new("base64", "0.22"),
132            Self::Url => DepRequirement::new("url", "2").with_features(&["serde"]),
133            Self::EmailAddress => DepRequirement::new("email_address", "0.2"),
134        }
135    }
136}
137
138/// One crate the generated code needs in its `Cargo.toml`.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct DepRequirement {
141    pub crate_name: &'static str,
142    pub version: &'static str,
143    pub features: Vec<&'static str>,
144}
145
146impl DepRequirement {
147    pub fn new(crate_name: &'static str, version: &'static str) -> Self {
148        Self {
149            crate_name,
150            version,
151            features: Vec::new(),
152        }
153    }
154
155    pub fn with_features(mut self, features: &[&'static str]) -> Self {
156        self.features = features.to_vec();
157        self
158    }
159
160    /// Render as a single TOML `[dependencies]` line. Picks the
161    /// most compact form that still expresses the required features.
162    pub fn to_toml_line(&self) -> String {
163        if self.features.is_empty() {
164            format!("{} = \"{}\"", self.crate_name, self.version)
165        } else {
166            let feats = self
167                .features
168                .iter()
169                .map(|f| format!("\"{f}\""))
170                .collect::<Vec<_>>()
171                .join(", ");
172            format!(
173                "{} = {{ version = \"{}\", features = [{}] }}",
174                self.crate_name, self.version, feats
175            )
176        }
177    }
178}
179
180/// Render `REQUIRED_DEPS.toml` content from a sorted set of
181/// requirements. Returns `None` when the input is empty so the
182/// caller can skip writing the file (no clutter when no optional
183/// crates were used).
184pub fn render_required_deps_toml(deps: &[DepRequirement]) -> Option<String> {
185    if deps.is_empty() {
186        return None;
187    }
188    let mut out = String::new();
189    out.push_str(
190        "# Generated by openapi-to-rust.\n\
191         # These crates are required by the typed-scalar formats used\n\
192         # in your OpenAPI spec. Copy these lines into the [dependencies]\n\
193         # section of your consuming crate's Cargo.toml.\n\
194         #\n\
195         # To opt out of typed scalars (and avoid these deps), set\n\
196         # the relevant strategies to \"string\" in [generator.types],\n\
197         # or pass --types-conservative on the CLI.\n\
198         \n\
199         [dependencies]\n",
200    );
201    for dep in deps {
202        out.push_str(&dep.to_toml_line());
203        out.push('\n');
204    }
205    Some(out)
206}
207
208/// Snapshot a `UsedFeatures` set as a sorted, de-duplicated list of
209/// `DepRequirement`s. Sorting by crate name keeps the emitted file
210/// deterministic so it can be checked in or diffed.
211pub fn collect_dep_requirements(used: &UsedFeatures) -> Vec<DepRequirement> {
212    let mut deps: Vec<DepRequirement> = used.iter().map(|f| f.dep_requirement()).collect();
213    deps.sort_by_key(|d| d.crate_name);
214    // Several features can point at the same crate with different
215    // feature lists (e.g. Time vs TimeDate both need `time`); union
216    // the features so the single emitted line satisfies all of them.
217    let mut merged: Vec<DepRequirement> = Vec::new();
218    for dep in deps {
219        match merged.last_mut() {
220            Some(last) if last.crate_name == dep.crate_name => {
221                for feat in dep.features {
222                    if !last.features.contains(&feat) {
223                        last.features.push(feat);
224                    }
225                }
226            }
227            _ => merged.push(dep),
228        }
229    }
230    merged
231}
232
233/// Tracks which optional crates the generator emitted code for.
234#[derive(Debug, Default, Clone)]
235pub struct UsedFeatures {
236    set: BTreeSet<TypeFeature>,
237}
238
239impl UsedFeatures {
240    pub fn insert(&mut self, feature: TypeFeature) {
241        self.set.insert(feature);
242    }
243
244    pub fn contains(&self, feature: TypeFeature) -> bool {
245        self.set.contains(&feature)
246    }
247
248    pub fn iter(&self) -> impl Iterator<Item = &TypeFeature> {
249        self.set.iter()
250    }
251
252    pub fn is_empty(&self) -> bool {
253        self.set.is_empty()
254    }
255}
256
257// =====================================================================
258// Strategy enums
259// =====================================================================
260
261/// Strategy for `format: date-time | date | time`.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
263#[serde(rename_all = "lowercase")]
264pub enum DateStrategy {
265    /// Plain `String`. Pre-Q2 behavior; pick this to opt out.
266    String,
267    /// `chrono::DateTime<Utc>` / `NaiveDate` / `NaiveTime` (default).
268    #[default]
269    Chrono,
270    /// `time::OffsetDateTime` / `Date` / `Time`.
271    Time,
272}
273
274/// Strategy for `format: duration` (ISO 8601 durations).
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
276#[serde(rename_all = "lowercase")]
277pub enum DurationStrategy {
278    // Off by default — `format: duration` is ISO 8601 (e.g.
279    // "PT1H30M") but `chrono::Duration`'s native serde encodes
280    // seconds. Round-tripping requires a custom parser that we'll
281    // land in a follow-up; for now `duration` stays String so
282    // default-on doesn't break specs that emit ISO 8601 strings
283    // the chrono codec couldn't decode.
284    #[default]
285    String,
286    /// `chrono::Duration`. Round-trips ISO 8601 durations via a
287    /// small custom serde module emitted into the generated crate.
288    Chrono,
289    /// `iso8601::Duration` from the `iso8601` crate.
290    Iso8601,
291}
292
293/// Strategy for `format: uuid` (or normalized aliases).
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
295#[serde(rename_all = "lowercase")]
296pub enum UuidStrategy {
297    String,
298    /// `uuid::Uuid` (default).
299    #[default]
300    Uuid,
301}
302
303/// Strategy for `format: byte` (base64-encoded binary on the wire).
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
305#[serde(rename_all = "snake_case")]
306pub enum ByteStrategy {
307    String,
308    /// `Vec<u8>` round-tripped via an inlined `base64_serde` module
309    /// (default).
310    #[default]
311    Base64,
312    /// `Vec<u8>` with no codec (caller responsible for encoding).
313    VecU8,
314}
315
316/// Strategy for `format: binary` (raw octets).
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
318#[serde(rename_all = "snake_case")]
319pub enum BinaryStrategy {
320    String,
321    /// `bytes::Bytes` (default).
322    #[default]
323    Bytes,
324    VecU8,
325}
326
327/// Strategy for `format: ipv4 | ipv6`.
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
329#[serde(rename_all = "lowercase")]
330pub enum IpStrategy {
331    String,
332    /// `std::net::Ipv4Addr` / `Ipv6Addr` (default; pure std, no deps).
333    #[default]
334    Std,
335}
336
337/// Strategy for `format: uri | url`.
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
339#[serde(rename_all = "lowercase")]
340pub enum UriStrategy {
341    String,
342    /// `url::Url` (default).
343    #[default]
344    Url,
345}
346
347/// Strategy for `format: email`.
348///
349/// Email is **off by default** — the `email_address` crate is more
350/// opinionated than the wire ever guarantees, and most APIs treat
351/// emails as opaque strings.
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
353#[serde(rename_all = "snake_case")]
354pub enum EmailStrategy {
355    #[default]
356    String,
357    EmailAddress,
358}
359
360// =====================================================================
361// Top-level config
362// =====================================================================
363
364/// Configuration for [`TypeMapper`]. Mirrors the `[generator.types]`
365/// TOML section. Defaults flip on every common typed scalar; opt out
366/// per format by setting the strategy to `string` in TOML.
367#[derive(Debug, Clone, Deserialize, Serialize)]
368#[serde(default, rename_all = "snake_case")]
369pub struct TypeMappingConfig {
370    pub date_time: DateStrategy,
371    pub date: DateStrategy,
372    pub time: DateStrategy,
373    pub duration: DurationStrategy,
374    pub uuid: UuidStrategy,
375    pub byte: ByteStrategy,
376    pub binary: BinaryStrategy,
377    pub ipv4: IpStrategy,
378    pub ipv6: IpStrategy,
379    pub uri: UriStrategy,
380    pub email: EmailStrategy,
381
382    /// Q2.1: honor `format: uint32` / `uint64` integer formats and
383    /// map them to `u32` / `u64` respectively. Default `true` (cheap,
384    /// no extra crate). Set `false` to revert to the pre-Q2.1
385    /// behavior where unsigned formats degraded to `i64`.
386    #[serde(default = "default_true")]
387    pub unsigned: bool,
388
389    /// Q2.2: user-extensible format aliases applied before standard
390    /// format dispatch (e.g. `"uuid4" -> "uuid"`,
391    /// `"unix-time" -> "int64"`). Built-in defaults are merged with
392    /// user-supplied entries; user entries win on collision.
393    #[serde(default)]
394    pub format_aliases: BTreeMap<String, String>,
395
396    /// Object/array shape toggles. Filled in by Q2.3, Q2.5, Q2.7.
397    pub shape: Option<TypeShapeConfig>,
398
399    /// Constraint annotation mode. Filled in by Q2.4.
400    pub constraints: Option<TypeConstraintsConfig>,
401
402    /// Vendor-extension toggles for enums. Filled in by Q2.6.
403    pub enums: Option<TypeEnumsConfig>,
404}
405
406fn default_true() -> bool {
407    true
408}
409
410impl Default for TypeMappingConfig {
411    fn default() -> Self {
412        Self {
413            date_time: DateStrategy::default(),
414            date: DateStrategy::default(),
415            time: DateStrategy::default(),
416            duration: DurationStrategy::default(),
417            uuid: UuidStrategy::default(),
418            byte: ByteStrategy::default(),
419            binary: BinaryStrategy::default(),
420            ipv4: IpStrategy::default(),
421            ipv6: IpStrategy::default(),
422            uri: UriStrategy::default(),
423            email: EmailStrategy::default(),
424            unsigned: true,
425            format_aliases: BTreeMap::new(),
426            shape: None,
427            constraints: None,
428            enums: None,
429        }
430    }
431}
432
433/// Built-in format aliases applied before user-supplied
434/// [`TypeMappingConfig::format_aliases`]. These normalize common
435/// vendor-isms found in real-world specs so the standard format
436/// dispatch in [`TypeMapper::string_format`] /
437/// [`TypeMapper::integer_format`] sees canonical names.
438fn builtin_format_aliases() -> &'static [(&'static str, &'static str)] {
439    &[
440        ("uuid4", "uuid"),
441        ("uuid_v4", "uuid"),
442        ("UUID", "uuid"),
443        ("unix-time", "int64"),
444        ("unix_time", "int64"),
445        ("unixtime", "int64"),
446        ("timestamp", "int64"),
447    ]
448}
449
450impl TypeMappingConfig {
451    /// Q2.4: constraint-doc emission mode. Defaults to
452    /// [`ConstraintMode::Doc`] when the
453    /// `[generator.types.constraints]` block is absent or its
454    /// `mode` field is unset.
455    pub fn constraint_mode(&self) -> ConstraintMode {
456        self.constraints
457            .as_ref()
458            .and_then(|c| c.mode)
459            .unwrap_or_default()
460    }
461
462    /// Q2.6: should `x-enum-varnames` override the heuristic
463    /// PascalCase variant naming? Default true.
464    pub fn x_enum_varnames_enabled(&self) -> bool {
465        self.enums
466            .as_ref()
467            .and_then(|e| e.x_enum_varnames)
468            .unwrap_or(true)
469    }
470
471    /// Q2.6: should `x-enum-descriptions` emit per-variant doc
472    /// comments? Default true.
473    pub fn x_enum_descriptions_enabled(&self) -> bool {
474        self.enums
475            .as_ref()
476            .and_then(|e| e.x_enum_descriptions)
477            .unwrap_or(true)
478    }
479
480    /// Pre-Q2 behavior — every format renders as `String` and
481    /// integer formats degrade to `i64`. Users opt in via
482    /// `--types-conservative` when bisecting regressions introduced
483    /// by typed-scalar adoption.
484    pub fn conservative() -> Self {
485        Self {
486            date_time: DateStrategy::String,
487            date: DateStrategy::String,
488            time: DateStrategy::String,
489            duration: DurationStrategy::String,
490            uuid: UuidStrategy::String,
491            byte: ByteStrategy::String,
492            binary: BinaryStrategy::String,
493            ipv4: IpStrategy::String,
494            ipv6: IpStrategy::String,
495            uri: UriStrategy::String,
496            email: EmailStrategy::String,
497            unsigned: false,
498            format_aliases: BTreeMap::new(),
499            shape: None,
500            constraints: None,
501            enums: None,
502        }
503    }
504}
505
506#[derive(Debug, Clone, Default, Deserialize, Serialize)]
507#[serde(default, rename_all = "snake_case")]
508pub struct TypeShapeConfig {
509    pub additional_properties_typed: Option<bool>,
510    pub unique_items_to_set: Option<bool>,
511    pub primitive_unions: Option<bool>,
512}
513
514#[derive(Debug, Clone, Default, Deserialize, Serialize)]
515#[serde(default, rename_all = "snake_case")]
516pub struct TypeConstraintsConfig {
517    /// Q2.4 constraint annotation mode. Defaults to `Doc` when the
518    /// `[generator.types.constraints]` block is absent (see
519    /// [`TypeMapper::config_constraint_mode`]).
520    pub mode: Option<ConstraintMode>,
521}
522
523/// Q2.4 — what to emit for OpenAPI constraint keywords
524/// (`minimum`/`maximum`/`minLength`/`maxLength`/`pattern`/etc.).
525///
526/// **No client-side validation.** Constraints belong to the wire
527/// contract; the server is the source of truth. The generator
528/// surfaces them only as doc-comments so callers see the rules
529/// without the SDK duplicating server logic and going brittle
530/// when the rules drift.
531#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
532#[serde(rename_all = "snake_case")]
533pub enum ConstraintMode {
534    /// Drop constraints entirely (pre-Q2.4 behavior).
535    Off,
536    /// Emit `/// Constraint: ...` doc comments on each field.
537    /// Cheap, no extra crate dependency. Default.
538    #[default]
539    Doc,
540}
541
542#[derive(Debug, Clone, Default, Deserialize, Serialize)]
543#[serde(default, rename_all = "snake_case")]
544pub struct TypeEnumsConfig {
545    pub x_enum_varnames: Option<bool>,
546    pub x_enum_descriptions: Option<bool>,
547}
548
549// =====================================================================
550// TypeMapper
551// =====================================================================
552
553pub struct TypeMapper {
554    config: TypeMappingConfig,
555    used: RefCell<UsedFeatures>,
556}
557
558impl Default for TypeMapper {
559    fn default() -> Self {
560        Self::new(TypeMappingConfig::default())
561    }
562}
563
564impl TypeMapper {
565    pub fn new(config: TypeMappingConfig) -> Self {
566        Self {
567            config,
568            used: RefCell::new(UsedFeatures::default()),
569        }
570    }
571
572    /// Snapshot of crates this mapper has emitted references to.
573    /// Read after generation by Q2.8 to write `REQUIRED_DEPS.toml`.
574    pub fn used_features(&self) -> UsedFeatures {
575        self.used.borrow().clone()
576    }
577
578    /// Borrow the underlying type-mapping config — useful for
579    /// non-format-mapping toggles (`shape`, `enums`, `constraints`)
580    /// that other modules need to inspect.
581    pub fn config(&self) -> &TypeMappingConfig {
582        &self.config
583    }
584
585    /// Q2.7 helper: should `anyOf` of primitives become an untagged
586    /// enum with primitive variant types directly (true), or fall
587    /// back to the pre-Q2.7 type-alias-per-variant shape (false)?
588    /// Default: true.
589    pub fn config_shape_primitive_unions(&self) -> Option<bool> {
590        self.config.shape.as_ref().and_then(|s| s.primitive_unions)
591    }
592
593    /// Q2.3 helper: should `additionalProperties: <schema>` produce
594    /// `BTreeMap<String, T>` (true) or degrade to `BTreeMap<String,
595    /// serde_json::Value>` (false)? Default: true.
596    pub fn config_shape_additional_properties_typed(&self) -> Option<bool> {
597        self.config
598            .shape
599            .as_ref()
600            .and_then(|s| s.additional_properties_typed)
601    }
602
603    /// Q2.4 helper: which constraint-annotation mode is active?
604    /// Defaults to [`ConstraintMode::Doc`] when the
605    /// `[generator.types.constraints]` block is absent or its `mode`
606    /// field is unset.
607    pub fn config_constraint_mode(&self) -> ConstraintMode {
608        self.config
609            .constraints
610            .as_ref()
611            .and_then(|c| c.mode)
612            .unwrap_or_default()
613    }
614
615    fn record(&self, feature: TypeFeature) {
616        self.used.borrow_mut().insert(feature);
617    }
618
619    /// Map `string` + optional `format` → typed Rust scalar.
620    ///
621    /// Routing:
622    /// 1. Apply user-provided + built-in `format_aliases`.
623    /// 2. Dispatch on the normalized format.
624    /// 3. Honor each format's strategy in `self.config`.
625    /// 4. Record any introduced crate in `used_features`.
626    pub fn string_format(&self, format: Option<&str>) -> MappedType {
627        let normalized = self.normalize_format(format);
628        match normalized.as_deref() {
629            Some("date-time") => self.map_date_time(self.config.date_time),
630            Some("date") => self.map_date(self.config.date),
631            Some("time") => self.map_time(self.config.time),
632            Some("duration") => self.map_duration(self.config.duration),
633            Some("uuid") => self.map_uuid(self.config.uuid),
634            Some("byte") => self.map_byte(self.config.byte),
635            Some("binary") => self.map_binary(self.config.binary),
636            Some("ipv4") => self.map_ipv4(self.config.ipv4),
637            Some("ipv6") => self.map_ipv6(self.config.ipv6),
638            Some("uri") | Some("url") => self.map_uri(self.config.uri),
639            Some("email") => self.map_email(self.config.email),
640            // Unknown formats (hostname, password, idn-email, etc.)
641            // and the no-format case fall through to plain String.
642            _ => MappedType::plain("String"),
643        }
644    }
645
646    /// Apply user + built-in format aliases (in that order — user
647    /// entries win on collision). Built-ins normalize common
648    /// vendor-isms like `uuid4` → `uuid` and `unix-time` → `int64`
649    /// so the standard format dispatch below sees canonical names.
650    fn normalize_format(&self, format: Option<&str>) -> Option<String> {
651        let raw = format?;
652        if let Some(target) = self.config.format_aliases.get(raw) {
653            return Some(target.clone());
654        }
655        for (from, to) in builtin_format_aliases() {
656            if *from == raw {
657                return Some((*to).to_string());
658            }
659        }
660        Some(raw.to_string())
661    }
662
663    fn map_date_time(&self, strat: DateStrategy) -> MappedType {
664        match strat {
665            DateStrategy::String => MappedType::plain("String"),
666            DateStrategy::Chrono => {
667                self.record(TypeFeature::Chrono);
668                // chrono::DateTime<Utc> with the `serde` feature
669                // serializes as RFC 3339 by default and parses both
670                // `Z` and `+HH:MM` offsets on input. No `with`
671                // attribute required.
672                MappedType::with_feature("chrono::DateTime<chrono::Utc>", TypeFeature::Chrono)
673            }
674            DateStrategy::Time => {
675                self.record(TypeFeature::Time);
676                MappedType::with_codec(
677                    "time::OffsetDateTime",
678                    "time::serde::rfc3339",
679                    TypeFeature::Time,
680                )
681            }
682        }
683    }
684
685    fn map_date(&self, strat: DateStrategy) -> MappedType {
686        match strat {
687            DateStrategy::String => MappedType::plain("String"),
688            DateStrategy::Chrono => {
689                self.record(TypeFeature::Chrono);
690                // chrono derives serde via the `serde` feature; no
691                // codec needed for NaiveDate (ISO 8601 by default).
692                MappedType::with_feature("chrono::NaiveDate", TypeFeature::Chrono)
693            }
694            DateStrategy::Time => {
695                self.record(TypeFeature::TimeDate);
696                // `time::serde::iso8601` only supports
697                // OffsetDateTime; `time_date_format` is a codec
698                // module the generator emits into the file via
699                // `time::serde::format_description!` (GH #25).
700                MappedType::with_codec("time::Date", "time_date_format", TypeFeature::TimeDate)
701            }
702        }
703    }
704
705    fn map_time(&self, strat: DateStrategy) -> MappedType {
706        match strat {
707            DateStrategy::String => MappedType::plain("String"),
708            DateStrategy::Chrono => {
709                self.record(TypeFeature::Chrono);
710                MappedType::with_feature("chrono::NaiveTime", TypeFeature::Chrono)
711            }
712            DateStrategy::Time => {
713                self.record(TypeFeature::TimeTime);
714                // Same story as `time::Date`: no built-in codec, so
715                // the generator emits `time_time_format`.
716                MappedType::with_codec("time::Time", "time_time_format", TypeFeature::TimeTime)
717            }
718        }
719    }
720
721    fn map_duration(&self, strat: DurationStrategy) -> MappedType {
722        match strat {
723            DurationStrategy::String => MappedType::plain("String"),
724            DurationStrategy::Chrono => {
725                // Placeholder: chrono::Duration's native serde
726                // encodes seconds (not ISO 8601). A follow-up will
727                // emit an iso8601_duration_serde helper module and
728                // wire it via with_codec; for now downgrade to the
729                // String mapping so this strategy is safe to enable
730                // even before the helper exists.
731                MappedType::plain("String")
732            }
733            DurationStrategy::Iso8601 => {
734                self.record(TypeFeature::Iso8601);
735                MappedType::with_feature("iso8601::Duration", TypeFeature::Iso8601)
736            }
737        }
738    }
739
740    fn map_uuid(&self, strat: UuidStrategy) -> MappedType {
741        match strat {
742            UuidStrategy::String => MappedType::plain("String"),
743            UuidStrategy::Uuid => {
744                self.record(TypeFeature::Uuid);
745                MappedType::with_feature("uuid::Uuid", TypeFeature::Uuid)
746            }
747        }
748    }
749
750    fn map_byte(&self, strat: ByteStrategy) -> MappedType {
751        match strat {
752            ByteStrategy::String => MappedType::plain("String"),
753            ByteStrategy::VecU8 => MappedType::plain("Vec<u8>"),
754            ByteStrategy::Base64 => {
755                self.record(TypeFeature::Base64);
756                // Path is resolved relative to the generated
757                // module; the helper module is emitted as
758                // `base64_serde` at the top of `types.rs`.
759                MappedType::with_codec("Vec<u8>", "base64_serde", TypeFeature::Base64)
760            }
761        }
762    }
763
764    fn map_binary(&self, strat: BinaryStrategy) -> MappedType {
765        match strat {
766            BinaryStrategy::String => MappedType::plain("String"),
767            BinaryStrategy::VecU8 => MappedType::plain("Vec<u8>"),
768            BinaryStrategy::Bytes => {
769                self.record(TypeFeature::Bytes);
770                MappedType::with_feature("bytes::Bytes", TypeFeature::Bytes)
771            }
772        }
773    }
774
775    fn map_ipv4(&self, strat: IpStrategy) -> MappedType {
776        match strat {
777            IpStrategy::String => MappedType::plain("String"),
778            IpStrategy::Std => MappedType::plain("std::net::Ipv4Addr"),
779        }
780    }
781
782    fn map_ipv6(&self, strat: IpStrategy) -> MappedType {
783        match strat {
784            IpStrategy::String => MappedType::plain("String"),
785            IpStrategy::Std => MappedType::plain("std::net::Ipv6Addr"),
786        }
787    }
788
789    fn map_uri(&self, strat: UriStrategy) -> MappedType {
790        match strat {
791            UriStrategy::String => MappedType::plain("String"),
792            UriStrategy::Url => {
793                self.record(TypeFeature::Url);
794                MappedType::with_feature("url::Url", TypeFeature::Url)
795            }
796        }
797    }
798
799    fn map_email(&self, strat: EmailStrategy) -> MappedType {
800        match strat {
801            EmailStrategy::String => MappedType::plain("String"),
802            EmailStrategy::EmailAddress => {
803                self.record(TypeFeature::EmailAddress);
804                MappedType::with_feature("email_address::EmailAddress", TypeFeature::EmailAddress)
805            }
806        }
807    }
808
809    /// Map `integer` + optional `format` → Rust type.
810    ///
811    /// Q2.1: honors `uint32` / `uint64` (and a few vendor variants
812    /// like `uint`) when `config.unsigned` is true (default).
813    /// Setting `unsigned = false` reverts to the pre-Q2.1 behavior
814    /// where unsigned formats degrade to `i64`.
815    pub fn integer_format(&self, format: Option<&str>) -> MappedType {
816        let normalized = self.normalize_format(format);
817        match normalized.as_deref() {
818            Some("int32") => MappedType::plain("i32"),
819            Some("int64") => MappedType::plain("i64"),
820            Some("uint32") if self.config.unsigned => MappedType::plain("u32"),
821            Some("uint64") if self.config.unsigned => MappedType::plain("u64"),
822            // OAS-adjacent specs sometimes use bare `uint` — treat
823            // it as 64-bit unsigned to match the broadest intended
824            // domain.
825            Some("uint") if self.config.unsigned => MappedType::plain("u64"),
826            _ => MappedType::plain("i64"),
827        }
828    }
829
830    pub fn number_format(&self, format: Option<&str>) -> MappedType {
831        let normalized = self.normalize_format(format);
832        match normalized.as_deref() {
833            Some("float") => MappedType::plain("f32"),
834            Some("double") => MappedType::plain("f64"),
835            _ => MappedType::plain("f64"),
836        }
837    }
838
839    pub fn boolean(&self) -> MappedType {
840        MappedType::plain("bool")
841    }
842
843    pub fn untyped_array(&self) -> MappedType {
844        MappedType::plain("Vec<serde_json::Value>")
845    }
846
847    pub fn dynamic_json(&self) -> MappedType {
848        MappedType::plain("serde_json::Value")
849    }
850
851    pub fn null_unit(&self) -> MappedType {
852        MappedType::plain("()")
853    }
854
855    /// One-shot dispatch from `(OpenApiSchemaType, &SchemaDetails)`.
856    pub fn map(&self, ty: OpenApiSchemaType, details: &SchemaDetails) -> MappedType {
857        let format = details.format.as_deref();
858        match ty {
859            OpenApiSchemaType::String => self.string_format(format),
860            OpenApiSchemaType::Integer => self.integer_format(format),
861            OpenApiSchemaType::Number => self.number_format(format),
862            OpenApiSchemaType::Boolean => self.boolean(),
863            OpenApiSchemaType::Array => self.untyped_array(),
864            OpenApiSchemaType::Object => self.dynamic_json(),
865            OpenApiSchemaType::Null => self.null_unit(),
866        }
867    }
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    fn details_with_format(format: Option<&str>) -> SchemaDetails {
875        SchemaDetails {
876            format: format.map(str::to_string),
877            ..Default::default()
878        }
879    }
880
881    #[test]
882    fn default_mapper_emits_typed_scalars_for_common_formats() {
883        let m = TypeMapper::default();
884        assert_eq!(
885            m.string_format(Some("date-time")).rust_type,
886            "chrono::DateTime<chrono::Utc>"
887        );
888        assert_eq!(m.string_format(Some("date")).rust_type, "chrono::NaiveDate");
889        assert_eq!(m.string_format(Some("uuid")).rust_type, "uuid::Uuid");
890        assert_eq!(m.string_format(Some("uri")).rust_type, "url::Url");
891        assert_eq!(
892            m.string_format(Some("ipv4")).rust_type,
893            "std::net::Ipv4Addr"
894        );
895        assert_eq!(m.string_format(Some("byte")).rust_type, "Vec<u8>");
896        assert_eq!(m.string_format(Some("binary")).rust_type, "bytes::Bytes");
897    }
898
899    #[test]
900    fn date_time_uses_default_chrono_serde() {
901        // chrono::DateTime<Utc> with the `serde` feature serializes
902        // as RFC 3339 by default — no `with = ...` codec required.
903        let m = TypeMapper::default();
904        let mt = m.string_format(Some("date-time"));
905        assert_eq!(mt.rust_type, "chrono::DateTime<chrono::Utc>");
906        assert!(mt.serde_with.is_none());
907        assert_eq!(mt.feature, Some(TypeFeature::Chrono));
908    }
909
910    #[test]
911    fn byte_emits_base64_codec() {
912        let m = TypeMapper::default();
913        let mt = m.string_format(Some("byte"));
914        assert_eq!(mt.rust_type, "Vec<u8>");
915        assert_eq!(mt.serde_with.as_deref(), Some("base64_serde"));
916        assert_eq!(mt.feature, Some(TypeFeature::Base64));
917    }
918
919    #[test]
920    fn conservative_config_collapses_everything_to_string() {
921        let m = TypeMapper::new(TypeMappingConfig::conservative());
922        for fmt in [
923            Some("date-time"),
924            Some("uuid"),
925            Some("uri"),
926            Some("byte"),
927            Some("binary"),
928            Some("ipv4"),
929            Some("ipv6"),
930            Some("date"),
931            None,
932        ] {
933            let mt = m.string_format(fmt);
934            assert_eq!(mt.rust_type, "String", "format = {fmt:?}");
935            assert!(mt.serde_with.is_none(), "format = {fmt:?}");
936        }
937    }
938
939    #[test]
940    fn unknown_formats_fall_through_to_string() {
941        let m = TypeMapper::default();
942        for fmt in [Some("hostname"), Some("password"), Some("idn-email")] {
943            assert_eq!(m.string_format(fmt).rust_type, "String");
944        }
945    }
946
947    #[test]
948    fn integer_formats_match_pre_refactor_behavior() {
949        let m = TypeMapper::default();
950        assert_eq!(m.integer_format(Some("int32")).rust_type, "i32");
951        assert_eq!(m.integer_format(Some("int64")).rust_type, "i64");
952        assert_eq!(m.integer_format(None).rust_type, "i64");
953    }
954
955    #[test]
956    fn integer_formats_default_handles_unsigned_q21() {
957        let m = TypeMapper::default();
958        assert_eq!(m.integer_format(Some("uint32")).rust_type, "u32");
959        assert_eq!(m.integer_format(Some("uint64")).rust_type, "u64");
960        // Non-standard `uint` falls into the broader uint64 bucket.
961        assert_eq!(m.integer_format(Some("uint")).rust_type, "u64");
962    }
963
964    #[test]
965    fn unsigned_off_degrades_uint_to_i64() {
966        let mut cfg = TypeMappingConfig::default();
967        cfg.unsigned = false;
968        let m = TypeMapper::new(cfg);
969        assert_eq!(m.integer_format(Some("uint32")).rust_type, "i64");
970        assert_eq!(m.integer_format(Some("uint64")).rust_type, "i64");
971    }
972
973    #[test]
974    fn conservative_disables_unsigned() {
975        let m = TypeMapper::new(TypeMappingConfig::conservative());
976        assert_eq!(m.integer_format(Some("uint64")).rust_type, "i64");
977    }
978
979    #[test]
980    fn builtin_aliases_normalize_uuid_variants_to_uuid() {
981        let m = TypeMapper::default();
982        for fmt in ["uuid4", "uuid_v4", "UUID"] {
983            let mt = m.string_format(Some(fmt));
984            assert_eq!(mt.rust_type, "uuid::Uuid", "format = {fmt}");
985        }
986    }
987
988    #[test]
989    fn builtin_aliases_normalize_unix_time_to_int64() {
990        let m = TypeMapper::default();
991        for fmt in ["unix-time", "unix_time", "unixtime", "timestamp"] {
992            let mt = m.integer_format(Some(fmt));
993            assert_eq!(mt.rust_type, "i64", "format = {fmt}");
994        }
995    }
996
997    #[test]
998    fn user_alias_overrides_builtin() {
999        let mut cfg = TypeMappingConfig::default();
1000        // User wants `uuid4` to mean plain string instead of uuid.
1001        cfg.format_aliases
1002            .insert("uuid4".to_string(), "hostname".to_string());
1003        let m = TypeMapper::new(cfg);
1004        // hostname is unmapped → falls through to String.
1005        assert_eq!(m.string_format(Some("uuid4")).rust_type, "String");
1006    }
1007
1008    #[test]
1009    fn used_features_records_referenced_crates() {
1010        let m = TypeMapper::default();
1011        let _ = m.string_format(Some("date-time"));
1012        let _ = m.string_format(Some("uuid"));
1013        let used = m.used_features();
1014        assert!(used.contains(TypeFeature::Chrono));
1015        assert!(used.contains(TypeFeature::Uuid));
1016        assert!(!used.contains(TypeFeature::Bytes));
1017    }
1018
1019    #[test]
1020    fn format_alias_normalizes_before_dispatch() {
1021        let mut cfg = TypeMappingConfig::default();
1022        cfg.format_aliases
1023            .insert("uuid4".to_string(), "uuid".to_string());
1024        let m = TypeMapper::new(cfg);
1025        assert_eq!(m.string_format(Some("uuid4")).rust_type, "uuid::Uuid");
1026    }
1027
1028    #[test]
1029    fn conservative_helper_round_trips() {
1030        let cfg = TypeMappingConfig::conservative();
1031        assert!(matches!(cfg.date_time, DateStrategy::String));
1032        assert!(matches!(cfg.uuid, UuidStrategy::String));
1033    }
1034
1035    #[test]
1036    fn dep_requirement_renders_features_list() {
1037        let dep = TypeFeature::Chrono.dep_requirement();
1038        assert_eq!(dep.crate_name, "chrono");
1039        assert_eq!(dep.features, vec!["serde"]);
1040        assert_eq!(
1041            dep.to_toml_line(),
1042            r#"chrono = { version = "0.4", features = ["serde"] }"#
1043        );
1044    }
1045
1046    #[test]
1047    fn dep_requirement_omits_features_when_none() {
1048        let dep = TypeFeature::Base64.dep_requirement();
1049        assert_eq!(dep.to_toml_line(), r#"base64 = "0.22""#);
1050    }
1051
1052    #[test]
1053    fn collect_dep_requirements_is_sorted_and_unique() {
1054        let mut used = UsedFeatures::default();
1055        used.insert(TypeFeature::Url);
1056        used.insert(TypeFeature::Chrono);
1057        used.insert(TypeFeature::Chrono); // duplicate
1058        used.insert(TypeFeature::Uuid);
1059        let deps = collect_dep_requirements(&used);
1060        assert_eq!(
1061            deps.iter().map(|d| d.crate_name).collect::<Vec<_>>(),
1062            vec!["chrono", "url", "uuid"]
1063        );
1064    }
1065
1066    #[test]
1067    fn render_required_deps_toml_is_none_when_empty() {
1068        let deps: Vec<DepRequirement> = Vec::new();
1069        assert!(render_required_deps_toml(&deps).is_none());
1070    }
1071
1072    #[test]
1073    fn render_required_deps_toml_includes_dependencies_block() {
1074        let deps = vec![
1075            TypeFeature::Chrono.dep_requirement(),
1076            TypeFeature::Uuid.dep_requirement(),
1077        ];
1078        let toml = render_required_deps_toml(&deps).expect("non-empty");
1079        assert!(toml.contains("[dependencies]"));
1080        assert!(toml.contains("chrono = "));
1081        assert!(toml.contains("uuid = "));
1082        assert!(toml.contains("# Generated by openapi-to-rust"));
1083    }
1084
1085    #[test]
1086    fn map_dispatches_through_helpers() {
1087        let m = TypeMapper::default();
1088        assert_eq!(
1089            m.map(
1090                OpenApiSchemaType::String,
1091                &details_with_format(Some("uuid"))
1092            )
1093            .rust_type,
1094            "uuid::Uuid"
1095        );
1096        assert_eq!(
1097            m.map(
1098                OpenApiSchemaType::Integer,
1099                &details_with_format(Some("int32"))
1100            )
1101            .rust_type,
1102            "i32"
1103        );
1104    }
1105}