Skip to main content

lemma/
literals.rs

1//! Literal value types and string parsing. No dependency on parsing/ast.
2//! AST and planning re-export these types where needed.
3
4use chrono::{Datelike, Timelike};
5use rust_decimal::Decimal;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use std::collections::BTreeMap;
8use std::fmt;
9use std::str::FromStr;
10
11use crate::computation::rational::{self, RationalInteger};
12
13// -----------------------------------------------------------------------------
14// Dimensional decomposition type
15// -----------------------------------------------------------------------------
16
17/// A dimensional decomposition vector. Maps base dimensions to integer exponents.
18/// For example, velocity `{length: 1, duration: -1}` or acceleration `{length: 1, duration: -2}`.
19/// An empty map indicates a base measure (no decomposition) until the decomposition pass runs,
20/// after which every measure carries a non-empty vector.
21pub type BaseMeasureVector = BTreeMap<crate::planning::semantics::Dimension, i32>;
22
23// -----------------------------------------------------------------------------
24// Unit tables for Measure and Ratio types
25// -----------------------------------------------------------------------------
26
27pub fn rational_from_parsed_decimal(decimal: Decimal) -> Result<RationalInteger, String> {
28    rational::decimal_to_rational(decimal).map_err(|failure| failure.to_string())
29}
30
31/// Conversion factor of a measure unit: a plan-time rational, or a product of
32/// data/rule references whose values are known only during evaluation.
33///
34/// Dynamic scales carry a plan-time `coefficient` and one or more
35/// `(reference, exponent)` factors:
36/// `effective = coefficient * Π settled(t_i)^e_i`.
37/// Plain `-> unit gbp: rate` uses coefficient 1 and factors `[(rate, 1)]`.
38/// `-> unit gbp_per_kg: gbp/kilogram` uses the gbp reference with
39/// coefficient `1/kilogram` and factors `[(rate, 1)]`.
40/// `-> unit usd_per_ce: usd/consumenteneenheid` uses two factors with
41/// exponents `1` and `-1`.
42#[derive(Clone, Debug, PartialEq, Eq, Hash)]
43pub enum UnitScale {
44    /// 1 of this unit equals this many canonical units.
45    Literal(RationalInteger),
46    /// Written path between parse and classification (`rate`, `fx.rate`). The
47    /// declaring spec's type resolution replaces it with [`Self::Reference`] or a
48    /// compound literal factor; it never survives type resolution.
49    Unresolved {
50        written: String,
51        segments: Vec<String>,
52        name: String,
53    },
54    /// Resolved `number` data fields or rules, in the coordinates of the spec whose
55    /// type resolution wrote them: local members, or `uses` hops plus members of
56    /// the imported spec. `factors` is non-empty after planning. After planning
57    /// succeeds every dynamic unit is this variant — never [`Self::Unresolved`].
58    Reference {
59        factors: Vec<(crate::planning::semantics::ReferenceTarget, i32)>,
60        coefficient: RationalInteger,
61    },
62}
63
64impl UnitScale {
65    #[must_use]
66    pub fn dynamic_coefficient(&self) -> Option<&RationalInteger> {
67        match self {
68            UnitScale::Literal(_) | UnitScale::Unresolved { .. } => None,
69            UnitScale::Reference { coefficient, .. } => Some(coefficient),
70        }
71    }
72
73    /// Merge `(target, exponent)` pairs: same target adds exponents; drop zeros;
74    /// order by [`ReferenceTarget`] ord. Empty result means the product cancelled.
75    #[must_use]
76    pub fn canonicalize_scale_factors(
77        factors: &[(crate::planning::semantics::ReferenceTarget, i32)],
78    ) -> Vec<(crate::planning::semantics::ReferenceTarget, i32)> {
79        let mut accumulator: BTreeMap<crate::planning::semantics::ReferenceTarget, i32> =
80            BTreeMap::new();
81        for (target, exponent) in factors {
82            let entry = accumulator.entry(target.clone()).or_insert(0);
83            *entry = entry.checked_add(*exponent).expect(
84                "BUG: planning bounds dynamic scale factor exponents when merging the same target",
85            );
86        }
87        accumulator
88            .into_iter()
89            .filter(|(_, exponent)| *exponent != 0)
90            .collect()
91    }
92}
93
94/// A single unit within a Measure type.
95///
96/// `scale` is the conversion factor: 1 of this unit equals `scale` canonical units
97/// when the scale is a literal, or a referenced `number` when it is dynamic.
98/// `derived_measure_factors` stores `(measure_ref, exponent)` pairs from compound unit declarations
99/// (e.g., `meter/second` produces `[("meter", 1), ("second", -1)]`). Empty for base units.
100/// `decomposition` is the dimensional decomposition vector, populated during the planning
101/// decomposition pass. It is empty until that pass completes.
102#[derive(Clone, Debug, PartialEq, Eq, Hash)]
103pub struct MeasureUnit {
104    pub name: String,
105    pub scale: UnitScale,
106    pub derived_measure_factors: Vec<(String, i32)>,
107    pub decomposition: BaseMeasureVector,
108    /// Minimum magnitude in this unit (schema/UI); canonical bound is on the type.
109    pub minimum: Option<RationalInteger>,
110    /// Maximum magnitude in this unit (schema/UI).
111    pub maximum: Option<RationalInteger>,
112    /// Default suggestion magnitude in this unit (schema/UI).
113    pub suggestion_magnitude: Option<RationalInteger>,
114}
115
116impl MeasureUnit {
117    pub fn from_decimal_factor(
118        name: String,
119        decimal_factor: Decimal,
120        derived_measure_factors: Vec<(String, i32)>,
121    ) -> Result<Self, String> {
122        let factor =
123            rational::decimal_to_rational(decimal_factor).map_err(|failure| failure.to_string())?;
124        Ok(MeasureUnit {
125            name,
126            scale: UnitScale::Literal(factor),
127            derived_measure_factors,
128            decomposition: BaseMeasureVector::new(),
129            minimum: None,
130            maximum: None,
131            suggestion_magnitude: None,
132        })
133    }
134
135    pub fn from_reference(
136        name: String,
137        written: String,
138        segments: Vec<String>,
139        reference_name: String,
140    ) -> Self {
141        MeasureUnit {
142            name,
143            scale: UnitScale::Unresolved {
144                written,
145                segments,
146                name: reference_name,
147            },
148            derived_measure_factors: Vec::new(),
149            decomposition: BaseMeasureVector::new(),
150            minimum: None,
151            maximum: None,
152            suggestion_magnitude: None,
153        }
154    }
155
156    pub fn clear_constraint_magnitudes(&mut self) {
157        self.minimum = None;
158        self.maximum = None;
159        self.suggestion_magnitude = None;
160    }
161
162    #[must_use]
163    pub fn is_dynamic(&self) -> bool {
164        matches!(
165            self.scale,
166            UnitScale::Unresolved { .. } | UnitScale::Reference { .. }
167        )
168    }
169
170    /// Source spelling of a reference scale (`rate`, or a product of targets).
171    #[must_use]
172    pub fn scale_written(&self) -> Option<String> {
173        match &self.scale {
174            UnitScale::Unresolved { written, .. } => Some(written.clone()),
175            UnitScale::Reference { factors, .. } => {
176                if factors.is_empty() {
177                    panic!("BUG: Reference scale factors must be non-empty after planning");
178                }
179                Some(format_scale_factors(factors))
180            }
181            UnitScale::Literal(_) => None,
182        }
183    }
184
185    /// Declaring-spec scale factors after graph resolution. Empty when not dynamic.
186    #[must_use]
187    pub fn resolved_scale_factors(&self) -> &[(crate::planning::semantics::ReferenceTarget, i32)] {
188        match &self.scale {
189            UnitScale::Reference { factors, .. } => factors.as_slice(),
190            UnitScale::Literal(_) | UnitScale::Unresolved { .. } => &[],
191        }
192    }
193
194    /// Written reference path (`segments` + `name`) of an unresolved dynamic unit.
195    #[must_use]
196    pub fn scale_reference_parts(&self) -> Option<(&[String], &str)> {
197        match &self.scale {
198            UnitScale::Unresolved { segments, name, .. } => {
199                Some((segments.as_slice(), name.as_str()))
200            }
201            UnitScale::Literal(_) | UnitScale::Reference { .. } => None,
202        }
203    }
204
205    /// Literal conversion factor, when this unit's scale is known at plan time.
206    #[must_use]
207    pub fn literal_factor(&self) -> Option<&RationalInteger> {
208        match &self.scale {
209            UnitScale::Literal(factor) => Some(factor),
210            UnitScale::Unresolved { .. } | UnitScale::Reference { .. } => None,
211        }
212    }
213
214    /// Plan-time coefficient for a dynamic scale (`1` when literal or unresolved).
215    #[must_use]
216    pub fn scale_coefficient(&self) -> RationalInteger {
217        match &self.scale {
218            UnitScale::Literal(_) | UnitScale::Unresolved { .. } => rational::rational_one(),
219            UnitScale::Reference { coefficient, .. } => coefficient.clone(),
220        }
221    }
222
223    /// Literal conversion factor. Panics if this unit is reference-scaled:
224    /// planning keeps every static path away from dynamic units.
225    pub fn require_literal_factor(&self) -> &RationalInteger {
226        self.literal_factor().unwrap_or_else(|| {
227            panic!(
228                "BUG: literal factor required for unit '{}'; planning must keep static paths away from reference-scaled units",
229                self.name
230            )
231        })
232    }
233
234    /// Whether this unit and `other` have the same scale: equal literal factors, or
235    /// equal reference factor products with equal coefficient. Both types carry
236    /// the coordinates of the spec holding them (import hops are already prefixed on
237    /// the type), so target equality is coordinate equality.
238    #[must_use]
239    pub fn same_scale(&self, other: &Self) -> bool {
240        self.scale == other.scale
241    }
242
243    pub fn is_canonical_factor(&self) -> bool {
244        self.literal_factor()
245            .is_some_and(|factor| *factor == rational::rational_one())
246    }
247
248    pub fn is_positive_factor(&self) -> bool {
249        match &self.scale {
250            UnitScale::Literal(factor) => factor.numer_is_positive(),
251            UnitScale::Unresolved { .. } => true,
252            UnitScale::Reference { coefficient, .. } => coefficient.numer_is_positive(),
253        }
254    }
255
256    /// Conversion factor as decimal (schema unit factors always commit).
257    pub fn factor_decimal(&self) -> Decimal {
258        rational::RationalInteger::try_to_decimal(self.require_literal_factor())
259            .expect("BUG: measure unit factor must convert to decimal")
260    }
261
262    #[must_use]
263    pub fn minimum_decimal(&self) -> Option<Decimal> {
264        self.minimum.as_ref().map(|bound| {
265            bound
266                .try_to_decimal()
267                .expect("BUG: planned measure unit minimum must convert to decimal")
268        })
269    }
270
271    #[must_use]
272    pub fn maximum_decimal(&self) -> Option<Decimal> {
273        self.maximum.as_ref().map(|bound| {
274            bound
275                .try_to_decimal()
276                .expect("BUG: planned measure unit maximum must convert to decimal")
277        })
278    }
279
280    #[must_use]
281    pub fn suggestion_magnitude_decimal(&self) -> Option<Decimal> {
282        self.suggestion_magnitude.as_ref().map(|bound| {
283            bound
284                .try_to_decimal()
285                .expect("BUG: planned measure unit default must convert to decimal")
286        })
287    }
288
289    /// Maximum bound lifted to canonical units via `maximum * factor`.
290    #[must_use]
291    pub fn maximum_canonical_decimal(&self) -> Option<Decimal> {
292        self.maximum.as_ref().map(|maximum| {
293            let canonical = rational::checked_mul(maximum, self.require_literal_factor())
294                .expect("BUG: planned measure unit maximum canonical multiply must succeed");
295            canonical
296                .try_to_decimal()
297                .expect("BUG: planned measure unit maximum canonical must convert to decimal")
298        })
299    }
300}
301
302#[derive(Clone, Debug, PartialEq, Eq, Hash)]
303pub struct MeasureUnits(pub Vec<MeasureUnit>);
304
305impl MeasureUnits {
306    pub fn new() -> Self {
307        MeasureUnits(Vec::new())
308    }
309    pub fn get(&self, name: &str) -> Result<&MeasureUnit, String> {
310        self.0.iter().find(|u| u.name == name).ok_or_else(|| {
311            let valid: Vec<&str> = self.0.iter().map(|u| u.name.as_str()).collect();
312            format!(
313                "Unknown unit '{}' for this measure type. Valid units: {}",
314                name,
315                valid.join(", ")
316            )
317        })
318    }
319
320    pub fn iter(&self) -> std::slice::Iter<'_, MeasureUnit> {
321        self.0.iter()
322    }
323    pub fn push(&mut self, u: MeasureUnit) {
324        self.0.push(u);
325    }
326    pub fn is_empty(&self) -> bool {
327        self.0.is_empty()
328    }
329    pub fn len(&self) -> usize {
330        self.0.len()
331    }
332    pub fn map<F: FnMut(MeasureUnit) -> MeasureUnit>(self, f: F) -> Self {
333        MeasureUnits(self.0.into_iter().map(f).collect())
334    }
335}
336
337impl MeasureUnit {
338    pub fn with_decomposition(self, decomposition: BaseMeasureVector) -> Self {
339        Self {
340            decomposition,
341            ..self
342        }
343    }
344    pub fn with_factor(self, factor: RationalInteger) -> Self {
345        match self.scale {
346            UnitScale::Literal(_) => Self {
347                scale: UnitScale::Literal(factor),
348                ..self
349            },
350            UnitScale::Unresolved { .. } | UnitScale::Reference { .. } => {
351                panic!(
352                    "BUG: with_factor on reference-scaled unit '{}'; use with_reference_scale for compounds that inherit dynamic bases",
353                    self.name
354                )
355            }
356        }
357    }
358
359    /// Replace this unit's scale with a dynamic product of settled references and
360    /// a plan-time coefficient from compound resolution.
361    pub fn with_reference_scale(
362        self,
363        factors: Vec<(crate::planning::semantics::ReferenceTarget, i32)>,
364        coefficient: RationalInteger,
365    ) -> Self {
366        let factors = UnitScale::canonicalize_scale_factors(&factors);
367        if factors.is_empty() {
368            panic!(
369                "BUG: with_reference_scale on unit '{}' got an empty factor product; compound resolution must leave a literal scale when all dynamics cancel",
370                self.name
371            );
372        }
373        Self {
374            scale: UnitScale::Reference {
375                factors,
376                coefficient,
377            },
378            ..self
379        }
380    }
381
382    pub fn with_resolved_scale(
383        self,
384        resolved: crate::planning::semantics::ReferenceTarget,
385    ) -> Self {
386        match self.scale {
387            UnitScale::Unresolved { .. } => Self {
388                scale: UnitScale::Reference {
389                    factors: vec![(resolved, 1)],
390                    coefficient: rational::rational_one(),
391                },
392                ..self
393            },
394            UnitScale::Literal(_) | UnitScale::Reference { .. } => {
395                panic!(
396                    "BUG: with_resolved_scale on non-unresolved unit '{}'",
397                    self.name
398                )
399            }
400        }
401    }
402    pub fn with_derived_measure_factors(self, derived_measure_factors: Vec<(String, i32)>) -> Self {
403        Self {
404            derived_measure_factors,
405            ..self
406        }
407    }
408}
409
410fn format_scale_factors(factors: &[(crate::planning::semantics::ReferenceTarget, i32)]) -> String {
411    factors
412        .iter()
413        .map(|(target, exponent)| {
414            if *exponent == 1 {
415                target.to_string()
416            } else {
417                format!("{target}^{exponent}")
418            }
419        })
420        .collect::<Vec<_>>()
421        .join(" * ")
422}
423
424impl Default for MeasureUnits {
425    fn default() -> Self {
426        MeasureUnits::new()
427    }
428}
429
430impl From<Vec<MeasureUnit>> for MeasureUnits {
431    fn from(v: Vec<MeasureUnit>) -> Self {
432        MeasureUnits(v)
433    }
434}
435
436impl<'a> IntoIterator for &'a MeasureUnits {
437    type Item = &'a MeasureUnit;
438    type IntoIter = std::slice::Iter<'a, MeasureUnit>;
439    fn into_iter(self) -> Self::IntoIter {
440        self.0.iter()
441    }
442}
443
444#[derive(Clone, Debug, PartialEq, Eq, Hash)]
445pub struct RatioUnit {
446    pub name: String,
447    pub value: RationalInteger,
448    pub minimum: Option<RationalInteger>,
449    pub maximum: Option<RationalInteger>,
450    pub suggestion_magnitude: Option<RationalInteger>,
451}
452
453impl RatioUnit {
454    pub fn clear_constraint_magnitudes(&mut self) {
455        self.minimum = None;
456        self.maximum = None;
457        self.suggestion_magnitude = None;
458    }
459
460    /// Unit scale as decimal (schema ratio unit values always commit).
461    pub fn value_decimal(&self) -> Decimal {
462        self.value
463            .try_to_decimal()
464            .expect("BUG: ratio unit value must convert to decimal")
465    }
466
467    #[must_use]
468    pub fn minimum_decimal(&self) -> Option<Decimal> {
469        self.minimum.as_ref().map(|bound| {
470            bound
471                .try_to_decimal()
472                .expect("BUG: planned ratio unit minimum must convert to decimal")
473        })
474    }
475
476    #[must_use]
477    pub fn maximum_decimal(&self) -> Option<Decimal> {
478        self.maximum.as_ref().map(|bound| {
479            bound
480                .try_to_decimal()
481                .expect("BUG: planned ratio unit maximum must convert to decimal")
482        })
483    }
484
485    #[must_use]
486    pub fn suggestion_magnitude_decimal(&self) -> Option<Decimal> {
487        self.suggestion_magnitude.as_ref().map(|bound| {
488            bound
489                .try_to_decimal()
490                .expect("BUG: planned ratio unit default must convert to decimal")
491        })
492    }
493
494    /// Maximum bound lifted to canonical ratio space via `maximum * value`.
495    #[must_use]
496    pub fn maximum_canonical_decimal(&self) -> Option<Decimal> {
497        self.maximum.as_ref().map(|maximum| {
498            let canonical = rational::checked_mul(maximum, &self.value)
499                .expect("BUG: planned ratio unit maximum canonical multiply must succeed");
500            canonical
501                .try_to_decimal()
502                .expect("BUG: planned ratio unit maximum canonical must convert to decimal")
503        })
504    }
505}
506
507#[derive(Clone, Debug, PartialEq, Eq, Hash)]
508pub struct RatioUnits(pub Vec<RatioUnit>);
509
510impl RatioUnits {
511    pub fn new() -> Self {
512        RatioUnits(Vec::new())
513    }
514    pub fn get(&self, name: &str) -> Result<&RatioUnit, String> {
515        self.0.iter().find(|u| u.name == name).ok_or_else(|| {
516            let valid: Vec<&str> = self.0.iter().map(|u| u.name.as_str()).collect();
517            format!(
518                "Unknown unit '{}' for this ratio type. Valid units: {}",
519                name,
520                valid.join(", ")
521            )
522        })
523    }
524
525    pub fn iter(&self) -> std::slice::Iter<'_, RatioUnit> {
526        self.0.iter()
527    }
528    pub fn push(&mut self, u: RatioUnit) {
529        self.0.push(u);
530    }
531    pub fn is_empty(&self) -> bool {
532        self.0.is_empty()
533    }
534    pub fn len(&self) -> usize {
535        self.0.len()
536    }
537}
538
539impl Default for RatioUnits {
540    fn default() -> Self {
541        RatioUnits::new()
542    }
543}
544
545impl From<Vec<RatioUnit>> for RatioUnits {
546    fn from(v: Vec<RatioUnit>) -> Self {
547        RatioUnits(v)
548    }
549}
550
551impl<'a> IntoIterator for &'a RatioUnits {
552    type Item = &'a RatioUnit;
553    type IntoIter = std::slice::Iter<'a, RatioUnit>;
554    fn into_iter(self) -> Self::IntoIter {
555        self.0.iter()
556    }
557}
558
559// -----------------------------------------------------------------------------
560// Literal value types
561// -----------------------------------------------------------------------------
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
564#[serde(rename_all = "lowercase")]
565pub enum BooleanValue {
566    True,
567    False,
568    Yes,
569    No,
570}
571
572impl From<BooleanValue> for bool {
573    fn from(value: BooleanValue) -> bool {
574        matches!(value, BooleanValue::True | BooleanValue::Yes)
575    }
576}
577
578impl From<&BooleanValue> for bool {
579    fn from(value: &BooleanValue) -> bool {
580        (*value).into() // Copy makes this ok
581    }
582}
583
584impl From<bool> for BooleanValue {
585    fn from(value: bool) -> BooleanValue {
586        if value {
587            BooleanValue::True
588        } else {
589            BooleanValue::False
590        }
591    }
592}
593
594impl std::ops::Not for BooleanValue {
595    type Output = BooleanValue;
596
597    fn not(self) -> Self::Output {
598        if self.into() {
599            BooleanValue::False
600        } else {
601            BooleanValue::True
602        }
603    }
604}
605
606impl std::ops::Not for &BooleanValue {
607    type Output = BooleanValue;
608
609    fn not(self) -> Self::Output {
610        if (*self).into() {
611            BooleanValue::False
612        } else {
613            BooleanValue::True
614        }
615    }
616}
617
618impl std::str::FromStr for BooleanValue {
619    type Err = String;
620
621    fn from_str(s: &str) -> Result<Self, Self::Err> {
622        match s.trim().to_lowercase().as_str() {
623            "true" => Ok(BooleanValue::True),
624            "false" => Ok(BooleanValue::False),
625            "yes" => Ok(BooleanValue::Yes),
626            "no" => Ok(BooleanValue::No),
627            _ => Err(format!("Invalid boolean: '{}'", s)),
628        }
629    }
630}
631
632impl BooleanValue {
633    #[must_use]
634    pub fn as_str(&self) -> &'static str {
635        match self {
636            BooleanValue::True => "true",
637            BooleanValue::False => "false",
638            BooleanValue::Yes => "yes",
639            BooleanValue::No => "no",
640        }
641    }
642}
643
644impl fmt::Display for BooleanValue {
645    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
646        write!(f, "{}", self.as_str())
647    }
648}
649
650/// UTC offset in signed whole minutes; `|offset_minutes|` is at most 23:59.
651#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
652pub struct TimezoneValue {
653    pub offset_minutes: i16,
654}
655
656const MAX_TIMEZONE_OFFSET_MINUTES: i16 = 23 * 60 + 59;
657
658impl TimezoneValue {
659    /// `None` when `seconds` is not a whole number of minutes within ±23:59.
660    pub fn from_offset_seconds(seconds: i32) -> Option<Self> {
661        if seconds % 60 != 0 {
662            return None;
663        }
664        let offset_minutes = i16::try_from(seconds / 60).ok()?;
665        (offset_minutes.unsigned_abs() <= MAX_TIMEZONE_OFFSET_MINUTES.unsigned_abs())
666            .then_some(Self { offset_minutes })
667    }
668
669    pub fn offset_seconds(&self) -> i32 {
670        i32::from(self.offset_minutes) * 60
671    }
672}
673
674impl fmt::Display for TimezoneValue {
675    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
676        if self.offset_minutes == 0 {
677            return write!(f, "Z");
678        }
679        let sign = if self.offset_minutes > 0 { "+" } else { "-" };
680        let magnitude = self.offset_minutes.unsigned_abs();
681        write!(f, "{}{:02}:{:02}", sign, magnitude / 60, magnitude % 60)
682    }
683}
684
685impl Serialize for TimezoneValue {
686    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
687        serializer.serialize_str(&self.to_string())
688    }
689}
690
691impl<'de> Deserialize<'de> for TimezoneValue {
692    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
693        let s = String::deserialize(deserializer)?;
694        Self::from_str(&s).map_err(serde::de::Error::custom)
695    }
696}
697
698impl FromStr for TimezoneValue {
699    type Err = String;
700
701    fn from_str(s: &str) -> Result<Self, Self::Err> {
702        let trimmed = s.trim();
703        if trimmed == "Z" || trimmed == "z" {
704            return Ok(Self { offset_minutes: 0 });
705        }
706        if trimmed.len() == 6
707            && (trimmed.starts_with('+') || trimmed.starts_with('-'))
708            && trimmed.as_bytes()[3] == b':'
709        {
710            let hours: i16 = trimmed[1..3]
711                .parse()
712                .map_err(|_| format!("Invalid timezone format: '{s}'"))?;
713            let minutes: i16 = trimmed[4..6]
714                .parse()
715                .map_err(|_| format!("Invalid timezone format: '{s}'"))?;
716            if !(0..=23).contains(&hours) || !(0..60).contains(&minutes) {
717                return Err(format!("Invalid timezone format: '{s}'"));
718            }
719            let magnitude = hours * 60 + minutes;
720            let offset_minutes = if trimmed.starts_with('-') {
721                -magnitude
722            } else {
723                magnitude
724            };
725            return Ok(Self { offset_minutes });
726        }
727        Err(format!("Invalid timezone format: '{s}'"))
728    }
729}
730
731#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
732pub struct TimeValue {
733    pub hour: u8,
734    pub minute: u8,
735    pub second: u8,
736    pub microsecond: u32,
737    pub timezone: Option<TimezoneValue>,
738}
739
740impl Serialize for TimeValue {
741    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
742        serializer.serialize_str(&self.to_string())
743    }
744}
745
746impl<'de> Deserialize<'de> for TimeValue {
747    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
748        let s = String::deserialize(deserializer)?;
749        Self::from_str(&s).map_err(serde::de::Error::custom)
750    }
751}
752
753impl fmt::Display for TimeValue {
754    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
755        write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)?;
756        if self.microsecond != 0 {
757            write!(f, ".{:06}", self.microsecond)?;
758        }
759        if let Some(timezone) = &self.timezone {
760            write!(f, "{}", timezone)?;
761        }
762        Ok(())
763    }
764}
765
766#[derive(
767    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
768)]
769#[serde(rename_all = "snake_case")]
770pub enum DateGranularity {
771    Year,
772    YearMonth,
773    /// ISO 8601 week date. Stores original (iso_year, week) because the ISO
774    /// week year can differ from the calendar year — e.g. "2026-W01" has
775    /// iso_year=2026 but the stored calendar date year=2025.
776    IsoWeek {
777        iso_year: i32,
778        week: u32,
779    },
780    #[default]
781    Full,
782    DateTime,
783}
784
785#[derive(Debug, Clone)]
786pub struct DateTimeValue {
787    pub year: i32,
788    pub month: u32,
789    pub day: u32,
790    pub hour: u32,
791    pub minute: u32,
792    pub second: u32,
793    pub microsecond: u32,
794    pub timezone: Option<TimezoneValue>,
795    pub granularity: DateGranularity,
796}
797
798impl Serialize for DateTimeValue {
799    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
800        serializer.serialize_str(&self.to_string())
801    }
802}
803
804impl<'de> Deserialize<'de> for DateTimeValue {
805    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
806        let s = String::deserialize(deserializer)?;
807        Self::from_str(&s).map_err(serde::de::Error::custom)
808    }
809}
810
811impl PartialEq for DateTimeValue {
812    fn eq(&self, other: &Self) -> bool {
813        self.year == other.year
814            && self.month == other.month
815            && self.day == other.day
816            && self.hour == other.hour
817            && self.minute == other.minute
818            && self.second == other.second
819            && self.microsecond == other.microsecond
820            && self.timezone == other.timezone
821    }
822}
823
824impl Eq for DateTimeValue {}
825
826impl PartialOrd for DateTimeValue {
827    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
828        Some(self.cmp(other))
829    }
830}
831
832impl Ord for DateTimeValue {
833    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
834        self.year
835            .cmp(&other.year)
836            .then_with(|| self.month.cmp(&other.month))
837            .then_with(|| self.day.cmp(&other.day))
838            .then_with(|| self.hour.cmp(&other.hour))
839            .then_with(|| self.minute.cmp(&other.minute))
840            .then_with(|| self.second.cmp(&other.second))
841            .then_with(|| self.microsecond.cmp(&other.microsecond))
842            .then_with(|| self.timezone.cmp(&other.timezone))
843    }
844}
845
846impl std::hash::Hash for DateTimeValue {
847    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
848        self.year.hash(state);
849        self.month.hash(state);
850        self.day.hash(state);
851        self.hour.hash(state);
852        self.minute.hash(state);
853        self.second.hash(state);
854        self.microsecond.hash(state);
855        self.timezone.hash(state);
856    }
857}
858
859impl DateTimeValue {
860    pub fn now() -> Self {
861        let now = chrono::Local::now();
862        let offset_secs = now.offset().local_minus_utc();
863        Self {
864            year: now.year(),
865            month: now.month(),
866            day: now.day(),
867            hour: now.time().hour(),
868            minute: now.time().minute(),
869            second: now.time().second(),
870            microsecond: now.time().nanosecond() / 1000 % 1_000_000,
871            timezone: Some(TimezoneValue::from_offset_seconds(offset_secs).expect(
872                "BUG: current local UTC offsets are whole minutes within ±23:59 since 1972",
873            )),
874            granularity: DateGranularity::DateTime,
875        }
876    }
877
878    fn parse_iso_week(s: &str) -> Option<Self> {
879        let parts: Vec<&str> = s.split("-W").collect();
880        if parts.len() != 2 {
881            return None;
882        }
883        let iso_year: i32 = parts[0].parse().ok()?;
884        let week: u32 = parts[1].parse().ok()?;
885        if week == 0 || week > 53 {
886            return None;
887        }
888        let date = chrono::NaiveDate::from_isoywd_opt(iso_year, week, chrono::Weekday::Mon)?;
889        Some(Self {
890            year: date.year(),
891            month: date.month(),
892            day: date.day(),
893            hour: 0,
894            minute: 0,
895            second: 0,
896            microsecond: 0,
897            timezone: None,
898            granularity: DateGranularity::IsoWeek { iso_year, week },
899        })
900    }
901}
902
903impl fmt::Display for DateTimeValue {
904    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
905        match self.granularity {
906            DateGranularity::Year => write!(f, "{:04}", self.year),
907            DateGranularity::YearMonth => write!(f, "{:04}-{:02}", self.year, self.month),
908            DateGranularity::IsoWeek { iso_year, week } => {
909                write!(f, "{:04}-W{:02}", iso_year, week)
910            }
911            DateGranularity::Full => {
912                write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
913            }
914            DateGranularity::DateTime => {
915                write!(
916                    f,
917                    "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
918                    self.year, self.month, self.day, self.hour, self.minute, self.second
919                )?;
920                if self.microsecond != 0 {
921                    write!(f, ".{:06}", self.microsecond)?;
922                }
923                if let Some(tz) = &self.timezone {
924                    write!(f, "{}", tz)?;
925                }
926                Ok(())
927            }
928        }
929    }
930}
931
932/// Literal value data (no type information). Single source of truth in literals.
933///
934/// `NumberWithUnit` is type-agnostic at parse time (`10 eur` and `50%` share this shape).
935/// Planning resolves ratio vs measure via the unit index and target [`TypeSpecification`].
936#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
937#[serde(rename_all = "snake_case")]
938pub enum Value {
939    Number(Decimal),
940    NumberWithUnit(Decimal, String),
941    Text(String),
942    Date(DateTimeValue),
943    Time(TimeValue),
944    Boolean(BooleanValue),
945    Range(Box<Value>, Box<Value>),
946}
947
948impl fmt::Display for Value {
949    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
950        match self {
951            Value::Number(n) => write!(f, "{}", n),
952            Value::Text(s) => write!(f, "{}", s),
953            Value::Date(dt) => write!(f, "{}", dt),
954            Value::Boolean(b) => write!(f, "{}", b),
955            Value::Time(time) => write!(f, "{}", time),
956            Value::NumberWithUnit(n, u) => match u.as_str() {
957                "percent" => {
958                    let norm = n.normalize();
959                    let s = if norm.fract().is_zero() {
960                        norm.trunc().to_string()
961                    } else {
962                        norm.to_string()
963                    };
964                    write!(f, "{}%", s)
965                }
966                "permille" => {
967                    let norm = n.normalize();
968                    let s = if norm.fract().is_zero() {
969                        norm.trunc().to_string()
970                    } else {
971                        norm.to_string()
972                    };
973                    write!(f, "{}%%", s)
974                }
975                unit => {
976                    let norm = n.normalize();
977                    let s = if norm.fract().is_zero() {
978                        norm.trunc().to_string()
979                    } else {
980                        norm.to_string()
981                    };
982                    write!(f, "{} {}", s, unit)
983                }
984            },
985            Value::Range(left, right) => write!(f, "{}...{}", left, right),
986        }
987    }
988}
989
990// -----------------------------------------------------------------------------
991// FromStr (single source of truth per type)
992// -----------------------------------------------------------------------------
993
994impl std::str::FromStr for DateTimeValue {
995    type Err = String;
996
997    fn from_str(s: &str) -> Result<Self, Self::Err> {
998        if let Ok(dt) = s.parse::<chrono::DateTime<chrono::FixedOffset>>() {
999            let timezone = TimezoneValue::from_offset_seconds(dt.offset().local_minus_utc())
1000                .ok_or_else(|| format!("Invalid timezone offset in date: '{}'", s))?;
1001            let microsecond = dt.nanosecond() / 1000 % 1_000_000;
1002            return Ok(DateTimeValue {
1003                year: dt.year(),
1004                month: dt.month(),
1005                day: dt.day(),
1006                hour: dt.hour(),
1007                minute: dt.minute(),
1008                second: dt.second(),
1009                microsecond,
1010                timezone: Some(timezone),
1011                granularity: DateGranularity::DateTime,
1012            });
1013        }
1014        if let Ok(dt) = s.parse::<chrono::NaiveDateTime>() {
1015            let microsecond = dt.nanosecond() / 1000 % 1_000_000;
1016            return Ok(DateTimeValue {
1017                year: dt.year(),
1018                month: dt.month(),
1019                day: dt.day(),
1020                hour: dt.hour(),
1021                minute: dt.minute(),
1022                second: dt.second(),
1023                microsecond,
1024                timezone: None,
1025                granularity: DateGranularity::DateTime,
1026            });
1027        }
1028        if let Ok(d) = s.parse::<chrono::NaiveDate>() {
1029            return Ok(DateTimeValue {
1030                year: d.year(),
1031                month: d.month(),
1032                day: d.day(),
1033                hour: 0,
1034                minute: 0,
1035                second: 0,
1036                microsecond: 0,
1037                timezone: None,
1038                granularity: DateGranularity::Full,
1039            });
1040        }
1041        if let Some(week_val) = Self::parse_iso_week(s) {
1042            return Ok(week_val);
1043        }
1044        if let Ok(ym) = chrono::NaiveDate::parse_from_str(&format!("{}-01", s), "%Y-%m-%d") {
1045            return Ok(Self {
1046                year: ym.year(),
1047                month: ym.month(),
1048                day: 1,
1049                hour: 0,
1050                minute: 0,
1051                second: 0,
1052                microsecond: 0,
1053                timezone: None,
1054                granularity: DateGranularity::YearMonth,
1055            });
1056        }
1057        if let Ok(year) = s.parse::<i32>() {
1058            if (1..=9999).contains(&year) {
1059                return Ok(Self {
1060                    year,
1061                    month: 1,
1062                    day: 1,
1063                    hour: 0,
1064                    minute: 0,
1065                    second: 0,
1066                    microsecond: 0,
1067                    timezone: None,
1068                    granularity: DateGranularity::Year,
1069                });
1070            }
1071        }
1072        Err(format!("Invalid date format: '{}'", s))
1073    }
1074}
1075
1076impl std::str::FromStr for TimeValue {
1077    type Err = String;
1078
1079    fn from_str(s: &str) -> Result<Self, Self::Err> {
1080        let trimmed = s.trim();
1081
1082        let (time_text, timezone) = if trimmed.ends_with('Z') || trimmed.ends_with('z') {
1083            (
1084                &trimmed[..trimmed.len() - 1],
1085                Some(TimezoneValue { offset_minutes: 0 }),
1086            )
1087        } else if let Some(first) = trimmed.chars().next() {
1088            let after_first = first.len_utf8();
1089            if let Some(sign_index) = trimmed[after_first..]
1090                .rfind(['+', '-'])
1091                .map(|index| index + after_first)
1092            {
1093                let timezone_text = &trimmed[sign_index..];
1094                if timezone_text.len() == 6
1095                    && (timezone_text.starts_with('+') || timezone_text.starts_with('-'))
1096                    && timezone_text.as_bytes()[3] == b':'
1097                {
1098                    let timezone = TimezoneValue::from_str(timezone_text)
1099                        .map_err(|_| format!("Invalid time format: '{s}'"))?;
1100                    (&trimmed[..sign_index], Some(timezone))
1101                } else {
1102                    (trimmed, None)
1103                }
1104            } else {
1105                (trimmed, None)
1106            }
1107        } else {
1108            (trimmed, None)
1109        };
1110
1111        if let Ok(t) = chrono::NaiveTime::parse_from_str(time_text, "%H:%M:%S%.f") {
1112            return Ok(TimeValue {
1113                hour: t.hour() as u8,
1114                minute: t.minute() as u8,
1115                second: t.second() as u8,
1116                microsecond: t.nanosecond() / 1000 % 1_000_000,
1117                timezone,
1118            });
1119        }
1120        if let Ok(t) = chrono::NaiveTime::parse_from_str(time_text, "%H:%M:%S") {
1121            return Ok(TimeValue {
1122                hour: t.hour() as u8,
1123                minute: t.minute() as u8,
1124                second: t.second() as u8,
1125                microsecond: 0,
1126                timezone,
1127            });
1128        }
1129        if let Ok(t) = chrono::NaiveTime::parse_from_str(time_text, "%H:%M") {
1130            return Ok(TimeValue {
1131                hour: t.hour() as u8,
1132                minute: t.minute() as u8,
1133                second: 0,
1134                microsecond: 0,
1135                timezone,
1136            });
1137        }
1138        Err(format!("Invalid time format: '{}'", s))
1139    }
1140}
1141
1142/// Number literal with Lemma rules (strip `_` separators).
1143///
1144/// Fractional digit count beyond [`Decimal::MAX_SCALE`] is rejected (no silent
1145/// truncate). An integer magnitude that cannot be represented is an error.
1146pub(crate) struct NumberLiteral(pub Decimal);
1147
1148impl std::str::FromStr for NumberLiteral {
1149    type Err = String;
1150
1151    fn from_str(s: &str) -> Result<Self, Self::Err> {
1152        let clean = s.trim().replace('_', "");
1153        if let Some(dot) = clean.find('.') {
1154            let frac = &clean[dot + 1..];
1155            let frac_digits = frac.chars().take_while(|c| c.is_ascii_digit()).count();
1156            if frac_digits > Decimal::MAX_SCALE as usize {
1157                return Err(format!(
1158                    "Invalid number '{}': too many fractional digits (max {})",
1159                    s,
1160                    Decimal::MAX_SCALE
1161                ));
1162            }
1163        }
1164        Decimal::from_str(&clean)
1165            .map_err(|_| format!("Invalid number: '{}'", s))
1166            .map(NumberLiteral)
1167    }
1168}
1169
1170/// Text literal with length limit.
1171pub(crate) struct TextLiteral(pub String);
1172
1173impl std::str::FromStr for TextLiteral {
1174    type Err = String;
1175
1176    fn from_str(s: &str) -> Result<Self, Self::Err> {
1177        if s.len() > crate::limits::MAX_TEXT_VALUE_LENGTH {
1178            return Err(format!(
1179                "Text value exceeds maximum length (max {} characters)",
1180                crate::limits::MAX_TEXT_VALUE_LENGTH
1181            ));
1182        }
1183        Ok(TextLiteral(s.to_string()))
1184    }
1185}
1186
1187/// Parsed `<number> <unit-name>` for runtime string input (measure and ratio types).
1188pub(crate) struct NumberWithUnit(pub Decimal, pub String);
1189
1190impl std::str::FromStr for NumberWithUnit {
1191    type Err = String;
1192
1193    fn from_str(s: &str) -> Result<Self, Self::Err> {
1194        let trimmed = s.trim();
1195        if trimmed.is_empty() {
1196            return Err(
1197                "Measure value cannot be empty. Use a number followed by a unit (e.g. '10 eur')."
1198                    .to_string(),
1199            );
1200        }
1201
1202        let mut parts = trimmed.split_whitespace();
1203        let number_part = parts
1204            .next()
1205            .expect("split_whitespace yields >=1 token after non-empty guard");
1206        let unit_part = parts.next().ok_or_else(|| {
1207            format!(
1208                "Measure value must include a unit (e.g. '{} eur').",
1209                number_part
1210            )
1211        })?;
1212        if parts.next().is_some() {
1213            return Err(format!(
1214                "Invalid measure value: '{}'. Expected exactly '<number> <unit>', got extra tokens.",
1215                s
1216            ));
1217        }
1218        let n = number_part
1219            .parse::<NumberLiteral>()
1220            .map_err(|_| format!("Invalid measure: '{}'", s))?
1221            .0;
1222        Ok(NumberWithUnit(n, unit_part.to_string()))
1223    }
1224}
1225
1226/// Strict ratio runtime literal.
1227///
1228/// Grammar (all inputs trimmed first):
1229/// - `<number>`                      → `Bare(n)`
1230/// - `<number>%`  (glued, no inner whitespace) → `Percent(n)` raw magnitude
1231/// - `<number>%%` (glued, no inner whitespace) → `Permille(n)` raw magnitude
1232/// - `<number> <unit-name>`          → `Named { value: n, unit: <unit-name> }`
1233///
1234/// `<number>` is parsed by [`NumberLiteral`] (signed, allows `_`/`,` separators).
1235/// Whitespace between the number and a keyword unit may be any non-empty run
1236/// (`"50 percent"`, `"50    percent"`, `"50\tpercent"` are all accepted).
1237///
1238/// The sigils `%` / `%%` are language-level constants meaning "divide by 100 / 1000"
1239/// and unconditionally produce the canonical unit names `"percent"` / `"permille"`.
1240/// They are NOT accepted as standalone unit-position tokens (i.e. `"5 %"` is rejected).
1241///
1242/// Signedness is intentionally not constrained at this layer: bounds are the
1243/// type-system's job (`-> minimum 0%`), and the evaluator can produce signed
1244/// ratios from non-negative inputs (e.g. `this_year - last_year` on `percent`).
1245/// The parser must accept everything the evaluator can emit (round-trip symmetry).
1246///
1247/// `Named` carries the raw unit name; the caller in `parse_number_unit::Ratio`
1248/// resolves it against the type's [`RatioUnits`] table (covering built-in
1249/// `percent`/`permille` and any user-defined units like `basis_points`).
1250#[derive(Debug, Clone, PartialEq, Eq)]
1251pub(crate) enum RatioLiteral {
1252    Bare(Decimal),
1253    Percent(Decimal),
1254    Permille(Decimal),
1255    Named { value: Decimal, unit: String },
1256}
1257
1258impl std::str::FromStr for RatioLiteral {
1259    type Err = String;
1260
1261    fn from_str(s: &str) -> Result<Self, Self::Err> {
1262        let trimmed = s.trim();
1263        if trimmed.is_empty() {
1264            return Err(
1265                "Ratio value cannot be empty. Use a number, optionally followed by '%', '%%', or a unit name (e.g. '0.5', '50%', '25%%', '50 percent')."
1266                    .to_string(),
1267            );
1268        }
1269
1270        let mut parts = trimmed.split_whitespace();
1271        let first = parts
1272            .next()
1273            .expect("split_whitespace yields >=1 token after non-empty guard");
1274        let second = parts.next();
1275        if parts.next().is_some() {
1276            return Err(format!(
1277                "Invalid ratio value: '{}'. Expected '<number>', '<number>%', '<number>%%', or '<number> <unit>'.",
1278                s
1279            ));
1280        }
1281
1282        match second {
1283            // 1-token forms: bare number, or sigil-suffixed number.
1284            None => {
1285                if let Some(rest) = first.strip_suffix("%%") {
1286                    if rest.is_empty() {
1287                        return Err(format!(
1288                            "Invalid ratio value: '{}'. '%%' must follow a number (e.g. '25%%').",
1289                            s
1290                        ));
1291                    }
1292                    let n = rest
1293                        .parse::<NumberLiteral>()
1294                        .map_err(|_| {
1295                            format!(
1296                            "Invalid ratio value: '{}'. '{}' is not a valid number before '%%'.",
1297                            s, rest
1298                        )
1299                        })?
1300                        .0;
1301                    return Ok(RatioLiteral::Permille(n));
1302                }
1303                if let Some(rest) = first.strip_suffix('%') {
1304                    if rest.is_empty() {
1305                        return Err(format!(
1306                            "Invalid ratio value: '{}'. '%' must follow a number (e.g. '50%').",
1307                            s
1308                        ));
1309                    }
1310                    let n = rest
1311                        .parse::<NumberLiteral>()
1312                        .map_err(|_| {
1313                            format!(
1314                                "Invalid ratio value: '{}'. '{}' is not a valid number before '%'.",
1315                                s, rest
1316                            )
1317                        })?
1318                        .0;
1319                    return Ok(RatioLiteral::Percent(n));
1320                }
1321                let n = first.parse::<NumberLiteral>().map_err(|_| {
1322                    format!(
1323                        "Invalid ratio value: '{}'. Must be a number, '<n>%', '<n>%%', '<n> percent', '<n> permille', or '<n> <unit>'.",
1324                        s
1325                    )
1326                })?.0;
1327                Ok(RatioLiteral::Bare(n))
1328            }
1329            // 2-token form: <number> <unit-name>. Sigils are not accepted as unit-position tokens.
1330            Some(unit) => {
1331                if unit == "%" || unit == "%%" {
1332                    return Err(format!(
1333                        "Invalid ratio value: '{}'. '{}' must be glued to the number (e.g. '{}{}'), not separated by whitespace.",
1334                        s, unit, first, unit
1335                    ));
1336                }
1337                let n = first
1338                    .parse::<NumberLiteral>()
1339                    .map_err(|_| {
1340                        format!(
1341                            "Invalid ratio value: '{}'. '{}' is not a valid number.",
1342                            s, first
1343                        )
1344                    })?
1345                    .0;
1346                Ok(RatioLiteral::Named {
1347                    value: n,
1348                    unit: unit.to_string(),
1349                })
1350            }
1351        }
1352    }
1353}
1354
1355#[cfg(test)]
1356mod tests {
1357    use super::{BooleanValue, TimeValue, TimezoneValue};
1358    use std::str::FromStr;
1359
1360    #[test]
1361    fn timezone_value_round_trips_every_signed_offset() {
1362        for text in [
1363            "-00:30", "-03:30", "-09:30", "+05:45", "+00:30", "-12:00", "+14:00",
1364        ] {
1365            let timezone = TimezoneValue::from_str(text).expect("valid offset");
1366            assert_eq!(timezone.to_string(), text);
1367        }
1368        assert_eq!(TimezoneValue::from_str("+00:00").unwrap().to_string(), "Z");
1369        assert_eq!(TimezoneValue::from_str("-00:00").unwrap().to_string(), "Z");
1370    }
1371
1372    #[test]
1373    fn timezone_value_orders_by_signed_offset() {
1374        let ordered = [
1375            "-09:30", "-03:30", "-03:00", "-00:30", "Z", "+00:30", "+05:45",
1376        ]
1377        .map(|text| TimezoneValue::from_str(text).expect("valid offset"));
1378        for pair in ordered.windows(2) {
1379            assert!(
1380                pair[0] < pair[1],
1381                "{} must order before {}",
1382                pair[0],
1383                pair[1]
1384            );
1385        }
1386    }
1387
1388    #[test]
1389    fn time_value_rejects_multibyte_first_char() {
1390        for invalid in ["é", "é10:00", "日12:00", "é10:00+01:00", "€+01:00"] {
1391            assert!(
1392                TimeValue::from_str(invalid).is_err(),
1393                "'{invalid}' must not parse as time"
1394            );
1395        }
1396    }
1397
1398    #[test]
1399    fn time_value_parses_timezone_after_ascii_time() {
1400        let time = TimeValue::from_str("10:30:00+01:00").expect("valid time with offset");
1401        assert_eq!(time.hour, 10);
1402        assert_eq!(time.minute, 30);
1403        let timezone = time.timezone.expect("offset parsed");
1404        assert_eq!(timezone.offset_minutes, 60);
1405    }
1406
1407    #[test]
1408    fn boolean_value_rejects_accept_and_reject_strings() {
1409        for invalid in ["accept", "reject"] {
1410            assert!(
1411                BooleanValue::from_str(invalid).is_err(),
1412                "'{invalid}' must not parse as boolean"
1413            );
1414        }
1415    }
1416
1417    #[test]
1418    #[should_panic(expected = "BUG: literal factor required")]
1419    fn require_literal_factor_panics_on_reference_scale() {
1420        let unit = super::MeasureUnit::from_reference(
1421            "gbp".to_string(),
1422            "rate".to_string(),
1423            Vec::new(),
1424            "rate".to_string(),
1425        );
1426        let _ = unit.require_literal_factor();
1427    }
1428}