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