1use std::fmt;
8
9use chrono::{DateTime, NaiveDate, Utc};
10use rust_decimal::Decimal;
11use vitaminc_aead::plaintext::kinds::{chrono as kinds_chrono, decimal as kinds_decimal};
12use vitaminc_aead::{Int, Logical, TimeUnit};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
17#[non_exhaustive]
18pub enum ConversionError {
19 #[error("BigInt is outside the signed/unsigned 128-bit range")]
20 IntegerRange,
21 #[error("invalid calendar date")]
22 InvalidDate,
23 #[error("invalid or out-of-range timestamp")]
24 InvalidTimestamp,
25 #[error("invalid or out-of-range decimal")]
26 InvalidDecimal,
27 #[error("decimal must be finite")]
28 NonFiniteDecimal,
29 #[error("invalid UUID")]
30 InvalidUuid,
31}
32
33impl ConversionError {
34 pub fn code(self) -> &'static str {
35 match self {
36 Self::IntegerRange => "ERR_INTEGER_RANGE",
37 Self::InvalidDate => "ERR_INVALID_DATE",
38 Self::InvalidTimestamp => "ERR_INVALID_TIMESTAMP",
39 Self::InvalidDecimal => "ERR_INVALID_DECIMAL",
40 Self::NonFiniteDecimal => "ERR_NON_FINITE_DECIMAL",
41 Self::InvalidUuid => "ERR_INVALID_UUID",
42 }
43 }
44}
45
46pub(crate) fn bigint(negative: bool, words: &[u64]) -> Result<Int, ConversionError> {
50 if words
51 .get(2..)
52 .is_some_and(|tail| tail.iter().any(|&word| word != 0))
53 {
54 return Err(ConversionError::IntegerRange);
55 }
56 let magnitude = u128::from(words.first().copied().unwrap_or(0))
57 .wrapping_add(u128::from(words.get(1).copied().unwrap_or(0)) << 64);
58 if negative && magnitude != 0 {
59 if magnitude > (1u128 << 127) {
60 return Err(ConversionError::IntegerRange);
61 }
62 Ok(signed((magnitude as i128).wrapping_neg()))
63 } else {
64 Ok(unsigned(magnitude))
65 }
66}
67
68fn signed(value: i128) -> Int {
69 if let Ok(v) = i8::try_from(value) {
70 Int::I8(v)
71 } else if let Ok(v) = i16::try_from(value) {
72 Int::I16(v)
73 } else if let Ok(v) = i32::try_from(value) {
74 Int::I32(v)
75 } else if let Ok(v) = i64::try_from(value) {
76 Int::I64(v)
77 } else {
78 Int::I128(value)
79 }
80}
81
82fn unsigned(value: u128) -> Int {
83 if let Ok(v) = i8::try_from(value) {
84 Int::I8(v)
85 } else if let Ok(v) = u8::try_from(value) {
86 Int::U8(v)
87 } else if let Ok(v) = i16::try_from(value) {
88 Int::I16(v)
89 } else if let Ok(v) = u16::try_from(value) {
90 Int::U16(v)
91 } else if let Ok(v) = i32::try_from(value) {
92 Int::I32(v)
93 } else if let Ok(v) = u32::try_from(value) {
94 Int::U32(v)
95 } else if let Ok(v) = i64::try_from(value) {
96 Int::I64(v)
97 } else if let Ok(v) = u64::try_from(value) {
98 Int::U64(v)
99 } else if let Ok(v) = i128::try_from(value) {
100 Int::I128(v)
101 } else {
102 Int::U128(value)
103 }
104}
105
106fn nanos_int(nanos: i128) -> Int {
108 match i64::try_from(nanos) {
109 Ok(v) => Int::I64(v),
110 Err(_) => Int::I128(nanos),
111 }
112}
113
114pub(crate) fn date(text: &str) -> Result<Int, ConversionError> {
116 let value =
117 NaiveDate::parse_from_str(text, "%Y-%m-%d").map_err(|_| ConversionError::InvalidDate)?;
118 if !displays_as(&value, text) {
119 return Err(ConversionError::InvalidDate);
120 }
121 Ok(Int::I32(kinds_chrono::date_days(value)))
122}
123
124fn displays_as(value: &impl fmt::Display, text: &str) -> bool {
128 struct Rest<'a>(&'a str);
129 impl fmt::Write for Rest<'_> {
130 fn write_str(&mut self, part: &str) -> fmt::Result {
131 self.0 = self.0.strip_prefix(part).ok_or(fmt::Error)?;
132 Ok(())
133 }
134 }
135 let mut rest = Rest(text);
136 fmt::write(&mut rest, format_args!("{value}")).is_ok() && rest.0.is_empty()
137}
138
139pub(crate) fn date_text(days: Int) -> Option<String> {
141 kinds_chrono::date(days).map(|date| date.to_string())
142}
143
144pub(crate) fn timestamp(text: &str) -> Result<Int, ConversionError> {
148 let parsed = if text.starts_with(['+', '-']) {
152 DateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S%.f%#z")
153 } else {
154 DateTime::parse_from_rfc3339(text)
155 };
156 let instant = parsed
157 .map(|value| value.with_timezone(&Utc))
158 .map_err(|_| ConversionError::InvalidTimestamp)?;
159 kinds_chrono::timestamp_nanos(instant)
160 .map(nanos_int)
161 .ok_or(ConversionError::InvalidTimestamp)
162}
163
164pub(crate) fn date_millis(ms: f64) -> Result<Int, ConversionError> {
168 if !ms.is_finite() || ms.fract() != 0.0 {
169 return Err(ConversionError::InvalidTimestamp);
170 }
171 let count = Int::I64(ms as i64);
173 if Logical::Timestamp(TimeUnit::Millis).admits(&vitaminc_aead::Physical::Int(count)) {
174 Ok(count)
175 } else {
176 Err(ConversionError::InvalidTimestamp)
177 }
178}
179
180pub(crate) fn timestamp_js_millis(unit: TimeUnit, count: Int) -> Option<f64> {
184 let nanos = vitaminc_aead::plaintext::kinds::timestamp_nanos(unit, count)?;
185 let millis = kinds_chrono::timestamp_count(TimeUnit::Millis, nanos)?;
186 Some(millis as f64)
187}
188
189pub(crate) fn timestamp_text(unit: TimeUnit, count: Int) -> Option<String> {
192 kinds_chrono::timestamp(unit, count)
193 .map(|instant| instant.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true))
194}
195
196pub(crate) fn decimal(text: &str) -> Result<(u8, Int), ConversionError> {
198 let unsigned = text.strip_prefix(['+', '-']).unwrap_or(text);
199 let non_finite = ["nan", "inf", "infinity"];
201 if non_finite.iter().any(|s| unsigned.eq_ignore_ascii_case(s)) {
202 return Err(ConversionError::NonFiniteDecimal);
203 }
204 let mut parts = unsigned.split('.');
207 let integral = parts.next().unwrap_or("");
208 let fraction = parts.next();
209 if integral.is_empty()
210 || !integral.bytes().all(|c| c.is_ascii_digit())
211 || fraction.is_some_and(|part| {
212 part.is_empty() || part.len() > 28 || !part.bytes().all(|c| c.is_ascii_digit())
213 })
214 || parts.next().is_some()
215 {
216 return Err(ConversionError::InvalidDecimal);
217 }
218 let (scale, mantissa) = Decimal::from_str_exact(text)
220 .map(kinds_decimal::parts)
221 .map_err(|_| ConversionError::InvalidDecimal)?;
222 Ok((scale, Int::I128(mantissa)))
223}
224
225pub(crate) fn decimal_text(scale: u8, mantissa: Int) -> Option<String> {
228 kinds_decimal::decimal(scale, mantissa).map(|value| value.to_string())
229}
230
231pub(crate) fn uuid(text: &str) -> Result<[u8; 16], ConversionError> {
234 let bytes = text.as_bytes();
235 if bytes.len() != 36 || [8, 13, 18, 23].iter().any(|&i| bytes[i] != b'-') {
236 return Err(ConversionError::InvalidUuid);
237 }
238 let mut out = [0u8; 16];
239 let mut digits = bytes.iter().filter(|&&b| b != b'-');
240 for byte in &mut out {
241 let hi = digits.next().and_then(|&d| (d as char).to_digit(16));
242 let lo = digits.next().and_then(|&d| (d as char).to_digit(16));
243 match (hi, lo) {
244 (Some(hi), Some(lo)) => *byte = (hi * 16 + lo) as u8,
245 _ => return Err(ConversionError::InvalidUuid),
246 }
247 }
248 Ok(out)
249}
250
251pub(crate) fn uuid_text(bytes: &[u8]) -> Option<String> {
253 let bytes: &[u8; 16] = bytes.try_into().ok()?;
254 let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
255 Some(format!(
256 "{}-{}-{}-{}-{}",
257 &hex[0..8],
258 &hex[8..12],
259 &hex[12..16],
260 &hex[16..20],
261 &hex[20..32]
262 ))
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use vitaminc_aead::plaintext::kinds::{MAX_TIMESTAMP_NANOS, MIN_TIMESTAMP_NANOS};
269
270 fn width(int: Int) -> &'static str {
271 match int {
272 Int::I8(_) => "i8",
273 Int::U8(_) => "u8",
274 Int::I16(_) => "i16",
275 Int::U16(_) => "u16",
276 Int::I32(_) => "i32",
277 Int::U32(_) => "u32",
278 Int::I64(_) => "i64",
279 Int::U64(_) => "u64",
280 Int::I128(_) => "i128",
281 Int::U128(_) => "u128",
282 }
283 }
284
285 #[test]
286 fn bigint_boundaries_choose_the_smallest_width() {
287 let positive = [
288 (0, "i8"),
289 (127, "i8"),
290 (128, "u8"),
291 (255, "u8"),
292 (256, "i16"),
293 (32767, "i16"),
294 (32768, "u16"),
295 (65535, "u16"),
296 (65536, "i32"),
297 (i32::MAX as u128, "i32"),
298 (i32::MAX as u128 + 1, "u32"),
299 (u32::MAX as u128, "u32"),
300 (u32::MAX as u128 + 1, "i64"),
301 (i64::MAX as u128, "i64"),
302 (i64::MAX as u128 + 1, "u64"),
303 (u64::MAX as u128, "u64"),
304 (u64::MAX as u128 + 1, "i128"),
305 (i128::MAX as u128, "i128"),
306 (i128::MAX as u128 + 1, "u128"),
307 (u128::MAX, "u128"),
308 ];
309 for (value, expected) in positive {
310 let words = [value as u64, (value >> 64) as u64];
311 let int = bigint(false, &words).expect("in range");
312 assert_eq!(width(int), expected, "{value}");
313 assert_eq!(int.to_u128(), Some(value));
314 }
315 let negative = [
316 (128u128, "i8"),
317 (129, "i16"),
318 (32768, "i16"),
319 (32769, "i32"),
320 (1 << 31, "i32"),
321 ((1 << 31) + 1, "i64"),
322 (1 << 63, "i64"),
323 ((1 << 63) + 1, "i128"),
324 (1 << 127, "i128"),
325 ];
326 for (magnitude, expected) in negative {
327 let int =
328 bigint(true, &[magnitude as u64, (magnitude >> 64) as u64]).expect("in range");
329 assert_eq!(width(int), expected, "-{magnitude}");
330 assert_eq!(int.to_i128(), Some((magnitude as i128).wrapping_neg()));
331 }
332 assert_eq!(bigint(true, &[0, 1 << 63]), Ok(Int::I128(i128::MIN)));
333 assert_eq!(bigint(false, &[]), Ok(Int::I8(0)));
334 assert_eq!(bigint(true, &[0]), Ok(Int::I8(0)), "negative zero");
335 assert_eq!(
336 bigint(false, &[1, 0, 0]),
337 Ok(Int::I8(1)),
338 "leading zero word"
339 );
340 assert_eq!(
341 bigint(true, &[1, 1 << 63]),
342 Err(ConversionError::IntegerRange)
343 );
344 assert_eq!(
345 bigint(false, &[0, 0, 1]),
346 Err(ConversionError::IntegerRange)
347 );
348 }
349
350 #[test]
351 fn dates_are_day_counts_and_round_trip_as_text() {
352 assert_eq!(date("1970-01-01"), Ok(Int::I32(0)));
353 assert_eq!(date("2024-02-29"), Ok(Int::I32(19782)));
354 assert_eq!(date("0000-01-01"), Ok(Int::I32(-719_528)));
355 for text in ["2024-02-29", "0000-01-01", "9999-12-31"] {
356 assert_eq!(date_text(date(text).expect("valid")).as_deref(), Some(text));
357 }
358 for invalid in ["2023-02-29", "2024-2-9", "", "2024-01-01T00:00:00Z"] {
359 assert_eq!(
360 date(invalid),
361 Err(ConversionError::InvalidDate),
362 "{invalid}"
363 );
364 }
365 assert_eq!(date_text(Int::I128(i128::MAX)), None);
366 }
367
368 #[test]
369 fn a_value_displays_as_text_only_when_every_byte_matches() {
370 assert!(displays_as(&"2024-02-29", "2024-02-29"));
371 assert!(displays_as(&"", ""));
372 assert!(!displays_as(&"2024-02-29", "2024-02-2"), "text too short");
373 assert!(!displays_as(&"2024-02-29", "2024-02-290"), "text too long");
374 assert!(!displays_as(&"2024-02-29", "2024-02-28"), "a byte differs");
375 }
376
377 #[test]
378 fn timestamps_are_nanosecond_counts_and_refuse_leap_seconds() {
379 assert_eq!(
380 timestamp("1970-01-01T00:00:00.123Z"),
381 Ok(Int::I64(123_000_000))
382 );
383 assert_eq!(
384 timestamp("1969-12-31T23:59:59.999999999Z"),
385 Ok(Int::I64(-1))
386 );
387 assert_eq!(
389 timestamp("2016-12-31T23:59:60.123456789Z"),
390 Err(ConversionError::InvalidTimestamp)
391 );
392 for (text, expected_width) in [
395 ("+12000-01-01T00:00:00.000000001Z", "i128"),
396 ("-0005-03-01T12:00:00.000000001Z", "i128"),
397 ("2262-04-11T23:47:16.854775807Z", "i64"),
398 ("2262-04-11T23:47:16.854775808Z", "i128"),
399 ] {
400 let count = timestamp(text).expect("valid");
401 assert_eq!(width(count), expected_width, "{text}");
402 assert_eq!(
403 timestamp_text(TimeUnit::Nanos, count).as_deref(),
404 Some(text)
405 );
406 }
407 assert!(matches!(
408 timestamp("-0005-03-01T12:00:00+01:00"),
409 Ok(count) if timestamp_text(TimeUnit::Nanos, count).as_deref()
410 == Some("-0005-03-01T11:00:00.000000000Z")
411 ));
412 for invalid in [
413 "+12000-01-01",
414 "+12000-13-01T00:00:00Z",
415 "-0005-03-01T12:00:00",
416 "bad",
417 ] {
418 assert_eq!(
419 timestamp(invalid),
420 Err(ConversionError::InvalidTimestamp),
421 "{invalid}"
422 );
423 }
424 }
425
426 #[test]
427 fn a_js_date_is_its_millisecond_count() {
428 assert_eq!(date_millis(-1.0), Ok(Int::I64(-1)));
429 assert_eq!(date_millis(0.0), Ok(Int::I64(0)));
430 assert_eq!(
431 date_millis(1_700_000_000_000.0),
432 Ok(Int::I64(1_700_000_000_000))
433 );
434 for invalid in [
435 f64::NAN,
436 f64::INFINITY,
437 f64::NEG_INFINITY,
438 0.5,
439 8_640_000_000_000_000.0,
441 -8_640_000_000_000_000.0,
442 ] {
443 assert_eq!(
444 date_millis(invalid),
445 Err(ConversionError::InvalidTimestamp),
446 "{invalid}"
447 );
448 }
449 }
450
451 #[test]
452 fn a_stored_timestamp_is_a_js_date_only_when_it_is_whole_milliseconds() {
453 assert_eq!(
454 timestamp_js_millis(TimeUnit::Millis, Int::I64(123)),
455 Some(123.0)
456 );
457 assert_eq!(
458 timestamp_js_millis(TimeUnit::Nanos, Int::I64(123_000_000)),
459 Some(123.0)
460 );
461 assert_eq!(
462 timestamp_js_millis(TimeUnit::Micros, Int::I64(-1_000)),
463 Some(-1.0)
464 );
465 assert_eq!(timestamp_js_millis(TimeUnit::Nanos, Int::I64(-1)), None);
467 assert_eq!(timestamp_js_millis(TimeUnit::Micros, Int::I64(1)), None);
468 for limit in [MIN_TIMESTAMP_NANOS, MAX_TIMESTAMP_NANOS] {
471 let ms = timestamp_js_millis(TimeUnit::Millis, Int::I64((limit / 1_000_000) as i64))
472 .expect("whole milliseconds at the bound");
473 assert!(ms.abs() <= 8_640_000_000_000_000.0, "{ms}");
474 }
475 assert_eq!(
477 timestamp_js_millis(TimeUnit::Nanos, Int::I128(i128::MAX)),
478 None
479 );
480 assert_eq!(timestamp_text(TimeUnit::Nanos, Int::I128(i128::MAX)), None);
481 }
482
483 #[test]
484 fn decimals_are_exact_and_negative_zero_is_zero() {
485 for text in ["NaN", "+nan", "-NaN", "inf", "+Infinity", "-Infinity"] {
486 assert_eq!(
487 decimal(text),
488 Err(ConversionError::NonFiniteDecimal),
489 "{text}"
490 );
491 }
492 for text in [
493 "",
494 "1e2",
495 "1_000",
496 "1.5_0",
497 ".1",
498 "1.",
499 "1.2.3",
500 " 1",
501 "1a",
502 "1.a",
503 "0.00000000000000000000000000001",
504 "79228162514264337593543950336",
505 ] {
506 assert_eq!(
507 decimal(text),
508 Err(ConversionError::InvalidDecimal),
509 "{text}"
510 );
511 }
512 assert_eq!(decimal("1.50"), Ok((2, Int::I128(150))));
513 assert_eq!(decimal("+1.50"), Ok((2, Int::I128(150))));
514 assert_eq!(decimal("-1.50"), Ok((2, Int::I128(-150))));
515 assert_eq!(decimal("-0.00"), Ok((2, Int::I128(0))));
517 assert_eq!(decimal_text(2, Int::I128(0)).as_deref(), Some("0.00"));
518 for text in [
519 "1.50",
520 "-79228162514264337593543950335",
521 "0.0000000000000000000000000001",
522 ] {
523 let (scale, mantissa) = decimal(text).expect("valid");
524 assert_eq!(decimal_text(scale, mantissa).as_deref(), Some(text));
525 }
526 assert_eq!(decimal_text(29, Int::I128(1)), None);
527 }
528
529 #[test]
530 fn uuids_are_sixteen_bytes_in_hyphenated_hex() {
531 let text = "123e4567-e89b-12d3-a456-426614174000";
532 let bytes = uuid(text).expect("valid");
533 assert_eq!(bytes[0..4], [0x12, 0x3e, 0x45, 0x67]);
534 assert_eq!(uuid_text(&bytes).as_deref(), Some(text));
535 assert_eq!(uuid(&text.to_uppercase()), Ok(bytes));
536 for invalid in [
537 "",
538 "123e4567e89b12d3a456426614174000",
539 "{123e4567-e89b-12d3-a456-426614174000}",
540 "123e4567-e89b-12d3-a456-42661417400g",
541 "123e4567-e89b-12d3-a456-4266141740000",
542 "123e4567-e89b-12d3-a456-42661417400",
543 "123e4567-e89b-12d3-a456-4266141740\u{e9}",
544 ] {
545 assert_eq!(
546 uuid(invalid),
547 Err(ConversionError::InvalidUuid),
548 "{invalid:?}"
549 );
550 }
551 assert_eq!(uuid_text(&[0; 15]), None);
552 }
553
554 #[test]
555 fn every_error_has_a_stable_code_and_a_message() {
556 for error in [
557 ConversionError::IntegerRange,
558 ConversionError::InvalidDate,
559 ConversionError::InvalidTimestamp,
560 ConversionError::InvalidDecimal,
561 ConversionError::NonFiniteDecimal,
562 ConversionError::InvalidUuid,
563 ] {
564 assert!(error.code().starts_with("ERR_"));
565 assert!(!error.to_string().is_empty());
566 }
567 }
568}