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