Skip to main content

openapi_to_rust/
type_mapping.rs

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