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, Serialize};
7use std::fmt;
8
9// -----------------------------------------------------------------------------
10// Unit tables for Scale and Ratio types
11// -----------------------------------------------------------------------------
12
13#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct ScaleUnit {
15    pub name: String,
16    pub value: Decimal,
17}
18
19#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(transparent)]
21pub struct ScaleUnits(pub Vec<ScaleUnit>);
22
23impl ScaleUnits {
24    pub fn new() -> Self {
25        ScaleUnits(Vec::new())
26    }
27    pub fn get(&self, name: &str) -> Result<&ScaleUnit, String> {
28        self.0.iter().find(|u| u.name == name).ok_or_else(|| {
29            let valid: Vec<&str> = self.0.iter().map(|u| u.name.as_str()).collect();
30            format!(
31                "Unknown unit '{}' for this scale type. Valid units: {}",
32                name,
33                valid.join(", ")
34            )
35        })
36    }
37    pub fn iter(&self) -> std::slice::Iter<'_, ScaleUnit> {
38        self.0.iter()
39    }
40    pub fn push(&mut self, u: ScaleUnit) {
41        self.0.push(u);
42    }
43    pub fn is_empty(&self) -> bool {
44        self.0.is_empty()
45    }
46    pub fn len(&self) -> usize {
47        self.0.len()
48    }
49}
50
51impl Default for ScaleUnits {
52    fn default() -> Self {
53        ScaleUnits::new()
54    }
55}
56
57impl From<Vec<ScaleUnit>> for ScaleUnits {
58    fn from(v: Vec<ScaleUnit>) -> Self {
59        ScaleUnits(v)
60    }
61}
62
63impl<'a> IntoIterator for &'a ScaleUnits {
64    type Item = &'a ScaleUnit;
65    type IntoIter = std::slice::Iter<'a, ScaleUnit>;
66    fn into_iter(self) -> Self::IntoIter {
67        self.0.iter()
68    }
69}
70
71#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
72pub struct RatioUnit {
73    pub name: String,
74    pub value: Decimal,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
78#[serde(transparent)]
79pub struct RatioUnits(pub Vec<RatioUnit>);
80
81impl RatioUnits {
82    pub fn new() -> Self {
83        RatioUnits(Vec::new())
84    }
85    pub fn get(&self, name: &str) -> Result<&RatioUnit, String> {
86        self.0.iter().find(|u| u.name == name).ok_or_else(|| {
87            let valid: Vec<&str> = self.0.iter().map(|u| u.name.as_str()).collect();
88            format!(
89                "Unknown unit '{}' for this ratio type. Valid units: {}",
90                name,
91                valid.join(", ")
92            )
93        })
94    }
95    pub fn iter(&self) -> std::slice::Iter<'_, RatioUnit> {
96        self.0.iter()
97    }
98    pub fn push(&mut self, u: RatioUnit) {
99        self.0.push(u);
100    }
101    pub fn is_empty(&self) -> bool {
102        self.0.is_empty()
103    }
104    pub fn len(&self) -> usize {
105        self.0.len()
106    }
107}
108
109impl Default for RatioUnits {
110    fn default() -> Self {
111        RatioUnits::new()
112    }
113}
114
115impl From<Vec<RatioUnit>> for RatioUnits {
116    fn from(v: Vec<RatioUnit>) -> Self {
117        RatioUnits(v)
118    }
119}
120
121impl<'a> IntoIterator for &'a RatioUnits {
122    type Item = &'a RatioUnit;
123    type IntoIter = std::slice::Iter<'a, RatioUnit>;
124    fn into_iter(self) -> Self::IntoIter {
125        self.0.iter()
126    }
127}
128
129// -----------------------------------------------------------------------------
130// Literal value types
131// -----------------------------------------------------------------------------
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
134#[serde(rename_all = "lowercase")]
135pub enum BooleanValue {
136    True,
137    False,
138    Yes,
139    No,
140    Accept,
141    Reject,
142}
143
144impl From<BooleanValue> for bool {
145    fn from(value: BooleanValue) -> bool {
146        matches!(
147            value,
148            BooleanValue::True | BooleanValue::Yes | BooleanValue::Accept
149        )
150    }
151}
152
153impl From<&BooleanValue> for bool {
154    fn from(value: &BooleanValue) -> bool {
155        (*value).into() // Copy makes this ok
156    }
157}
158
159impl From<bool> for BooleanValue {
160    fn from(value: bool) -> BooleanValue {
161        if value {
162            BooleanValue::True
163        } else {
164            BooleanValue::False
165        }
166    }
167}
168
169impl std::ops::Not for BooleanValue {
170    type Output = BooleanValue;
171
172    fn not(self) -> Self::Output {
173        if self.into() {
174            BooleanValue::False
175        } else {
176            BooleanValue::True
177        }
178    }
179}
180
181impl std::ops::Not for &BooleanValue {
182    type Output = BooleanValue;
183
184    fn not(self) -> Self::Output {
185        if (*self).into() {
186            BooleanValue::False
187        } else {
188            BooleanValue::True
189        }
190    }
191}
192
193impl std::str::FromStr for BooleanValue {
194    type Err = String;
195
196    fn from_str(s: &str) -> Result<Self, Self::Err> {
197        match s.trim().to_lowercase().as_str() {
198            "true" => Ok(BooleanValue::True),
199            "false" => Ok(BooleanValue::False),
200            "yes" => Ok(BooleanValue::Yes),
201            "no" => Ok(BooleanValue::No),
202            "accept" => Ok(BooleanValue::Accept),
203            "reject" => Ok(BooleanValue::Reject),
204            _ => Err(format!("Invalid boolean: '{}'", s)),
205        }
206    }
207}
208
209impl BooleanValue {
210    #[must_use]
211    pub fn as_str(&self) -> &'static str {
212        match self {
213            BooleanValue::True => "true",
214            BooleanValue::False => "false",
215            BooleanValue::Yes => "yes",
216            BooleanValue::No => "no",
217            BooleanValue::Accept => "accept",
218            BooleanValue::Reject => "reject",
219        }
220    }
221}
222
223impl fmt::Display for BooleanValue {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        write!(f, "{}", self.as_str())
226    }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Hash)]
230pub enum DurationUnit {
231    Year,
232    Month,
233    Week,
234    Day,
235    Hour,
236    Minute,
237    Second,
238    Millisecond,
239    Microsecond,
240}
241
242impl Serialize for DurationUnit {
243    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
244    where
245        S: serde::Serializer,
246    {
247        serializer.serialize_str(&self.to_string())
248    }
249}
250
251impl<'de> Deserialize<'de> for DurationUnit {
252    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
253    where
254        D: serde::Deserializer<'de>,
255    {
256        let s = String::deserialize(deserializer)?;
257        s.parse().map_err(serde::de::Error::custom)
258    }
259}
260
261impl fmt::Display for DurationUnit {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        let s = match self {
264            DurationUnit::Year => "years",
265            DurationUnit::Month => "months",
266            DurationUnit::Week => "weeks",
267            DurationUnit::Day => "days",
268            DurationUnit::Hour => "hours",
269            DurationUnit::Minute => "minutes",
270            DurationUnit::Second => "seconds",
271            DurationUnit::Millisecond => "milliseconds",
272            DurationUnit::Microsecond => "microseconds",
273        };
274        write!(f, "{}", s)
275    }
276}
277
278impl std::str::FromStr for DurationUnit {
279    type Err = String;
280
281    fn from_str(s: &str) -> Result<Self, Self::Err> {
282        match s.trim().to_lowercase().as_str() {
283            "year" | "years" => Ok(DurationUnit::Year),
284            "month" | "months" => Ok(DurationUnit::Month),
285            "week" | "weeks" => Ok(DurationUnit::Week),
286            "day" | "days" => Ok(DurationUnit::Day),
287            "hour" | "hours" => Ok(DurationUnit::Hour),
288            "minute" | "minutes" => Ok(DurationUnit::Minute),
289            "second" | "seconds" => Ok(DurationUnit::Second),
290            "millisecond" | "milliseconds" => Ok(DurationUnit::Millisecond),
291            "microsecond" | "microseconds" => Ok(DurationUnit::Microsecond),
292            _ => Err(format!("Unknown duration unit: '{}'", s)),
293        }
294    }
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
298pub struct TimezoneValue {
299    pub offset_hours: i8,
300    pub offset_minutes: u8,
301}
302
303impl fmt::Display for TimezoneValue {
304    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305        if self.offset_hours == 0 && self.offset_minutes == 0 {
306            write!(f, "Z")
307        } else {
308            let sign = if self.offset_hours >= 0 { "+" } else { "-" };
309            let hours = self.offset_hours.abs();
310            write!(f, "{}{:02}:{:02}", sign, hours, self.offset_minutes)
311        }
312    }
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)]
316pub struct TimeValue {
317    pub hour: u8,
318    pub minute: u8,
319    pub second: u8,
320    pub timezone: Option<TimezoneValue>,
321}
322
323impl fmt::Display for TimeValue {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)
326    }
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
330pub struct DateTimeValue {
331    pub year: i32,
332    pub month: u32,
333    pub day: u32,
334    pub hour: u32,
335    pub minute: u32,
336    pub second: u32,
337    #[serde(default)]
338    pub microsecond: u32,
339    pub timezone: Option<TimezoneValue>,
340}
341
342impl DateTimeValue {
343    pub fn now() -> Self {
344        let now = chrono::Local::now();
345        let offset_secs = now.offset().local_minus_utc();
346        Self {
347            year: now.year(),
348            month: now.month(),
349            day: now.day(),
350            hour: now.time().hour(),
351            minute: now.time().minute(),
352            second: now.time().second(),
353            microsecond: now.time().nanosecond() / 1000 % 1_000_000,
354            timezone: Some(TimezoneValue {
355                offset_hours: (offset_secs / 3600) as i8,
356                offset_minutes: ((offset_secs.abs() % 3600) / 60) as u8,
357            }),
358        }
359    }
360
361    fn parse_iso_week(s: &str) -> Option<Self> {
362        let parts: Vec<&str> = s.split("-W").collect();
363        if parts.len() != 2 {
364            return None;
365        }
366        let year: i32 = parts[0].parse().ok()?;
367        let week: u32 = parts[1].parse().ok()?;
368        if week == 0 || week > 53 {
369            return None;
370        }
371        let date = chrono::NaiveDate::from_isoywd_opt(year, week, chrono::Weekday::Mon)?;
372        Some(Self {
373            year: date.year(),
374            month: date.month(),
375            day: date.day(),
376            hour: 0,
377            minute: 0,
378            second: 0,
379            microsecond: 0,
380            timezone: None,
381        })
382    }
383}
384
385impl fmt::Display for DateTimeValue {
386    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387        let has_time = self.hour != 0
388            || self.minute != 0
389            || self.second != 0
390            || self.microsecond != 0
391            || self.timezone.is_some();
392        if !has_time {
393            write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
394        } else {
395            write!(
396                f,
397                "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
398                self.year, self.month, self.day, self.hour, self.minute, self.second
399            )?;
400            if self.microsecond != 0 {
401                write!(f, ".{:06}", self.microsecond)?;
402            }
403            if let Some(tz) = &self.timezone {
404                write!(f, "{}", tz)?;
405            }
406            Ok(())
407        }
408    }
409}
410
411/// Literal value data (no type information). Single source of truth in literals.
412#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
413#[serde(rename_all = "snake_case")]
414pub enum Value {
415    Number(Decimal),
416    Scale(Decimal, String),
417    Text(String),
418    Date(DateTimeValue),
419    Time(TimeValue),
420    Boolean(BooleanValue),
421    Duration(Decimal, DurationUnit),
422    Ratio(Decimal, Option<String>),
423}
424
425impl fmt::Display for Value {
426    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427        match self {
428            Value::Number(n) => write!(f, "{}", n),
429            Value::Text(s) => write!(f, "{}", s),
430            Value::Date(dt) => write!(f, "{}", dt),
431            Value::Boolean(b) => write!(f, "{}", b),
432            Value::Time(time) => write!(f, "{}", time),
433            Value::Scale(n, u) => write!(f, "{} {}", n, u),
434            Value::Duration(n, u) => write!(f, "{} {}", n, u),
435            Value::Ratio(n, u) => match u.as_deref() {
436                Some("percent") => {
437                    let display_value = *n * Decimal::from(100);
438                    let norm = display_value.normalize();
439                    let s = if norm.fract().is_zero() {
440                        norm.trunc().to_string()
441                    } else {
442                        norm.to_string()
443                    };
444                    write!(f, "{}%", s)
445                }
446                Some("permille") => {
447                    let display_value = *n * Decimal::from(1000);
448                    let norm = display_value.normalize();
449                    let s = if norm.fract().is_zero() {
450                        norm.trunc().to_string()
451                    } else {
452                        norm.to_string()
453                    };
454                    write!(f, "{}%%", s)
455                }
456                Some(unit) => {
457                    let norm = n.normalize();
458                    let s = if norm.fract().is_zero() {
459                        norm.trunc().to_string()
460                    } else {
461                        norm.to_string()
462                    };
463                    write!(f, "{} {}", s, unit)
464                }
465                None => {
466                    let norm = n.normalize();
467                    let s = if norm.fract().is_zero() {
468                        norm.trunc().to_string()
469                    } else {
470                        norm.to_string()
471                    };
472                    write!(f, "{}", s)
473                }
474            },
475        }
476    }
477}
478
479// -----------------------------------------------------------------------------
480// FromStr (single source of truth per type)
481// -----------------------------------------------------------------------------
482
483impl std::str::FromStr for DateTimeValue {
484    type Err = String;
485
486    fn from_str(s: &str) -> Result<Self, Self::Err> {
487        if let Ok(dt) = s.parse::<chrono::DateTime<chrono::FixedOffset>>() {
488            let offset = dt.offset().local_minus_utc();
489            let microsecond = dt.nanosecond() / 1000 % 1_000_000;
490            return Ok(DateTimeValue {
491                year: dt.year(),
492                month: dt.month(),
493                day: dt.day(),
494                hour: dt.hour(),
495                minute: dt.minute(),
496                second: dt.second(),
497                microsecond,
498                timezone: Some(TimezoneValue {
499                    offset_hours: (offset / 3600) as i8,
500                    offset_minutes: ((offset.abs() % 3600) / 60) as u8,
501                }),
502            });
503        }
504        if let Ok(dt) = s.parse::<chrono::NaiveDateTime>() {
505            let microsecond = dt.nanosecond() / 1000 % 1_000_000;
506            return Ok(DateTimeValue {
507                year: dt.year(),
508                month: dt.month(),
509                day: dt.day(),
510                hour: dt.hour(),
511                minute: dt.minute(),
512                second: dt.second(),
513                microsecond,
514                timezone: None,
515            });
516        }
517        if let Ok(d) = s.parse::<chrono::NaiveDate>() {
518            return Ok(DateTimeValue {
519                year: d.year(),
520                month: d.month(),
521                day: d.day(),
522                hour: 0,
523                minute: 0,
524                second: 0,
525                microsecond: 0,
526                timezone: None,
527            });
528        }
529        if let Some(week_val) = Self::parse_iso_week(s) {
530            return Ok(week_val);
531        }
532        if let Ok(ym) = chrono::NaiveDate::parse_from_str(&format!("{}-01", s), "%Y-%m-%d") {
533            return Ok(Self {
534                year: ym.year(),
535                month: ym.month(),
536                day: 1,
537                hour: 0,
538                minute: 0,
539                second: 0,
540                microsecond: 0,
541                timezone: None,
542            });
543        }
544        if let Ok(year) = s.parse::<i32>() {
545            if (1..=9999).contains(&year) {
546                return Ok(Self {
547                    year,
548                    month: 1,
549                    day: 1,
550                    hour: 0,
551                    minute: 0,
552                    second: 0,
553                    microsecond: 0,
554                    timezone: None,
555                });
556            }
557        }
558        Err(format!("Invalid date format: '{}'", s))
559    }
560}
561
562impl std::str::FromStr for TimeValue {
563    type Err = String;
564
565    fn from_str(s: &str) -> Result<Self, Self::Err> {
566        if let Ok(t) = s.parse::<chrono::DateTime<chrono::FixedOffset>>() {
567            let offset = t.offset().local_minus_utc();
568            return Ok(TimeValue {
569                hour: t.hour() as u8,
570                minute: t.minute() as u8,
571                second: t.second() as u8,
572                timezone: Some(TimezoneValue {
573                    offset_hours: (offset / 3600) as i8,
574                    offset_minutes: ((offset.abs() % 3600) / 60) as u8,
575                }),
576            });
577        }
578        if let Ok(t) = s.parse::<chrono::NaiveTime>() {
579            return Ok(TimeValue {
580                hour: t.hour() as u8,
581                minute: t.minute() as u8,
582                second: t.second() as u8,
583                timezone: None,
584            });
585        }
586        Err(format!("Invalid time format: '{}'", s))
587    }
588}
589
590/// Number literal with Lemma rules (strip _ and ,; MAX_NUMBER_DIGITS).
591pub(crate) struct NumberLiteral(pub Decimal);
592
593impl std::str::FromStr for NumberLiteral {
594    type Err = String;
595
596    fn from_str(s: &str) -> Result<Self, Self::Err> {
597        let clean = s.trim().replace(['_', ','], "");
598        let digit_count = clean.chars().filter(|c| c.is_ascii_digit()).count();
599        if digit_count > crate::limits::MAX_NUMBER_DIGITS {
600            return Err(format!(
601                "Number has too many digits (max {})",
602                crate::limits::MAX_NUMBER_DIGITS
603            ));
604        }
605        Decimal::from_str(&clean)
606            .map_err(|_| format!("Invalid number: '{}'", s))
607            .map(NumberLiteral)
608    }
609}
610
611/// Text literal with length limit.
612pub(crate) struct TextLiteral(pub String);
613
614impl std::str::FromStr for TextLiteral {
615    type Err = String;
616
617    fn from_str(s: &str) -> Result<Self, Self::Err> {
618        if s.len() > crate::limits::MAX_TEXT_VALUE_LENGTH {
619            return Err(format!(
620                "Text value exceeds maximum length (max {} characters)",
621                crate::limits::MAX_TEXT_VALUE_LENGTH
622            ));
623        }
624        Ok(TextLiteral(s.to_string()))
625    }
626}
627
628/// Duration magnitude: number + unit (e.g. "10 hours").
629pub(crate) struct DurationLiteral(pub Decimal, pub DurationUnit);
630
631impl std::str::FromStr for DurationLiteral {
632    type Err = String;
633
634    fn from_str(s: &str) -> Result<Self, Self::Err> {
635        let trimmed = s.trim();
636        let mut parts: Vec<&str> = trimmed.split_whitespace().collect();
637        if parts.len() < 2 {
638            return Err(format!(
639                "Invalid duration: '{}'. Expected format: <number> <unit> (e.g. 10 hours, 2 weeks)",
640                s
641            ));
642        }
643        let unit_str = parts.pop().unwrap();
644        let number_str = parts.join(" ");
645        let n = number_str
646            .parse::<NumberLiteral>()
647            .map_err(|_| format!("Invalid duration number: '{}'", number_str))?
648            .0;
649        let unit = unit_str.parse()?;
650        Ok(DurationLiteral(n, unit))
651    }
652}
653
654/// Strict scale literal: `<number> <unit-name>` separated by any whitespace run.
655///
656/// Does NOT accept ratio sigils (`%`, `%%`) — those are a `Ratio` concern. See
657/// [`RatioLiteral`] for runtime ratio input parsing. Trailing tokens after the
658/// unit are rejected (no silent truncation).
659pub(crate) struct NumberWithUnit(pub Decimal, pub String);
660
661impl std::str::FromStr for NumberWithUnit {
662    type Err = String;
663
664    fn from_str(s: &str) -> Result<Self, Self::Err> {
665        let trimmed = s.trim();
666        if trimmed.is_empty() {
667            return Err(
668                "Scale value cannot be empty. Use a number followed by a unit (e.g. '10 eur')."
669                    .to_string(),
670            );
671        }
672
673        let mut parts = trimmed.split_whitespace();
674        let number_part = parts
675            .next()
676            .expect("split_whitespace yields >=1 token after non-empty guard");
677        let unit_part = parts.next().ok_or_else(|| {
678            format!(
679                "Scale value must include a unit (e.g. '{} eur').",
680                number_part
681            )
682        })?;
683        if parts.next().is_some() {
684            return Err(format!(
685                "Invalid scale value: '{}'. Expected exactly '<number> <unit>', got extra tokens.",
686                s
687            ));
688        }
689        let n = number_part
690            .parse::<NumberLiteral>()
691            .map_err(|_| format!("Invalid scale: '{}'", s))?
692            .0;
693        Ok(NumberWithUnit(n, unit_part.to_string()))
694    }
695}
696
697/// Strict ratio runtime literal.
698///
699/// Grammar (all inputs trimmed first):
700/// - `<number>`                      → `Bare(n)`
701/// - `<number>%`  (glued, no inner whitespace) → `Percent(n / 100)`
702/// - `<number>%%` (glued, no inner whitespace) → `Permille(n / 1000)`
703/// - `<number> <unit-name>`          → `Named { value: n, unit: <unit-name> }`
704///
705/// `<number>` is parsed by [`NumberLiteral`] (signed, allows `_`/`,` separators).
706/// Whitespace between the number and a keyword unit may be any non-empty run
707/// (`"50 percent"`, `"50    percent"`, `"50\tpercent"` are all accepted).
708///
709/// The sigils `%` / `%%` are language-level constants meaning "divide by 100 / 1000"
710/// and unconditionally produce the canonical unit names `"percent"` / `"permille"`.
711/// They are NOT accepted as standalone unit-position tokens (i.e. `"5 %"` is rejected).
712///
713/// Signedness is intentionally not constrained at this layer: bounds are the
714/// type-system's job (`-> minimum 0%`), and the evaluator can produce signed
715/// ratios from non-negative inputs (e.g. `this_year - last_year` on `percent`).
716/// The parser must accept everything the evaluator can emit (round-trip symmetry).
717///
718/// `Named` carries the raw unit name; the caller in `parse_number_unit::Ratio`
719/// resolves it against the type's [`RatioUnits`] table (covering built-in
720/// `percent`/`permille` and any user-defined units like `basis_points`).
721#[derive(Debug, Clone, PartialEq, Eq)]
722pub(crate) enum RatioLiteral {
723    Bare(Decimal),
724    Percent(Decimal),
725    Permille(Decimal),
726    Named { value: Decimal, unit: String },
727}
728
729impl std::str::FromStr for RatioLiteral {
730    type Err = String;
731
732    fn from_str(s: &str) -> Result<Self, Self::Err> {
733        let trimmed = s.trim();
734        if trimmed.is_empty() {
735            return Err(
736                "Ratio value cannot be empty. Use a number, optionally followed by '%', '%%', or a unit name (e.g. '0.5', '50%', '25%%', '50 percent')."
737                    .to_string(),
738            );
739        }
740
741        let mut parts = trimmed.split_whitespace();
742        let first = parts
743            .next()
744            .expect("split_whitespace yields >=1 token after non-empty guard");
745        let second = parts.next();
746        if parts.next().is_some() {
747            return Err(format!(
748                "Invalid ratio value: '{}'. Expected '<number>', '<number>%', '<number>%%', or '<number> <unit>'.",
749                s
750            ));
751        }
752
753        match second {
754            // 1-token forms: bare number, or sigil-suffixed number.
755            None => {
756                if let Some(rest) = first.strip_suffix("%%") {
757                    if rest.is_empty() {
758                        return Err(format!(
759                            "Invalid ratio value: '{}'. '%%' must follow a number (e.g. '25%%').",
760                            s
761                        ));
762                    }
763                    let n = rest
764                        .parse::<NumberLiteral>()
765                        .map_err(|_| {
766                            format!(
767                            "Invalid ratio value: '{}'. '{}' is not a valid number before '%%'.",
768                            s, rest
769                        )
770                        })?
771                        .0;
772                    return Ok(RatioLiteral::Permille(n / Decimal::from(1000)));
773                }
774                if let Some(rest) = first.strip_suffix('%') {
775                    if rest.is_empty() {
776                        return Err(format!(
777                            "Invalid ratio value: '{}'. '%' must follow a number (e.g. '50%').",
778                            s
779                        ));
780                    }
781                    let n = rest
782                        .parse::<NumberLiteral>()
783                        .map_err(|_| {
784                            format!(
785                                "Invalid ratio value: '{}'. '{}' is not a valid number before '%'.",
786                                s, rest
787                            )
788                        })?
789                        .0;
790                    return Ok(RatioLiteral::Percent(n / Decimal::from(100)));
791                }
792                let n = first.parse::<NumberLiteral>().map_err(|_| {
793                    format!(
794                        "Invalid ratio value: '{}'. Must be a number, '<n>%', '<n>%%', '<n> percent', '<n> permille', or '<n> <unit>'.",
795                        s
796                    )
797                })?.0;
798                Ok(RatioLiteral::Bare(n))
799            }
800            // 2-token form: <number> <unit-name>. Sigils are not accepted as unit-position tokens.
801            Some(unit) => {
802                if unit == "%" || unit == "%%" {
803                    return Err(format!(
804                        "Invalid ratio value: '{}'. '{}' must be glued to the number (e.g. '{}{}'), not separated by whitespace.",
805                        s, unit, first, unit
806                    ));
807                }
808                let n = first
809                    .parse::<NumberLiteral>()
810                    .map_err(|_| {
811                        format!(
812                            "Invalid ratio value: '{}'. '{}' is not a valid number.",
813                            s, first
814                        )
815                    })?
816                    .0;
817                Ok(RatioLiteral::Named {
818                    value: n,
819                    unit: unit.to_string(),
820                })
821            }
822        }
823    }
824}