duckling 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,29 +1,207 @@
1
- use chrono::DateTime;
1
+ use chrono::{FixedOffset, TimeZone};
2
2
  use duckling::{
3
- parse as duckling_parse, Context, DimensionKind, DimensionValue, Entity, Lang, Locale, Options,
4
- Region, TimePoint, TimeValue,
3
+ Context, DimensionKind, DimensionValue, Entity, Lang, Locale, Options, Region, TimePoint,
4
+ TimeValue, parse as duckling_parse,
5
5
  };
6
- use magnus::{function, prelude::*, scan_args, Error, RArray, Ruby, Value};
6
+ use magnus::{
7
+ Error, RArray, RHash, Ruby, Time as RubyTime, Value, function, prelude::*, scan_args,
8
+ };
9
+ use std::os::raw::c_void;
10
+ use std::panic::{AssertUnwindSafe, catch_unwind};
11
+
12
+ mod ruby_value;
13
+ use ruby_value::serialize_symbolized;
7
14
 
8
- // `Duckling` is the gem's whole public surface today (one module, one method),
9
- // so Magnus defines it directly rather than nesting a separate binding module
10
- // underneath it there's no second Ruby-level API to keep stable independent
11
- // of the native layer yet. Revisit if/when the gem grows enough public Ruby
12
- // behavior (e.g. locale/dimension helpers) to warrant its own file.
15
+ // `Duckling::Native` holds the raw Magnus-defined entrypoint; `Duckling.parse`
16
+ // itself is a thin Ruby-level wrapper (see lib/duckling.rb) that dispatches
17
+ // through a `Thread.new { ... }.value` so a calling Fiber on an Async::Reactor
18
+ // can yield to sibling Fibers while the GVL-released native call runs (issue
19
+ // #64). Keeping the native singleton method under a separate `Native` module
20
+ // — rather than directly on `Duckling` — is what makes that split possible:
21
+ // it gives Ruby code something to call *without* the thread-spawn, which the
22
+ // benchmark suite also relies on to measure the dispatch overhead directly.
13
23
  #[magnus::init]
14
24
  fn init(ruby: &Ruby) -> Result<(), Error> {
15
25
  let module = ruby.define_module("Duckling")?;
16
- module.define_singleton_method("parse", function!(parse, -1))?;
26
+ let native = module.define_module("Native")?;
27
+ native.define_singleton_method("parse", function!(parse, -1))?;
28
+ let panicking_fake = module.define_module("PanickingNativeFake")?;
29
+ panicking_fake.define_singleton_method("parse", function!(panicking_parse, -1))?;
17
30
  Ok(())
18
31
  }
19
32
 
20
- /// `Duckling.parse(text, locale: "en", dims: ["time"], reference_time: nil, with_latent: false)`
33
+ /// Everything the off-GVL callback needs, and everything it produces.
34
+ /// Deliberately holds only fully-owned Rust data — no `magnus::Value`, no
35
+ /// `magnus::Error`, no other Ruby-VM-touching type crosses this struct in
36
+ /// either direction (this repo's established rule: never stash a bare
37
+ /// `magnus::Value`, or anything wrapping one, across a Magnus call boundary —
38
+ /// a past incident here caused a real GC-safety segfault).
39
+ struct ParsePayload {
40
+ text: String,
41
+ locale: Locale,
42
+ dims: Vec<DimensionKind>,
43
+ context: Context,
44
+ options: Options,
45
+ result: Option<Result<Vec<Entity>, String>>,
46
+ }
47
+
48
+ /// The raw callback handed to `rb_thread_call_without_gvl` as `func`. Runs
49
+ /// with the GVL released: no Ruby method calls, no `Value`/`RArray`
50
+ /// construction, no `magnus::Error` construction or raising is permitted
51
+ /// here — only the plain Rust computation and writing plain Rust data back
52
+ /// into `*payload`. Wraps the call in `std::panic::catch_unwind` directly
53
+ /// (not `magnus::rb_sys::catch_unwind`) to keep the payload's "plain Rust
54
+ /// data only" invariant simple and mechanically checkable.
55
+ ///
56
+ /// This guard is required unconditionally, not just as release-profile
57
+ /// defense-in-depth: the wrapped `duckling` crate's own internal
58
+ /// `catch_unwind` is compiled out entirely under `#[cfg(not(debug_assertions))]`,
59
+ /// which is absent from this repo's own `dev`-profile local default
60
+ /// (`RB_SYS_CARGO_PROFILE=dev`, set via `.env.local`).
61
+ unsafe extern "C" fn parse_without_gvl(payload: *mut c_void) -> *mut c_void {
62
+ // Edition 2024 requires unsafe operations to be wrapped in their own
63
+ // `unsafe` block even inside an `unsafe fn` (unsafe_op_in_unsafe_fn).
64
+ let payload = unsafe { &mut *(payload as *mut ParsePayload) };
65
+
66
+ let outcome = catch_unwind(AssertUnwindSafe(|| {
67
+ duckling_parse(
68
+ &payload.text,
69
+ &payload.locale,
70
+ &payload.dims,
71
+ &payload.context,
72
+ &payload.options,
73
+ )
74
+ }));
75
+
76
+ payload.result = Some(match outcome {
77
+ Ok(entities) => Ok(entities),
78
+ Err(panic_payload) => Err(panic_message(&*panic_payload)),
79
+ });
80
+
81
+ // Return value is unused by our caller (the real result is read back out
82
+ // of `*payload`); the C API just requires we return *something*.
83
+ std::ptr::null_mut()
84
+ }
85
+
86
+ /// Mirrors duckling's own panic_payload_message downcast (`&str` / `String` /
87
+ /// fallback), kept local since we don't have access to duckling's private helper.
88
+ fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
89
+ if let Some(&s) = payload.downcast_ref::<&'static str>() {
90
+ s.to_string()
91
+ } else if let Some(s) = payload.downcast_ref::<String>() {
92
+ s.clone()
93
+ } else {
94
+ "no panic message".to_string()
95
+ }
96
+ }
97
+
98
+ /// Converts a caught `duckling::parse` panic into the Ruby error the
99
+ /// extension raises. Single choke point for the panic → exception mapping,
100
+ /// shared by the real entrypoint and `Duckling::PanickingNativeFake`, so
101
+ /// tests exercising the fake exercise the exact mapping callers see.
102
+ ///
103
+ /// Deliberately `RuntimeError` (a `StandardError`), not magnus's own
104
+ /// `Error::from_panic` convention of `fatal`: a native panic already cost
105
+ /// the caller nothing but this one call, so it must be an ordinary
106
+ /// `rescue => e`-able error, not one that also tears down the calling
107
+ /// Thread's `Thread#value`/`Thread#join` propagation as unrescuable.
108
+ fn panic_error(ruby: &Ruby, message: &str) -> Error {
109
+ Error::new(
110
+ ruby.exception_runtime_error(),
111
+ format!("duckling::parse panicked: {message}"),
112
+ )
113
+ }
114
+
115
+ /// Shorthand for the `Error::new(ruby.exception_arg_error(), ...)` pattern
116
+ /// repeated across `parse_locale`, `parse_dims`, and `build_context`.
117
+ fn arg_error(ruby: &Ruby, message: impl Into<String>) -> Error {
118
+ Error::new(ruby.exception_arg_error(), message.into())
119
+ }
120
+
121
+ /// Shorthand for internal invariant violations in the generic-serialize-then-
122
+ /// patch walk (`patch_time_point`, `patch_time_value`, and `entity_to_ruby`'s
123
+ /// `Time` branch): these only fire if `serde_magnus`'s serialization of
124
+ /// `TimeValue`/`TimePoint` drifts from the typed walk built against it — a
125
+ /// wrapper bug, not anything the caller did wrong. Deliberately
126
+ /// `RuntimeError` (matching `panic_error`'s convention for "shouldn't happen"
127
+ /// cases), not `arg_error`'s `ArgumentError`, so a caller `rescue
128
+ /// ArgumentError`-ing their own bad `locale:`/`dims:` input handling doesn't
129
+ /// inadvertently swallow a real bug here.
130
+ fn internal_error(ruby: &Ruby, message: impl Into<String>) -> Error {
131
+ Error::new(ruby.exception_runtime_error(), message.into())
132
+ }
133
+
134
+ /// Payload for the test-only panicking fake below — same "plain owned Rust
135
+ /// data only" rule as `ParsePayload`.
136
+ struct PanicFakePayload {
137
+ result: Option<Result<Vec<Entity>, String>>,
138
+ }
139
+
140
+ /// Off-GVL callback for `Duckling::PanickingNativeFake.parse`: identical
141
+ /// shape to `parse_without_gvl`, but the guarded computation always panics —
142
+ /// standing in for a `duckling::parse` panic without needing a real
143
+ /// panic-triggering input.
144
+ unsafe extern "C" fn panic_fake_without_gvl(payload: *mut c_void) -> *mut c_void {
145
+ let payload = unsafe { &mut *(payload as *mut PanicFakePayload) };
146
+
147
+ let outcome = catch_unwind(AssertUnwindSafe(|| -> Vec<Entity> {
148
+ panic!("intentional panic from Duckling::PanickingNativeFake")
149
+ }));
150
+
151
+ payload.result = Some(match outcome {
152
+ Ok(entities) => Ok(entities),
153
+ Err(panic_payload) => Err(panic_message(&*panic_payload)),
154
+ });
155
+
156
+ std::ptr::null_mut()
157
+ }
158
+
159
+ /// `Duckling::PanickingNativeFake.parse(*)` — test-only stand-in for
160
+ /// `Duckling::Native` whose native call always panics. Accepts (and
161
+ /// ignores) `Native.parse`'s arguments so tests can swap the `Native`
162
+ /// constant and drive the public `Duckling.parse` through the real
163
+ /// GVL-release + `catch_unwind` + `panic_error` path, observing exactly
164
+ /// what a `duckling::parse` panic does to a Ruby caller. Not part of the
165
+ /// public API.
166
+ fn panicking_parse(ruby: &Ruby, _args: &[Value]) -> Result<RArray, Error> {
167
+ let mut payload = PanicFakePayload { result: None };
168
+
169
+ unsafe {
170
+ rb_sys::rb_thread_call_without_gvl(
171
+ Some(panic_fake_without_gvl),
172
+ &mut payload as *mut PanicFakePayload as *mut c_void,
173
+ None,
174
+ std::ptr::null_mut(),
175
+ );
176
+ }
177
+
178
+ match payload
179
+ .result
180
+ .expect("panic_fake_without_gvl always sets result before returning")
181
+ {
182
+ Ok(_) => Ok(ruby.ary_new()),
183
+ Err(message) => Err(panic_error(ruby, &message)),
184
+ }
185
+ }
186
+
187
+ /// `Duckling::Native.parse(text, locale: "en", dims: ["time"], reference_time: nil, with_latent: false)`
188
+ ///
189
+ /// The raw native entrypoint — no Thread spawn, no GVL-release considerations
190
+ /// visible at the call site. `Duckling.parse` (see lib/duckling.rb) is the
191
+ /// public API; it wraps this in `Thread.new { ... }.value` for thread-per-call
192
+ /// dispatch. Called directly (no thread), this is also the "without" baseline
193
+ /// the benchmark suite compares thread-per-call dispatch overhead against.
21
194
  ///
22
195
  /// - `locale`: BCP-47 tag (e.g. `"en"`, `"en-GB"`); unsupported codes raise `ArgumentError`.
23
196
  /// - `dims`: dimension names to extract; only `"time"` is implemented in 0.2.0,
24
197
  /// other values raise `ArgumentError`.
25
- /// - `reference_time`: Unix seconds anchoring relative expressions like "tomorrow";
26
- /// defaults to `Context::default()` (now, UTC).
198
+ /// - `reference_time`: a Ruby `Time` anchoring relative expressions like "tomorrow";
199
+ /// its `utc_offset` is preserved into every time result's `:value` — both
200
+ /// `Instant` results (e.g. "in one hour") and `Naive` (wall-clock) results
201
+ /// (e.g. "tomorrow", "5pm"), which are resolved against this offset before
202
+ /// being returned. `:value` is always a real Ruby `Time`, never a string.
203
+ /// Defaults to `Context::default()` (now, UTC) when `nil`/omitted. A
204
+ /// non-`Time` value raises `TypeError`.
27
205
  /// - `with_latent`: include ambiguous/latent matches (e.g. bare "morning").
28
206
  fn parse(ruby: &Ruby, args: &[Value]) -> Result<RArray, Error> {
29
207
  let args = scan_args::scan_args::<(String,), (), (), (), _, ()>(args)?;
@@ -33,7 +211,7 @@ fn parse(ruby: &Ruby, args: &[Value]) -> Result<RArray, Error> {
33
211
  (
34
212
  Option<String>,
35
213
  Option<Vec<String>>,
36
- Option<i64>,
214
+ Option<RubyTime>,
37
215
  Option<bool>,
38
216
  ),
39
217
  (),
@@ -44,21 +222,54 @@ fn parse(ruby: &Ruby, args: &[Value]) -> Result<RArray, Error> {
44
222
  )?;
45
223
 
46
224
  let text = args.required.0;
47
- let (locale_str, dims_strs, ref_time_i, with_latent) = kw.optional;
225
+ let (locale_str, dims_strs, ref_time, with_latent) = kw.optional;
48
226
  let locale_str = locale_str.unwrap_or_else(|| "en".to_string());
49
227
  let dims_strs = dims_strs.unwrap_or_else(|| vec!["time".to_string()]);
50
228
  let with_latent = with_latent.unwrap_or(false);
51
229
 
52
230
  let locale = parse_locale(ruby, &locale_str)?;
53
231
  let dims = parse_dims(ruby, &dims_strs)?;
54
- let context = build_context(ruby, ref_time_i)?;
232
+ let context = build_context(ruby, ref_time)?;
55
233
  let options = Options { with_latent };
56
234
 
57
- let entities = duckling_parse(&text, &locale, &dims, &context, &options);
235
+ // Release the GVL, call duckling::parse off-GVL, and block until the
236
+ // GVL is reacquired (rb_thread_call_without_gvl's documented step 4)
237
+ // before touching any Ruby Value again. The payload lives on this stack
238
+ // frame: rb_thread_call_without_gvl runs the callback to completion
239
+ // before returning, so no heap allocation or ownership transfer is
240
+ // needed — the borrow ends when the call returns.
241
+ let mut payload = ParsePayload {
242
+ text,
243
+ locale,
244
+ dims,
245
+ context,
246
+ options,
247
+ result: None,
248
+ };
249
+
250
+ unsafe {
251
+ rb_sys::rb_thread_call_without_gvl(
252
+ Some(parse_without_gvl),
253
+ &mut payload as *mut ParsePayload as *mut c_void,
254
+ None, // ubf: no cancellation hook (Thread#raise/#kill against an
255
+ // in-flight parse isn't handled — see issue #64's "Out of scope")
256
+ std::ptr::null_mut(),
257
+ );
258
+ }
259
+
260
+ let offset = payload.context.timezone();
261
+ let entities = match payload
262
+ .result
263
+ .expect("parse_without_gvl always sets result before returning")
264
+ {
265
+ Ok(entities) => entities,
266
+ // Safe to construct/raise now: the GVL is confirmed held again.
267
+ Err(message) => return Err(panic_error(ruby, &message)),
268
+ };
58
269
 
59
270
  let out = ruby.ary_new();
60
271
  for e in &entities {
61
- out.push(entity_to_ruby(ruby, e)?)?;
272
+ out.push(entity_to_ruby(ruby, e, offset)?)?;
62
273
  }
63
274
  Ok(out)
64
275
  }
@@ -68,20 +279,14 @@ fn parse_locale(ruby: &Ruby, locale_str: &str) -> Result<Locale, Error> {
68
279
  let lang_code = parts.next().unwrap_or("");
69
280
  let region_code = parts.next();
70
281
 
71
- let lang = lang_from_code(lang_code).ok_or_else(|| {
72
- Error::new(
73
- ruby.exception_arg_error(),
74
- format!("unsupported locale: {locale_str:?}"),
75
- )
76
- })?;
282
+ let lang = lang_from_code(lang_code)
283
+ .ok_or_else(|| arg_error(ruby, format!("unsupported locale: {locale_str:?}")))?;
77
284
 
78
285
  let region = match region_code {
79
- Some(code) => Some(region_from_code(code).ok_or_else(|| {
80
- Error::new(
81
- ruby.exception_arg_error(),
82
- format!("unsupported locale: {locale_str:?}"),
83
- )
84
- })?),
286
+ Some(code) => Some(
287
+ region_from_code(code)
288
+ .ok_or_else(|| arg_error(ruby, format!("unsupported locale: {locale_str:?}")))?,
289
+ ),
85
290
  None => None,
86
291
  };
87
292
 
@@ -192,82 +397,165 @@ fn parse_dims(ruby: &Ruby, dims_strs: &[String]) -> Result<Vec<DimensionKind>, E
192
397
  "credit-card-number" => Ok(DimensionKind::CreditCardNumber),
193
398
  "time-grain" => Ok(DimensionKind::TimeGrain),
194
399
  "duration" => Ok(DimensionKind::Duration),
195
- other => Err(Error::new(
196
- ruby.exception_arg_error(),
197
- format!("unsupported dimension: {other:?}"),
198
- )),
400
+ other => Err(arg_error(ruby, format!("unsupported dimension: {other:?}"))),
199
401
  })
200
402
  .collect()
201
403
  }
202
404
 
203
- fn build_context(ruby: &Ruby, ref_time_i: Option<i64>) -> Result<Context, Error> {
204
- match ref_time_i {
205
- Some(secs) => {
206
- let utc = DateTime::from_timestamp(secs, 0)
207
- .ok_or_else(|| Error::new(ruby.exception_arg_error(), "invalid reference_time"))?;
208
- Ok(Context::new(utc.fixed_offset(), Locale::default()))
405
+ fn build_context(ruby: &Ruby, ref_time: Option<RubyTime>) -> Result<Context, Error> {
406
+ match ref_time {
407
+ Some(time) => {
408
+ let ts = time.timespec()?;
409
+ let offset = FixedOffset::east_opt(time.utc_offset() as i32).ok_or_else(|| {
410
+ arg_error(ruby, "invalid reference_time: utc_offset out of range")
411
+ })?;
412
+ let anchor = offset
413
+ .timestamp_opt(ts.tv_sec, ts.tv_nsec as u32)
414
+ .single()
415
+ .ok_or_else(|| arg_error(ruby, "invalid reference_time: timestamp out of range"))?;
416
+ Ok(Context::new(anchor, Locale::default()))
209
417
  }
210
418
  None => Ok(Context::default()),
211
419
  }
212
420
  }
213
421
 
214
- fn time_point_to_ruby(ruby: &Ruby, tp: &TimePoint) -> Result<Value, Error> {
215
- let h = ruby.hash_new();
216
- h.aset(ruby.to_symbol("type"), ruby.to_symbol("value"))?;
422
+ /// Resolves a bare `NaiveDateTime` (wall-clock, no offset) against the
423
+ /// reference offset into an absolute `DateTime<FixedOffset>`. Shared by
424
+ /// `time_point_to_ruby` and `time_value_to_ruby` so the two call sites can't
425
+ /// drift apart on error message or ambiguity-handling strategy.
426
+ /// `FixedOffset` has no DST, so `.single()` is total in practice for any
427
+ /// `NaiveDateTime` duckling can produce; the `ok_or_else` is defensive.
428
+ fn resolve_naive(
429
+ ruby: &Ruby,
430
+ offset: FixedOffset,
431
+ value: &chrono::NaiveDateTime,
432
+ ) -> Result<chrono::DateTime<FixedOffset>, Error> {
433
+ offset
434
+ .from_local_datetime(value)
435
+ .single()
436
+ .ok_or_else(|| arg_error(ruby, "invalid or ambiguous naive time for reference offset"))
437
+ }
438
+
439
+ /// Patches a single generically-serialized `TimePoint` leaf in place: `point`
440
+ /// is the externally-tagged one-key Hash `serde_magnus` produced for it
441
+ /// (`{Naive: {value:, grain:}}` or `{Instant: {value:, grain:}}`), still
442
+ /// holding serde's placeholder String datetime and raw PascalCase grain. `tp`
443
+ /// is the typed Rust value the serialization came from, giving us everything
444
+ /// needed to overwrite both leaves with the real thing: a genuine Magnus
445
+ /// `Time` (running `resolve_naive`'s reference-offset resolution for
446
+ /// `Naive`, direct `IntoValue` for `Instant`) and `Grain::as_str()`'s
447
+ /// lowercase-snake_case symbol.
448
+ fn patch_time_point(
449
+ ruby: &Ruby,
450
+ point: Value,
451
+ tp: &TimePoint,
452
+ offset: FixedOffset,
453
+ ) -> Result<(), Error> {
454
+ let outer = RHash::from_value(point)
455
+ .ok_or_else(|| internal_error(ruby, "expected serialized TimePoint to be a Hash"))?;
456
+ let tag = match tp {
457
+ TimePoint::Naive { .. } => "Naive",
458
+ TimePoint::Instant { .. } => "Instant",
459
+ };
460
+ let inner: Value = outer.aref(ruby.to_symbol(tag))?;
461
+ let inner = RHash::from_value(inner).ok_or_else(|| {
462
+ internal_error(ruby, "expected serialized TimePoint payload to be a Hash")
463
+ })?;
217
464
  match tp {
218
465
  TimePoint::Naive { value, grain } => {
219
- h.aset(
220
- ruby.to_symbol("value"),
221
- value.format("%Y-%m-%dT%H:%M:%S").to_string(),
222
- )?;
223
- h.aset(ruby.to_symbol("grain"), ruby.to_symbol(grain.as_str()))?;
466
+ inner.aset(ruby.to_symbol("value"), resolve_naive(ruby, offset, value)?)?;
467
+ inner.aset(ruby.to_symbol("grain"), ruby.to_symbol(grain.as_str()))?;
224
468
  }
225
469
  TimePoint::Instant { value, grain } => {
226
- h.aset(ruby.to_symbol("value"), value.to_rfc3339())?;
227
- h.aset(ruby.to_symbol("grain"), ruby.to_symbol(grain.as_str()))?;
470
+ inner.aset(ruby.to_symbol("value"), *value)?;
471
+ inner.aset(ruby.to_symbol("grain"), ruby.to_symbol(grain.as_str()))?;
228
472
  }
229
473
  }
230
- Ok(h.as_value())
474
+ Ok(())
231
475
  }
232
476
 
233
- fn time_value_to_ruby(ruby: &Ruby, tv: &TimeValue) -> Result<Value, Error> {
234
- let h = ruby.hash_new();
477
+ /// Patches every datetime/grain leaf reachable from a generically-serialized
478
+ /// `TimeValue` (`serialized` is the `{Single: {...}}` / `{Interval: {...}}`
479
+ /// payload, i.e. `entity.value`'s serialization with the outer `Time` tag
480
+ /// already unwrapped) using the typed `tv` this serialization came from to
481
+ /// walk to exactly the same positions `serde_magnus` placed them at: the
482
+ /// `Single` case's primary `value` plus every `values` recurrence entry, or
483
+ /// the `Interval` case's `from`/`to` plus every `values` recurrence entry's
484
+ /// own `from`/`to`. Every other field `serde_magnus` produced (`values`
485
+ /// array framing, `holidayBeta`) is left untouched — this only overwrites
486
+ /// the known problem leaves, per issue #91's "generic-serialize-then-patch"
487
+ /// design.
488
+ fn patch_time_value(
489
+ ruby: &Ruby,
490
+ serialized: Value,
491
+ tv: &TimeValue,
492
+ offset: FixedOffset,
493
+ ) -> Result<(), Error> {
494
+ let outer = RHash::from_value(serialized)
495
+ .ok_or_else(|| internal_error(ruby, "expected serialized TimeValue to be a Hash"))?;
235
496
  match tv {
236
497
  TimeValue::Single { value, values, .. } => {
237
- h.aset(ruby.to_symbol("type"), ruby.to_symbol("value"))?;
238
- match value {
239
- TimePoint::Naive { value: dt, grain } => {
240
- h.aset(
241
- ruby.to_symbol("value"),
242
- dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
243
- )?;
244
- h.aset(ruby.to_symbol("grain"), ruby.to_symbol(grain.as_str()))?;
245
- }
246
- TimePoint::Instant { value: dt, grain } => {
247
- h.aset(ruby.to_symbol("value"), dt.to_rfc3339())?;
248
- h.aset(ruby.to_symbol("grain"), ruby.to_symbol(grain.as_str()))?;
249
- }
250
- }
251
- let vals = ruby.ary_new();
252
- for tp in values {
253
- vals.push(time_point_to_ruby(ruby, tp)?)?;
498
+ let inner: Value = outer.aref(ruby.to_symbol("Single"))?;
499
+ let inner = RHash::from_value(inner).ok_or_else(|| {
500
+ internal_error(ruby, "expected serialized Single payload to be a Hash")
501
+ })?;
502
+
503
+ let point: Value = inner.aref(ruby.to_symbol("value"))?;
504
+ patch_time_point(ruby, point, value, offset)?;
505
+
506
+ let vals: Value = inner.aref(ruby.to_symbol("values"))?;
507
+ let vals = RArray::from_value(vals).ok_or_else(|| {
508
+ internal_error(ruby, "expected serialized Single values to be an Array")
509
+ })?;
510
+ for (i, tp) in values.iter().enumerate() {
511
+ let entry: Value = vals.entry(i as isize)?;
512
+ patch_time_point(ruby, entry, tp, offset)?;
254
513
  }
255
- h.aset(ruby.to_symbol("values"), vals)?;
256
514
  }
257
- TimeValue::Interval { from, to, .. } => {
258
- h.aset(ruby.to_symbol("type"), ruby.to_symbol("interval"))?;
515
+ TimeValue::Interval {
516
+ from, to, values, ..
517
+ } => {
518
+ let inner: Value = outer.aref(ruby.to_symbol("Interval"))?;
519
+ let inner = RHash::from_value(inner).ok_or_else(|| {
520
+ internal_error(ruby, "expected serialized Interval payload to be a Hash")
521
+ })?;
522
+
259
523
  if let Some(tp) = from {
260
- h.aset(ruby.to_symbol("from"), time_point_to_ruby(ruby, tp)?)?;
524
+ let point: Value = inner.aref(ruby.to_symbol("from"))?;
525
+ patch_time_point(ruby, point, tp, offset)?;
261
526
  }
262
527
  if let Some(tp) = to {
263
- h.aset(ruby.to_symbol("to"), time_point_to_ruby(ruby, tp)?)?;
528
+ let point: Value = inner.aref(ruby.to_symbol("to"))?;
529
+ patch_time_point(ruby, point, tp, offset)?;
530
+ }
531
+
532
+ let vals: Value = inner.aref(ruby.to_symbol("values"))?;
533
+ let vals = RArray::from_value(vals).ok_or_else(|| {
534
+ internal_error(ruby, "expected serialized Interval values to be an Array")
535
+ })?;
536
+ for (i, endpoints) in values.iter().enumerate() {
537
+ let entry: Value = vals.entry(i as isize)?;
538
+ let entry = RHash::from_value(entry).ok_or_else(|| {
539
+ internal_error(
540
+ ruby,
541
+ "expected serialized IntervalEndpoints entry to be a Hash",
542
+ )
543
+ })?;
544
+ if let Some(tp) = &endpoints.from {
545
+ let point: Value = entry.aref(ruby.to_symbol("from"))?;
546
+ patch_time_point(ruby, point, tp, offset)?;
547
+ }
548
+ if let Some(tp) = &endpoints.to {
549
+ let point: Value = entry.aref(ruby.to_symbol("to"))?;
550
+ patch_time_point(ruby, point, tp, offset)?;
551
+ }
264
552
  }
265
553
  }
266
554
  }
267
- Ok(h.as_value())
555
+ Ok(())
268
556
  }
269
557
 
270
- fn entity_to_ruby(ruby: &Ruby, entity: &Entity) -> Result<Value, Error> {
558
+ fn entity_to_ruby(ruby: &Ruby, entity: &Entity, offset: FixedOffset) -> Result<Value, Error> {
271
559
  let h = ruby.hash_new();
272
560
  h.aset(ruby.to_symbol("body"), entity.body.clone())?;
273
561
  h.aset(ruby.to_symbol("start"), entity.start)?;
@@ -277,8 +565,52 @@ fn entity_to_ruby(ruby: &Ruby, entity: &Entity) -> Result<Value, Error> {
277
565
  if let Some(latent) = entity.latent {
278
566
  h.aset(ruby.to_symbol("latent"), latent)?;
279
567
  }
280
- if let DimensionValue::Time(ref tv) = entity.value {
281
- h.aset(ruby.to_symbol("value"), time_value_to_ruby(ruby, tv)?)?;
568
+ match &entity.value {
569
+ DimensionValue::Time(tv) => {
570
+ // Generic serialize-then-patch (issue #91), same convention #90
571
+ // uses for the other 13 dimensions: serde_magnus gives us the
572
+ // structural shape (`Single`/`Interval` discrimination, `values`,
573
+ // `holidayBeta`) for free; `patch_time_value` then overwrites the
574
+ // known-problem leaves (datetime placeholders, raw-PascalCase
575
+ // grains) using the typed `tv` this serialization came from.
576
+ let serialized = serialize_symbolized(ruby, &entity.value)?;
577
+ let outer = RHash::from_value(serialized).ok_or_else(|| {
578
+ internal_error(ruby, "expected serialized DimensionValue to be a Hash")
579
+ })?;
580
+ let time_payload: Value = outer.aref(ruby.to_symbol("Time"))?;
581
+ patch_time_value(ruby, time_payload, tv, offset)?;
582
+ h.aset(ruby.to_symbol("value"), serialized)?;
583
+ }
584
+ // `Grain` serde-serializes as PascalCase variant names ("Second",
585
+ // "NoGrain"); the shipped convention is `Grain::as_str()` symbols
586
+ // (:second, :no_grain) — see time_point_to_ruby. For TimeGrain the
587
+ // grain *is* the whole tagged payload, so the hash is built directly
588
+ // rather than serialized-then-patched.
589
+ DimensionValue::TimeGrain(grain) => {
590
+ let value = ruby.hash_new();
591
+ value.aset(ruby.to_symbol("TimeGrain"), ruby.to_symbol(grain.as_str()))?;
592
+ h.aset(ruby.to_symbol("value"), value)?;
593
+ }
594
+ // Duration has exactly three scalar fields, so — like TimeGrain above
595
+ // — the tagged hash is built directly rather than serialized then
596
+ // patched: a serialize-then-patch here would silently no-op if the
597
+ // crate's serde representation of Duration ever changed shape.
598
+ DimensionValue::Duration {
599
+ value: count,
600
+ grain,
601
+ normalized_seconds,
602
+ } => {
603
+ let payload = ruby.hash_new();
604
+ payload.aset(ruby.to_symbol("value"), *count)?;
605
+ payload.aset(ruby.to_symbol("grain"), ruby.to_symbol(grain.as_str()))?;
606
+ payload.aset(ruby.to_symbol("normalized_seconds"), *normalized_seconds)?;
607
+ let tagged = ruby.hash_new();
608
+ tagged.aset(ruby.to_symbol("Duration"), payload)?;
609
+ h.aset(ruby.to_symbol("value"), tagged)?;
610
+ }
611
+ other => {
612
+ h.aset(ruby.to_symbol("value"), serialize_symbolized(ruby, other)?)?;
613
+ }
282
614
  }
283
615
  Ok(h.as_value())
284
616
  }