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}