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