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