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 data/rule
32/// reference whose value is known only during evaluation.
33///
34/// Dynamic scales carry a plan-time `coefficient` and `exponent` so compound
35/// units can inherit a base unit's reference:
36/// `effective = settled(reference)^exponent * coefficient`.
37/// Plain `-> unit gbp: rate` uses coefficient 1 and exponent 1.
38/// `-> unit gbp_per_kg: gbp/kilogram` uses the gbp reference with
39/// coefficient `1/kilogram` and exponent 1.
40#[derive(Clone, Debug, PartialEq, Eq, Hash)]
41pub enum UnitScale {
42    /// 1 of this unit equals this many canonical units.
43    Literal(RationalInteger),
44    /// Written path between parse and classification (`rate`, `fx.rate`). The
45    /// declaring spec's type resolution replaces it with [`Self::Reference`] or a
46    /// compound literal factor; it never survives type resolution.
47    Unresolved {
48        written: String,
49        segments: Vec<String>,
50        name: String,
51        coefficient: RationalInteger,
52        exponent: i32,
53    },
54    /// Resolved `number` data field or rule, in the coordinates of the spec whose
55    /// type resolution wrote it: a local data or rule, or `uses` hops plus a member
56    /// of the imported spec. After planning succeeds every dynamic unit is this
57    /// variant — never [`Self::Unresolved`].
58    Reference {
59        target: crate::planning::semantics::ReferenceTarget,
60        coefficient: RationalInteger,
61        exponent: i32,
62    },
63}
64
65impl UnitScale {
66    #[must_use]
67    pub fn dynamic_coefficient(&self) -> Option<&RationalInteger> {
68        match self {
69            UnitScale::Literal(_) => None,
70            UnitScale::Unresolved { coefficient, .. }
71            | UnitScale::Reference { coefficient, .. } => Some(coefficient),
72        }
73    }
74
75    #[must_use]
76    pub fn dynamic_exponent(&self) -> Option<i32> {
77        match self {
78            UnitScale::Literal(_) => None,
79            UnitScale::Unresolved { exponent, .. } | UnitScale::Reference { exponent, .. } => {
80                Some(*exponent)
81            }
82        }
83    }
84}
85
86/// A single unit within a Measure type.
87///
88/// `scale` is the conversion factor: 1 of this unit equals `scale` canonical units
89/// when the scale is a literal, or a referenced `number` when it is dynamic.
90/// `derived_measure_factors` stores `(measure_ref, exponent)` pairs from compound unit declarations
91/// (e.g., `meter/second` produces `[("meter", 1), ("second", -1)]`). Empty for base units.
92/// `decomposition` is the dimensional decomposition vector, populated during the planning
93/// decomposition pass. It is empty until that pass completes.
94#[derive(Clone, Debug, PartialEq, Eq, Hash)]
95pub struct MeasureUnit {
96    pub name: String,
97    pub scale: UnitScale,
98    pub derived_measure_factors: Vec<(String, i32)>,
99    pub decomposition: BaseMeasureVector,
100    /// Minimum magnitude in this unit (schema/UI); canonical bound is on the type.
101    pub minimum: Option<RationalInteger>,
102    /// Maximum magnitude in this unit (schema/UI).
103    pub maximum: Option<RationalInteger>,
104    /// Default suggestion magnitude in this unit (schema/UI).
105    pub suggestion_magnitude: Option<RationalInteger>,
106}
107
108impl MeasureUnit {
109    pub fn from_decimal_factor(
110        name: String,
111        decimal_factor: Decimal,
112        derived_measure_factors: Vec<(String, i32)>,
113    ) -> Result<Self, String> {
114        let factor =
115            rational::decimal_to_rational(decimal_factor).map_err(|failure| failure.to_string())?;
116        Ok(MeasureUnit {
117            name,
118            scale: UnitScale::Literal(factor),
119            derived_measure_factors,
120            decomposition: BaseMeasureVector::new(),
121            minimum: None,
122            maximum: None,
123            suggestion_magnitude: None,
124        })
125    }
126
127    pub fn from_reference(
128        name: String,
129        written: String,
130        segments: Vec<String>,
131        reference_name: String,
132    ) -> Self {
133        MeasureUnit {
134            name,
135            scale: UnitScale::Unresolved {
136                written,
137                segments,
138                name: reference_name,
139                coefficient: rational::rational_one(),
140                exponent: 1,
141            },
142            derived_measure_factors: Vec::new(),
143            decomposition: BaseMeasureVector::new(),
144            minimum: None,
145            maximum: None,
146            suggestion_magnitude: None,
147        }
148    }
149
150    pub fn clear_constraint_magnitudes(&mut self) {
151        self.minimum = None;
152        self.maximum = None;
153        self.suggestion_magnitude = None;
154    }
155
156    #[must_use]
157    pub fn is_dynamic(&self) -> bool {
158        matches!(
159            self.scale,
160            UnitScale::Unresolved { .. } | UnitScale::Reference { .. }
161        )
162    }
163
164    /// Source spelling of a reference scale (`rate`, `fx.rate`).
165    #[must_use]
166    pub fn scale_written(&self) -> Option<String> {
167        match &self.scale {
168            UnitScale::Unresolved { written, .. } => Some(written.clone()),
169            UnitScale::Reference { target, .. } => Some(target.to_string()),
170            UnitScale::Literal(_) => None,
171        }
172    }
173
174    /// Declaring-spec target after graph resolution.
175    #[must_use]
176    pub fn resolved_scale_target(&self) -> Option<&crate::planning::semantics::ReferenceTarget> {
177        match &self.scale {
178            UnitScale::Reference { target, .. } => Some(target),
179            UnitScale::Literal(_) | UnitScale::Unresolved { .. } => None,
180        }
181    }
182
183    /// Written reference path (`segments` + `name`) of an unresolved dynamic unit.
184    #[must_use]
185    pub fn scale_reference_parts(&self) -> Option<(&[String], &str)> {
186        match &self.scale {
187            UnitScale::Unresolved { segments, name, .. } => {
188                Some((segments.as_slice(), name.as_str()))
189            }
190            UnitScale::Literal(_) | UnitScale::Reference { .. } => None,
191        }
192    }
193
194    /// Literal conversion factor, when this unit's scale is known at plan time.
195    #[must_use]
196    pub fn literal_factor(&self) -> Option<&RationalInteger> {
197        match &self.scale {
198            UnitScale::Literal(factor) => Some(factor),
199            UnitScale::Unresolved { .. } | UnitScale::Reference { .. } => None,
200        }
201    }
202
203    /// Plan-time coefficient for a dynamic scale (`1` when literal).
204    #[must_use]
205    pub fn scale_coefficient(&self) -> RationalInteger {
206        match &self.scale {
207            UnitScale::Literal(_) => rational::rational_one(),
208            UnitScale::Unresolved { coefficient, .. }
209            | UnitScale::Reference { coefficient, .. } => coefficient.clone(),
210        }
211    }
212
213    /// Exponent applied to the settled reference for a dynamic scale (`1` when literal).
214    #[must_use]
215    pub fn scale_exponent(&self) -> i32 {
216        match &self.scale {
217            UnitScale::Literal(_) => 1,
218            UnitScale::Unresolved { exponent, .. } | UnitScale::Reference { exponent, .. } => {
219                *exponent
220            }
221        }
222    }
223
224    /// Literal conversion factor. Panics if this unit is reference-scaled:
225    /// planning keeps every static path away from dynamic units.
226    pub fn require_literal_factor(&self) -> &RationalInteger {
227        self.literal_factor().unwrap_or_else(|| {
228            panic!(
229                "BUG: literal factor required for unit '{}'; planning must keep static paths away from reference-scaled units",
230                self.name
231            )
232        })
233    }
234
235    /// Whether this unit and `other` have the same scale: equal literal factors, or
236    /// equal reference targets with equal coefficient and exponent. Both types carry
237    /// the coordinates of the spec holding them (import hops are already prefixed on
238    /// the type), so target equality is coordinate equality.
239    #[must_use]
240    pub fn same_scale(&self, other: &Self) -> bool {
241        self.scale == other.scale
242    }
243
244    pub fn is_canonical_factor(&self) -> bool {
245        self.literal_factor()
246            .is_some_and(|factor| *factor == rational::rational_one())
247    }
248
249    pub fn is_positive_factor(&self) -> bool {
250        match &self.scale {
251            UnitScale::Literal(factor) => factor.numer_is_positive(),
252            UnitScale::Unresolved { coefficient, .. }
253            | UnitScale::Reference { coefficient, .. } => coefficient.numer_is_positive(),
254        }
255    }
256
257    /// Conversion factor as decimal (schema unit factors always commit).
258    pub fn factor_decimal(&self) -> Decimal {
259        rational::RationalInteger::try_to_decimal(self.require_literal_factor())
260            .expect("BUG: measure unit factor must convert to decimal")
261    }
262
263    #[must_use]
264    pub fn minimum_decimal(&self) -> Option<Decimal> {
265        self.minimum.as_ref().map(|bound| {
266            bound
267                .try_to_decimal()
268                .expect("BUG: planned measure unit minimum must convert to decimal")
269        })
270    }
271
272    #[must_use]
273    pub fn maximum_decimal(&self) -> Option<Decimal> {
274        self.maximum.as_ref().map(|bound| {
275            bound
276                .try_to_decimal()
277                .expect("BUG: planned measure unit maximum must convert to decimal")
278        })
279    }
280
281    #[must_use]
282    pub fn suggestion_magnitude_decimal(&self) -> Option<Decimal> {
283        self.suggestion_magnitude.as_ref().map(|bound| {
284            bound
285                .try_to_decimal()
286                .expect("BUG: planned measure unit default must convert to decimal")
287        })
288    }
289
290    /// Maximum bound lifted to canonical units via `maximum * factor`.
291    #[must_use]
292    pub fn maximum_canonical_decimal(&self) -> Option<Decimal> {
293        self.maximum.as_ref().map(|maximum| {
294            let canonical = rational::checked_mul(maximum, self.require_literal_factor())
295                .expect("BUG: planned measure unit maximum canonical multiply must succeed");
296            canonical
297                .try_to_decimal()
298                .expect("BUG: planned measure unit maximum canonical must convert to decimal")
299        })
300    }
301}
302
303#[derive(Clone, Debug, PartialEq, Eq, Hash)]
304pub struct MeasureUnits(pub Vec<MeasureUnit>);
305
306impl MeasureUnits {
307    pub fn new() -> Self {
308        MeasureUnits(Vec::new())
309    }
310    pub fn get(&self, name: &str) -> Result<&MeasureUnit, String> {
311        self.0.iter().find(|u| u.name == name).ok_or_else(|| {
312            let valid: Vec<&str> = self.0.iter().map(|u| u.name.as_str()).collect();
313            format!(
314                "Unknown unit '{}' for this measure type. Valid units: {}",
315                name,
316                valid.join(", ")
317            )
318        })
319    }
320
321    pub fn iter(&self) -> std::slice::Iter<'_, MeasureUnit> {
322        self.0.iter()
323    }
324    pub fn push(&mut self, u: MeasureUnit) {
325        self.0.push(u);
326    }
327    pub fn is_empty(&self) -> bool {
328        self.0.is_empty()
329    }
330    pub fn len(&self) -> usize {
331        self.0.len()
332    }
333    pub fn map<F: FnMut(MeasureUnit) -> MeasureUnit>(self, f: F) -> Self {
334        MeasureUnits(self.0.into_iter().map(f).collect())
335    }
336}
337
338impl MeasureUnit {
339    pub fn with_decomposition(self, decomposition: BaseMeasureVector) -> Self {
340        Self {
341            decomposition,
342            ..self
343        }
344    }
345    pub fn with_factor(self, factor: RationalInteger) -> Self {
346        match self.scale {
347            UnitScale::Literal(_) => Self {
348                scale: UnitScale::Literal(factor),
349                ..self
350            },
351            UnitScale::Unresolved { .. } | UnitScale::Reference { .. } => {
352                panic!(
353                    "BUG: with_factor on reference-scaled unit '{}'; use with_dynamic_scale for compounds that inherit a dynamic base",
354                    self.name
355                )
356            }
357        }
358    }
359
360    /// Replace this unit's scale with a dynamic scale (typically copied from a
361    /// referenced base unit) and the composed coefficient/exponent from compound
362    /// resolution.
363    pub fn with_dynamic_scale(
364        self,
365        scale: UnitScale,
366        coefficient: RationalInteger,
367        exponent: i32,
368    ) -> Self {
369        let scale = match scale {
370            UnitScale::Unresolved { .. } => {
371                panic!(
372                    "BUG: with_dynamic_scale on unit '{}' got a still-unresolved base; the declaring spec's type resolution must classify and resolve unit payloads before compound resolution",
373                    self.name
374                )
375            }
376            UnitScale::Reference { target, .. } => UnitScale::Reference {
377                target,
378                coefficient,
379                exponent,
380            },
381            UnitScale::Literal(_) => {
382                panic!(
383                    "BUG: with_dynamic_scale on unit '{}' requires an unresolved or reference scale",
384                    self.name
385                )
386            }
387        };
388        Self { scale, ..self }
389    }
390
391    pub fn with_resolved_scale(
392        self,
393        resolved: crate::planning::semantics::ReferenceTarget,
394    ) -> Self {
395        match self.scale {
396            UnitScale::Unresolved {
397                coefficient,
398                exponent,
399                ..
400            } => Self {
401                scale: UnitScale::Reference {
402                    target: resolved,
403                    coefficient,
404                    exponent,
405                },
406                ..self
407            },
408            UnitScale::Literal(_) | UnitScale::Reference { .. } => {
409                panic!(
410                    "BUG: with_resolved_scale on non-unresolved unit '{}'",
411                    self.name
412                )
413            }
414        }
415    }
416    pub fn with_derived_measure_factors(self, derived_measure_factors: Vec<(String, i32)>) -> Self {
417        Self {
418            derived_measure_factors,
419            ..self
420        }
421    }
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}