Skip to main content

ontogen_ts/
types.rs

1//! Public types for the ontogen-ts emitter.
2//!
3//! These define the API surface that ontogen (and any future consumer) talks
4//! to. The phase-1 design pass for OF-015 pinned each of these shapes:
5//!
6//! - [`TypePath`] keys the type pool and root list — fully-qualified, one or
7//!   more segments, never empty.
8//! - [`EmitConfig`] gathers the knobs callers can tune per-build (external
9//!   types, BigInt behavior, default case transform, strictness).
10//! - [`EmitError`] enumerates every way emission can fail. There is no
11//!   "warn-and-continue" tier — see OF-015 scope item 6.
12//! - [`BigIntBehavior`] picks the TS rendering for 64-bit integers; the
13//!   default mirrors the OF-014 spike (plain `number`).
14//! - [`RenameAll`] enumerates the eight serde `rename_all` modes. PR 2
15//!   implements the actual transforms; PR 1 just declares the shape.
16
17use std::collections::BTreeMap;
18
19/// Fully-qualified canonical path to a type in the user's crate (or an
20/// external crate).
21///
22/// The pool walker normalizes `use` statements and `crate::` prefixes into
23/// canonical form before constructing a `TypePath`, so two references to the
24/// same item always produce the same key.
25///
26/// Invariant: `segments` is non-empty. Construct via [`TypePath::new`].
27#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
28pub struct TypePath {
29    segments: Vec<String>,
30}
31
32impl TypePath {
33    /// Build a [`TypePath`] from one or more segments.
34    ///
35    /// Returns [`TypePathError::Empty`] if the segment list is empty.
36    pub fn new(segments: Vec<String>) -> Result<Self, TypePathError> {
37        if segments.is_empty() {
38            return Err(TypePathError::Empty);
39        }
40        Ok(Self { segments })
41    }
42
43    /// Borrowed view of the path segments.
44    pub fn segments(&self) -> &[String] {
45        &self.segments
46    }
47
48    /// Last segment — the terminal ident of the type.
49    pub fn terminal(&self) -> &str {
50        // `segments` is guaranteed non-empty by the constructor.
51        self.segments.last().expect("TypePath invariant: non-empty segments").as_str()
52    }
53}
54
55impl std::fmt::Display for TypePath {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        let mut first = true;
58        for segment in &self.segments {
59            if !first {
60                f.write_str("::")?;
61            }
62            f.write_str(segment)?;
63            first = false;
64        }
65        Ok(())
66    }
67}
68
69/// Construction error for [`TypePath`].
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum TypePathError {
72    /// Caller supplied an empty segment list.
73    Empty,
74}
75
76impl std::fmt::Display for TypePathError {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            Self::Empty => f.write_str("TypePath must have at least one segment"),
80        }
81    }
82}
83
84impl std::error::Error for TypePathError {}
85
86/// How `u64` / `i64` / `usize` / `isize` are rendered in TypeScript.
87///
88/// JavaScript `number` is a double-precision float; values above 2^53 lose
89/// precision. Consumers who need to send arbitrarily large integers over the
90/// wire pick [`BigIntBehavior::BigInt`] (TS `bigint` literal type) or
91/// [`BigIntBehavior::String`] (string-serialized integers). Default is
92/// [`BigIntBehavior::Number`] to match the OF-014 spike's effective behavior
93/// and what most consumers expect.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub enum BigIntBehavior {
96    /// Render as TS `number`. Default. May silently truncate above 2^53.
97    #[default]
98    Number,
99    /// Render as TS `bigint`. Requires the consumer to use `bigint` literals.
100    BigInt,
101    /// Render as TS `string`. Wire payload becomes a JSON string; consumers
102    /// parse it themselves.
103    String,
104}
105
106/// How TypeScript string literals are quoted in emitted source.
107///
108/// Affects every place the emitter renders a quoted string literal — today
109/// that's enum variant wire names in string-literal unions (`'Red'` vs.
110/// `"Red"`). The choice has no effect on the wire shape; it's purely a
111/// generated-source style toggle that lets consumers match their project's
112/// existing quote convention (eslint's `quotes: ['error', 'single']` style
113/// vs. Prettier's default of `"..."`).
114///
115/// Default is [`QuoteStyle::Single`] to preserve byte-identical emission
116/// for current ontogen-ts consumers; new consumers wanting double quotes
117/// (e.g. to match what specta emitted before the ontogen-ts cut-over) set
118/// it explicitly on their [`EmitConfig`].
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
120pub enum QuoteStyle {
121    /// `'foo' | 'bar'` — matches ontogen-ts's default and eslint's
122    /// `quotes: ['error', 'single']` style.
123    #[default]
124    Single,
125    /// `"foo" | "bar"` — matches Prettier's default and TypeScript's own
126    /// documentation examples; what specta emitted before the ontogen-ts
127    /// cut-over.
128    Double,
129}
130
131impl QuoteStyle {
132    /// The delimiter character this style uses for TS string literals.
133    pub(crate) fn delimiter(self) -> char {
134        match self {
135            Self::Single => '\'',
136            Self::Double => '"',
137        }
138    }
139}
140
141/// Serde's eight `rename_all` modes.
142///
143/// PR 1 declares the enum shape so [`EmitConfig::case_default`] type-checks;
144/// PR 2 implements the actual case-transform table and property-tests it
145/// against `serde_json::to_string`.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum RenameAll {
148    /// `"lowercase"` — all-lower, no separator.
149    Lowercase,
150    /// `"UPPERCASE"` — all-upper, no separator.
151    Uppercase,
152    /// `"PascalCase"`.
153    PascalCase,
154    /// `"camelCase"`.
155    CamelCase,
156    /// `"snake_case"`.
157    SnakeCase,
158    /// `"SCREAMING_SNAKE_CASE"`.
159    ScreamingSnakeCase,
160    /// `"kebab-case"`.
161    KebabCase,
162    /// `"SCREAMING-KEBAB-CASE"`.
163    ScreamingKebabCase,
164}
165
166/// Per-build configuration for an [`crate::emit`] call.
167///
168/// The full surface is in place for PR 1, but later PRs wire the individual
169/// fields into the emission path:
170///
171/// - `external_types` is consumed by PR 3's use-resolution + external-types
172///   lookup
173/// - `bigint_behavior` is consumed by PR 1's `emit_type` for `u64`/`i64`
174/// - `case_default` is consumed by PR 2's serde-rename engine
175/// - `strict_unsupported` is documented but, per the OF-015 design pass, the
176///   emitter is hard-error only — the field exists today to keep the
177///   `EmitConfig` shape stable across the PR series, and the strict path is
178///   the only path the emitter takes regardless of the flag's value. The
179///   field will be removed entirely in a later PR if no consumer surfaces a
180///   reason to keep it; see OF-015 scope item 6 for the rationale.
181#[derive(Debug, Clone, Default)]
182pub struct EmitConfig {
183    /// Canonical-path → TS rendering map for types ontogen-ts treats as
184    /// terminal. Defaults shipped by PR 3 (`chrono::DateTime` → `"string"`,
185    /// etc.); user overrides merge on top.
186    pub external_types: BTreeMap<String, String>,
187    /// Rendering for 64-bit integer types. Defaults to
188    /// [`BigIntBehavior::Number`].
189    pub bigint_behavior: BigIntBehavior,
190    /// Default `rename_all` mode applied to types that don't specify one.
191    /// `None` means "respect each type's own annotation; emit fields as-is
192    /// otherwise."
193    pub case_default: Option<RenameAll>,
194    /// Reserved for future use. Per the OF-015 design pass the emitter is
195    /// hard-error only; this flag is currently a no-op and exists to keep
196    /// the public shape stable across the PR series.
197    pub strict_unsupported: bool,
198    /// Quote style applied to every TS string literal the emitter renders.
199    /// Defaults to [`QuoteStyle::Single`] for byte-identical output with
200    /// pre-knob consumers.
201    pub quote_style: QuoteStyle,
202}
203
204/// Every way emission can fail.
205///
206/// Per the OF-015 design pass these are *hard errors only*: there is no
207/// `FallbackRecord` placeholder, no warning-and-continue, no silent untyping.
208/// Either a type emits cleanly or the build fails with one of these.
209///
210/// Errors collect into `Vec<EmitError>` at the [`crate::emit`] boundary so a
211/// single build surfaces every problem rather than first-fails.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub enum EmitError {
214    /// The type's Rust shape isn't in phase-1's supported subset (tuple
215    /// struct, unit struct, runtime-coordination wrapper like `Mutex<T>`,
216    /// user-defined generic, etc.).
217    UnsupportedShape {
218        /// Path of the offending type.
219        type_path: TypePath,
220        /// Human-readable explanation.
221        reason: String,
222    },
223    /// A `#[serde(...)]` attribute isn't supported in phase 1 (e.g.
224    /// `rename(serialize = "...", deserialize = "...")`, or `tag`/`content`/
225    /// `untagged` — see OF-015 phase 2), or appears at a level where serde
226    /// itself wouldn't accept it (`flatten` on a container or variant).
227    UnsupportedSerdeAttr {
228        /// Path of the type carrying the attribute.
229        type_path: TypePath,
230        /// Name of the offending attribute (e.g. `"split-rename"`).
231        attr: String,
232    },
233    /// A referenced ident couldn't be resolved against the type pool or the
234    /// external-types table.
235    UnresolvedReference {
236        /// The unresolved name as it appeared in source.
237        name: String,
238        /// Path of the type that referenced it.
239        referenced_by: TypePath,
240    },
241    /// Two reachable types render to the same TS name.
242    NameCollision {
243        /// The colliding TS name.
244        name: String,
245        /// All canonical paths that resolved to that name.
246        paths: Vec<TypePath>,
247    },
248}
249
250impl std::fmt::Display for EmitError {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        match self {
253            Self::UnsupportedShape { type_path, reason } => {
254                write!(f, "unsupported shape at `{type_path}`: {reason}")
255            }
256            Self::UnsupportedSerdeAttr { type_path, attr } => {
257                write!(f, "unsupported serde attribute `{attr}` on `{type_path}`")
258            }
259            Self::UnresolvedReference { name, referenced_by } => {
260                write!(f, "unresolved reference `{name}` (from `{referenced_by}`)")
261            }
262            Self::NameCollision { name, paths } => {
263                write!(f, "TS name collision on `{name}` between ")?;
264                let mut first = true;
265                for path in paths {
266                    if !first {
267                        write!(f, ", ")?;
268                    }
269                    write!(f, "`{path}`")?;
270                    first = false;
271                }
272                Ok(())
273            }
274        }
275    }
276}
277
278impl std::error::Error for EmitError {}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn type_path_rejects_empty() {
286        let err = TypePath::new(Vec::new()).expect_err("empty path should fail");
287        assert_eq!(err, TypePathError::Empty);
288    }
289
290    #[test]
291    fn type_path_accepts_single_segment() {
292        let path = TypePath::new(vec!["Foo".to_string()]).expect("single segment is valid");
293        assert_eq!(path.segments(), &["Foo".to_string()]);
294        assert_eq!(path.terminal(), "Foo");
295        assert_eq!(path.to_string(), "Foo");
296    }
297
298    #[test]
299    fn type_path_accepts_multi_segment() {
300        let path = TypePath::new(vec!["crate".to_string(), "models".to_string(), "Workout".to_string()])
301            .expect("multi-segment is valid");
302        assert_eq!(path.terminal(), "Workout");
303        assert_eq!(path.to_string(), "crate::models::Workout");
304    }
305
306    #[test]
307    fn bigint_behavior_default_is_number() {
308        assert_eq!(BigIntBehavior::default(), BigIntBehavior::Number);
309    }
310
311    #[test]
312    fn emit_config_default_is_empty_and_lax() {
313        let config = EmitConfig::default();
314        assert!(config.external_types.is_empty());
315        assert_eq!(config.bigint_behavior, BigIntBehavior::Number);
316        assert_eq!(config.case_default, None);
317        assert!(!config.strict_unsupported);
318        assert_eq!(config.quote_style, QuoteStyle::Single);
319    }
320
321    #[test]
322    fn quote_style_default_is_single() {
323        assert_eq!(QuoteStyle::default(), QuoteStyle::Single);
324        assert_eq!(QuoteStyle::Single.delimiter(), '\'');
325        assert_eq!(QuoteStyle::Double.delimiter(), '"');
326    }
327
328    #[test]
329    fn emit_error_display_renders_reasonably() {
330        let tp = TypePath::new(vec!["crate".to_string(), "Foo".to_string()]).unwrap();
331        let err = EmitError::UnsupportedShape { type_path: tp.clone(), reason: "tuple struct".to_string() };
332        assert_eq!(err.to_string(), "unsupported shape at `crate::Foo`: tuple struct");
333
334        let err = EmitError::NameCollision {
335            name: "Foo".to_string(),
336            paths: vec![tp.clone(), TypePath::new(vec!["other".to_string(), "Foo".to_string()]).unwrap()],
337        };
338        assert_eq!(err.to_string(), "TS name collision on `Foo` between `crate::Foo`, `other::Foo`");
339    }
340}