duckling 0.1.2 → 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.
@@ -0,0 +1,616 @@
1
+ use chrono::{FixedOffset, TimeZone};
2
+ use duckling::{
3
+ Context, DimensionKind, DimensionValue, Entity, Lang, Locale, Options, Region, TimePoint,
4
+ TimeValue, parse as duckling_parse,
5
+ };
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;
14
+
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.
23
+ #[magnus::init]
24
+ fn init(ruby: &Ruby) -> Result<(), Error> {
25
+ let module = ruby.define_module("Duckling")?;
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))?;
30
+ Ok(())
31
+ }
32
+
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.
194
+ ///
195
+ /// - `locale`: BCP-47 tag (e.g. `"en"`, `"en-GB"`); unsupported codes raise `ArgumentError`.
196
+ /// - `dims`: dimension names to extract; only `"time"` is implemented in 0.2.0,
197
+ /// other values raise `ArgumentError`.
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`.
205
+ /// - `with_latent`: include ambiguous/latent matches (e.g. bare "morning").
206
+ fn parse(ruby: &Ruby, args: &[Value]) -> Result<RArray, Error> {
207
+ let args = scan_args::scan_args::<(String,), (), (), (), _, ()>(args)?;
208
+ let kw = scan_args::get_kwargs::<
209
+ _,
210
+ (),
211
+ (
212
+ Option<String>,
213
+ Option<Vec<String>>,
214
+ Option<RubyTime>,
215
+ Option<bool>,
216
+ ),
217
+ (),
218
+ >(
219
+ args.keywords,
220
+ &[],
221
+ &["locale", "dims", "reference_time", "with_latent"],
222
+ )?;
223
+
224
+ let text = args.required.0;
225
+ let (locale_str, dims_strs, ref_time, with_latent) = kw.optional;
226
+ let locale_str = locale_str.unwrap_or_else(|| "en".to_string());
227
+ let dims_strs = dims_strs.unwrap_or_else(|| vec!["time".to_string()]);
228
+ let with_latent = with_latent.unwrap_or(false);
229
+
230
+ let locale = parse_locale(ruby, &locale_str)?;
231
+ let dims = parse_dims(ruby, &dims_strs)?;
232
+ let context = build_context(ruby, ref_time)?;
233
+ let options = Options { with_latent };
234
+
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
+ };
269
+
270
+ let out = ruby.ary_new();
271
+ for e in &entities {
272
+ out.push(entity_to_ruby(ruby, e, offset)?)?;
273
+ }
274
+ Ok(out)
275
+ }
276
+
277
+ fn parse_locale(ruby: &Ruby, locale_str: &str) -> Result<Locale, Error> {
278
+ let mut parts = locale_str.splitn(2, '-');
279
+ let lang_code = parts.next().unwrap_or("");
280
+ let region_code = parts.next();
281
+
282
+ let lang = lang_from_code(lang_code)
283
+ .ok_or_else(|| arg_error(ruby, format!("unsupported locale: {locale_str:?}")))?;
284
+
285
+ let region = match region_code {
286
+ Some(code) => Some(
287
+ region_from_code(code)
288
+ .ok_or_else(|| arg_error(ruby, format!("unsupported locale: {locale_str:?}")))?,
289
+ ),
290
+ None => None,
291
+ };
292
+
293
+ Ok(Locale::new(lang, region))
294
+ }
295
+
296
+ fn lang_from_code(code: &str) -> Option<Lang> {
297
+ Some(match code.to_ascii_lowercase().as_str() {
298
+ "af" => Lang::AF,
299
+ "ar" => Lang::AR,
300
+ "bg" => Lang::BG,
301
+ "bn" => Lang::BN,
302
+ "ca" => Lang::CA,
303
+ "cs" => Lang::CS,
304
+ "da" => Lang::DA,
305
+ "de" => Lang::DE,
306
+ "el" => Lang::EL,
307
+ "en" => Lang::EN,
308
+ "es" => Lang::ES,
309
+ "et" => Lang::ET,
310
+ "fa" => Lang::FA,
311
+ "fi" => Lang::FI,
312
+ "fr" => Lang::FR,
313
+ "ga" => Lang::GA,
314
+ "he" => Lang::HE,
315
+ "hi" => Lang::HI,
316
+ "hr" => Lang::HR,
317
+ "hu" => Lang::HU,
318
+ "id" => Lang::ID,
319
+ "is" => Lang::IS,
320
+ "it" => Lang::IT,
321
+ "ja" => Lang::JA,
322
+ "ka" => Lang::KA,
323
+ "km" => Lang::KM,
324
+ "kn" => Lang::KN,
325
+ "ko" => Lang::KO,
326
+ "lo" => Lang::LO,
327
+ "ml" => Lang::ML,
328
+ "mn" => Lang::MN,
329
+ "my" => Lang::MY,
330
+ "nb" => Lang::NB,
331
+ "ne" => Lang::NE,
332
+ "nl" => Lang::NL,
333
+ "pl" => Lang::PL,
334
+ "pt" => Lang::PT,
335
+ "ro" => Lang::RO,
336
+ "ru" => Lang::RU,
337
+ "sk" => Lang::SK,
338
+ "sv" => Lang::SV,
339
+ "sw" => Lang::SW,
340
+ "ta" => Lang::TA,
341
+ "te" => Lang::TE,
342
+ "th" => Lang::TH,
343
+ "tr" => Lang::TR,
344
+ "uk" => Lang::UK,
345
+ "vi" => Lang::VI,
346
+ "zh" => Lang::ZH,
347
+ _ => return None,
348
+ })
349
+ }
350
+
351
+ fn region_from_code(code: &str) -> Option<Region> {
352
+ Some(match code.to_ascii_uppercase().as_str() {
353
+ "AR" => Region::AR,
354
+ "US" => Region::US,
355
+ "GB" => Region::GB,
356
+ "AU" => Region::AU,
357
+ "BE" => Region::BE,
358
+ "BZ" => Region::BZ,
359
+ "CA" => Region::CA,
360
+ "CL" => Region::CL,
361
+ "CN" => Region::CN,
362
+ "CO" => Region::CO,
363
+ "EG" => Region::EG,
364
+ "ES" => Region::ES,
365
+ "HK" => Region::HK,
366
+ "IE" => Region::IE,
367
+ "IN" => Region::IN,
368
+ "JM" => Region::JM,
369
+ "MO" => Region::MO,
370
+ "MX" => Region::MX,
371
+ "NZ" => Region::NZ,
372
+ "PE" => Region::PE,
373
+ "PH" => Region::PH,
374
+ "TT" => Region::TT,
375
+ "TW" => Region::TW,
376
+ "VE" => Region::VE,
377
+ "ZA" => Region::ZA,
378
+ _ => return None,
379
+ })
380
+ }
381
+
382
+ fn parse_dims(ruby: &Ruby, dims_strs: &[String]) -> Result<Vec<DimensionKind>, Error> {
383
+ dims_strs
384
+ .iter()
385
+ .map(|s| match s.as_str() {
386
+ "time" => Ok(DimensionKind::Time),
387
+ "number" => Ok(DimensionKind::Numeral),
388
+ "ordinal" => Ok(DimensionKind::Ordinal),
389
+ "temperature" => Ok(DimensionKind::Temperature),
390
+ "distance" => Ok(DimensionKind::Distance),
391
+ "volume" => Ok(DimensionKind::Volume),
392
+ "quantity" => Ok(DimensionKind::Quantity),
393
+ "amount-of-money" => Ok(DimensionKind::AmountOfMoney),
394
+ "email" => Ok(DimensionKind::Email),
395
+ "phone-number" => Ok(DimensionKind::PhoneNumber),
396
+ "url" => Ok(DimensionKind::Url),
397
+ "credit-card-number" => Ok(DimensionKind::CreditCardNumber),
398
+ "time-grain" => Ok(DimensionKind::TimeGrain),
399
+ "duration" => Ok(DimensionKind::Duration),
400
+ other => Err(arg_error(ruby, format!("unsupported dimension: {other:?}"))),
401
+ })
402
+ .collect()
403
+ }
404
+
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()))
417
+ }
418
+ None => Ok(Context::default()),
419
+ }
420
+ }
421
+
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
+ })?;
464
+ match tp {
465
+ TimePoint::Naive { value, grain } => {
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()))?;
468
+ }
469
+ TimePoint::Instant { value, grain } => {
470
+ inner.aset(ruby.to_symbol("value"), *value)?;
471
+ inner.aset(ruby.to_symbol("grain"), ruby.to_symbol(grain.as_str()))?;
472
+ }
473
+ }
474
+ Ok(())
475
+ }
476
+
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"))?;
496
+ match tv {
497
+ TimeValue::Single { value, values, .. } => {
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)?;
513
+ }
514
+ }
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
+
523
+ if let Some(tp) = from {
524
+ let point: Value = inner.aref(ruby.to_symbol("from"))?;
525
+ patch_time_point(ruby, point, tp, offset)?;
526
+ }
527
+ if let Some(tp) = to {
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
+ }
552
+ }
553
+ }
554
+ }
555
+ Ok(())
556
+ }
557
+
558
+ fn entity_to_ruby(ruby: &Ruby, entity: &Entity, offset: FixedOffset) -> Result<Value, Error> {
559
+ let h = ruby.hash_new();
560
+ h.aset(ruby.to_symbol("body"), entity.body.clone())?;
561
+ h.aset(ruby.to_symbol("start"), entity.start)?;
562
+ h.aset(ruby.to_symbol("end"), entity.end)?;
563
+ let dim_str = entity.value.dim_kind().to_string();
564
+ h.aset(ruby.to_symbol("dim"), ruby.to_symbol(dim_str.as_str()))?;
565
+ if let Some(latent) = entity.latent {
566
+ h.aset(ruby.to_symbol("latent"), latent)?;
567
+ }
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
+ }
614
+ }
615
+ Ok(h.as_value())
616
+ }