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