Skip to main content

paft_domain/
period.rs

1//! Financial period primitives.
2//!
3//! Provides separate reporting/fiscal period labels and calendar period buckets.
4
5use serde::{
6    Deserialize, Deserializer, Serialize, Serializer,
7    de::{Error as DeError, Visitor},
8};
9use std::borrow::Cow;
10use std::fmt;
11
12use crate::error::DomainError;
13use chrono::{Datelike, NaiveDate};
14use paft_utils::Canonical;
15
16/// Valid year component for structured financial periods.
17///
18/// `ReportingPeriod` accepts calendar-style four-digit years in `0..=9999`. The lower
19/// bound preserves the crate's existing parser behavior for tokens like
20/// `0000`; the upper bound keeps structured period display/serde canonical as
21/// exactly four year digits.
22///
23/// Standalone serde emits the same four-digit canonical string as
24/// [`std::fmt::Display`].
25/// Deserialization also accepts integer years for compatibility and normalizes
26/// them on the next serialization.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
28pub struct PeriodYear(u16);
29
30impl PeriodYear {
31    /// Smallest valid structured period year.
32    pub const MIN: u16 = 0;
33
34    /// Largest valid structured period year.
35    pub const MAX: u16 = 9999;
36
37    /// Builds a validated period year.
38    ///
39    /// # Errors
40    /// Returns [`DomainError::InvalidPeriodYear`] when `year` is outside
41    /// `0..=9999`.
42    pub fn new(year: i32) -> Result<Self, DomainError> {
43        let Ok(year_u16) = u16::try_from(year) else {
44            return Err(DomainError::InvalidPeriodYear { year });
45        };
46
47        if year_u16 <= Self::MAX {
48            Ok(Self(year_u16))
49        } else {
50            Err(DomainError::InvalidPeriodYear { year })
51        }
52    }
53
54    /// Returns the year as an `i32`, matching [`chrono::Datelike::year`].
55    #[must_use]
56    pub const fn get(self) -> i32 {
57        self.0 as i32
58    }
59
60    /// Returns the year as the compact unsigned storage type.
61    #[must_use]
62    pub const fn as_u16(self) -> u16 {
63        self.0
64    }
65}
66
67impl TryFrom<i32> for PeriodYear {
68    type Error = DomainError;
69
70    fn try_from(year: i32) -> Result<Self, Self::Error> {
71        Self::new(year)
72    }
73}
74
75impl TryFrom<u16> for PeriodYear {
76    type Error = DomainError;
77
78    fn try_from(year: u16) -> Result<Self, Self::Error> {
79        Self::new(i32::from(year))
80    }
81}
82
83impl From<PeriodYear> for i32 {
84    fn from(year: PeriodYear) -> Self {
85        year.get()
86    }
87}
88
89impl From<PeriodYear> for u16 {
90    fn from(year: PeriodYear) -> Self {
91        year.as_u16()
92    }
93}
94
95impl fmt::Display for PeriodYear {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        write!(f, "{:04}", self.0)
98    }
99}
100
101impl Serialize for PeriodYear {
102    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
103    where
104        S: Serializer,
105    {
106        serializer.serialize_str(&self.to_string())
107    }
108}
109
110impl<'de> Deserialize<'de> for PeriodYear {
111    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
112    where
113        D: Deserializer<'de>,
114    {
115        deserializer.deserialize_any(PeriodYearVisitor)
116    }
117}
118
119struct PeriodYearVisitor;
120
121impl Visitor<'_> for PeriodYearVisitor {
122    type Value = PeriodYear;
123
124    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
125        formatter.write_str("a canonical four-digit period year string or integer in 0..=9999")
126    }
127
128    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
129    where
130        E: DeError,
131    {
132        parse_period_year_code(value).map_err(DeError::custom)
133    }
134
135    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
136    where
137        E: DeError,
138    {
139        period_year_from_i64(value).map_err(DeError::custom)
140    }
141
142    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
143    where
144        E: DeError,
145    {
146        period_year_from_u64(value).map_err(DeError::custom)
147    }
148}
149
150fn parse_period_year_code(value: &str) -> Result<PeriodYear, DomainError> {
151    let bytes = value.as_bytes();
152    if bytes.len() != 4 || !bytes.iter().all(u8::is_ascii_digit) {
153        return Err(DomainError::InvalidPeriodFormat {
154            format: value.to_string(),
155        });
156    }
157
158    let year = i32::from(bytes[0] - b'0') * 1_000
159        + i32::from(bytes[1] - b'0') * 100
160        + i32::from(bytes[2] - b'0') * 10
161        + i32::from(bytes[3] - b'0');
162
163    PeriodYear::new(year)
164}
165
166fn period_year_from_i64(value: i64) -> Result<PeriodYear, DomainError> {
167    let Ok(year) = i32::try_from(value) else {
168        return Err(DomainError::InvalidPeriodFormat {
169            format: value.to_string(),
170        });
171    };
172
173    PeriodYear::new(year)
174}
175
176fn period_year_from_u64(value: u64) -> Result<PeriodYear, DomainError> {
177    let Ok(year) = i32::try_from(value) else {
178        return Err(DomainError::InvalidPeriodFormat {
179            format: value.to_string(),
180        });
181    };
182
183    PeriodYear::new(year)
184}
185
186fn parse_period_date_code(value: &str) -> Result<PeriodDate, DomainError> {
187    let invalid = || DomainError::InvalidPeriodFormat {
188        format: value.to_string(),
189    };
190
191    let bytes = value.as_bytes();
192    if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
193        return Err(invalid());
194    }
195
196    let Some(year) = read_4_digits(bytes, 0) else {
197        return Err(invalid());
198    };
199
200    if !bytes[5..7].iter().all(u8::is_ascii_digit) || !bytes[8..10].iter().all(u8::is_ascii_digit) {
201        return Err(invalid());
202    }
203
204    let month = u32::from(bytes[5] - b'0') * 10 + u32::from(bytes[6] - b'0');
205    let day = u32::from(bytes[8] - b'0') * 10 + u32::from(bytes[9] - b'0');
206    let date = NaiveDate::from_ymd_opt(year, month, day).ok_or_else(invalid)?;
207    PeriodDate::new(date)
208}
209
210fn parse_quarter_of_year_code(value: &str) -> Result<QuarterOfYear, DomainError> {
211    let bytes = value.as_bytes();
212    if bytes.len() != 1 || !bytes[0].is_ascii_digit() {
213        return Err(DomainError::InvalidPeriodFormat {
214            format: value.to_string(),
215        });
216    }
217
218    QuarterOfYear::new(bytes[0] - b'0')
219}
220
221fn quarter_of_year_from_i64(value: i64) -> Result<QuarterOfYear, DomainError> {
222    let Ok(quarter) = u8::try_from(value) else {
223        return Err(DomainError::InvalidPeriodFormat {
224            format: value.to_string(),
225        });
226    };
227
228    QuarterOfYear::new(quarter)
229}
230
231fn quarter_of_year_from_u64(value: u64) -> Result<QuarterOfYear, DomainError> {
232    let Ok(quarter) = u8::try_from(value) else {
233        return Err(DomainError::InvalidPeriodFormat {
234            format: value.to_string(),
235        });
236    };
237
238    QuarterOfYear::new(quarter)
239}
240
241/// Valid date component for structured financial periods.
242///
243/// The wrapped [`NaiveDate`] always has a year in `0..=9999`, matching
244/// [`PeriodYear`] and the four-digit canonical `YYYY-MM-DD` period format.
245///
246/// Standalone serde emits the same canonical `YYYY-MM-DD` string as
247/// [`std::fmt::Display`] and deserializes that canonical form.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
249pub struct PeriodDate(NaiveDate);
250
251impl PeriodDate {
252    /// Builds a validated period date.
253    ///
254    /// # Errors
255    /// Returns [`DomainError::InvalidPeriodYear`] when the date's year is
256    /// outside `0..=9999`.
257    pub fn new(date: NaiveDate) -> Result<Self, DomainError> {
258        PeriodYear::new(date.year())?;
259        Ok(Self(date))
260    }
261
262    /// Returns the wrapped date.
263    #[must_use]
264    pub const fn get(self) -> NaiveDate {
265        self.0
266    }
267}
268
269impl TryFrom<NaiveDate> for PeriodDate {
270    type Error = DomainError;
271
272    fn try_from(date: NaiveDate) -> Result<Self, Self::Error> {
273        Self::new(date)
274    }
275}
276
277impl From<PeriodDate> for NaiveDate {
278    fn from(date: PeriodDate) -> Self {
279        date.get()
280    }
281}
282
283impl fmt::Display for PeriodDate {
284    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285        write!(f, "{}", self.0.format("%Y-%m-%d"))
286    }
287}
288
289impl Serialize for PeriodDate {
290    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
291    where
292        S: Serializer,
293    {
294        serializer.serialize_str(&self.to_string())
295    }
296}
297
298impl<'de> Deserialize<'de> for PeriodDate {
299    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
300    where
301        D: Deserializer<'de>,
302    {
303        let raw = String::deserialize(deserializer)?;
304        parse_period_date_code(&raw).map_err(DeError::custom)
305    }
306}
307
308/// Valid quarter-of-year component for structured financial periods.
309///
310/// Standalone serde emits the same canonical string as [`std::fmt::Display`].
311/// Deserialization also accepts integer quarters for compatibility and
312/// normalizes them on the next serialization.
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
314pub struct QuarterOfYear(u8);
315
316impl QuarterOfYear {
317    /// First quarter.
318    pub const Q1: Self = Self(1);
319
320    /// Second quarter.
321    pub const Q2: Self = Self(2);
322
323    /// Third quarter.
324    pub const Q3: Self = Self(3);
325
326    /// Fourth quarter.
327    pub const Q4: Self = Self(4);
328
329    /// Smallest valid quarter number.
330    pub const MIN: u8 = 1;
331
332    /// Largest valid quarter number.
333    pub const MAX: u8 = 4;
334
335    /// Builds a validated quarter-of-year.
336    ///
337    /// # Errors
338    /// Returns [`DomainError::InvalidPeriodQuarter`] when `quarter` is outside
339    /// `1..=4`.
340    pub const fn new(quarter: u8) -> Result<Self, DomainError> {
341        if quarter >= Self::MIN && quarter <= Self::MAX {
342            Ok(Self(quarter))
343        } else {
344            Err(DomainError::InvalidPeriodQuarter { quarter })
345        }
346    }
347
348    /// Returns the quarter number.
349    #[must_use]
350    pub const fn get(self) -> u8 {
351        self.0
352    }
353}
354
355impl TryFrom<u8> for QuarterOfYear {
356    type Error = DomainError;
357
358    fn try_from(quarter: u8) -> Result<Self, Self::Error> {
359        Self::new(quarter)
360    }
361}
362
363impl From<QuarterOfYear> for u8 {
364    fn from(quarter: QuarterOfYear) -> Self {
365        quarter.get()
366    }
367}
368
369impl fmt::Display for QuarterOfYear {
370    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
371        self.0.fmt(f)
372    }
373}
374
375impl Serialize for QuarterOfYear {
376    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
377    where
378        S: Serializer,
379    {
380        serializer.serialize_str(&self.to_string())
381    }
382}
383
384impl<'de> Deserialize<'de> for QuarterOfYear {
385    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
386    where
387        D: Deserializer<'de>,
388    {
389        deserializer.deserialize_any(QuarterOfYearVisitor)
390    }
391}
392
393struct QuarterOfYearVisitor;
394
395impl Visitor<'_> for QuarterOfYearVisitor {
396    type Value = QuarterOfYear;
397
398    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
399        formatter.write_str("a canonical quarter string or integer in 1..=4")
400    }
401
402    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
403    where
404        E: DeError,
405    {
406        parse_quarter_of_year_code(value).map_err(DeError::custom)
407    }
408
409    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
410    where
411        E: DeError,
412    {
413        quarter_of_year_from_i64(value).map_err(DeError::custom)
414    }
415
416    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
417    where
418        E: DeError,
419    {
420        quarter_of_year_from_u64(value).map_err(DeError::custom)
421    }
422}
423
424paft_core::other_string_code_type!(
425    /// Provider-specific period token that is not modeled by [`ReportingPeriod`].
426    pub struct OtherPeriod for ReportingPeriod;
427    type Error = DomainError;
428    parse(input) => input.parse::<ReportingPeriod>();
429    invalid(input) => DomainError::InvalidPeriodFormat {
430        format: input.to_string(),
431    };
432);
433
434/// Reporting or fiscal period label with structured variants and extensible fallback.
435///
436/// `ReportingPeriod` models labels reported by issuers, analysts, or providers:
437/// `2023Q4`, `FY2023`, `2023-12-31`, and provider-specific ranges are labels,
438/// not calendar boundary claims. A fiscal `2023Q4` may not overlap calendar Q4.
439/// Use [`CalendarPeriod`] when you need date boundary helpers.
440///
441/// Canonical/serde rules:
442/// - Emission uses a single canonical form per variant (UPPERCASE ASCII where applicable)
443/// - Parser accepts a superset of tokens (aliases, case-insensitive where appropriate)
444/// - `Other(s)` serializes to its canonical `code()` string (no escape prefix)
445/// - `Display` output matches the canonical form for structured variants and the raw `s` for `Other(s)`
446/// - Serde round-trips preserve identity for canonical variants; unknown tokens normalize to `Other(UPPERCASE)`
447///
448/// Canonical outputs:
449/// - Quarters: `YYYYQ#` (e.g., `2023Q4`)
450/// - Years: `YYYY` (e.g., `2023`)
451/// - Dates: `YYYY-MM-DD` (ISO 8601)
452/// - `Other` stores and emits `canonicalize`-style tokens
453///
454/// `Display` and serde always emit the canonical forms listed above. The parser
455/// accepts common provider variants (e.g., `FY2023`, `2023-Q4`, `12/31/2023`) and
456/// normalizes to the single canonical emission for round-trip stability.
457///
458/// `ReportingPeriod` intentionally does not implement `Ord` or date-boundary
459/// helpers: cross-granularity ordering needs caller-chosen semantics (fiscal
460/// calendar, exact date, provider-specific `Other`, etc.).
461#[derive(Debug, Clone, PartialEq, Eq, Hash)]
462#[non_exhaustive]
463pub enum ReportingPeriod {
464    /// Quarterly period with year and quarter number
465    Quarter {
466        /// The year of the quarter
467        year: PeriodYear,
468        /// The quarter number (1-4)
469        quarter: QuarterOfYear,
470    },
471    /// Annual period with year
472    Year {
473        /// The year of the annual period
474        year: PeriodYear,
475    },
476    /// Specific date
477    Date(
478        /// Validated calendar date
479        PeriodDate,
480    ),
481    /// Unknown or provider-specific period format
482    Other(OtherPeriod),
483}
484
485impl ReportingPeriod {
486    /// Builds a validated quarterly period.
487    ///
488    /// # Errors
489    /// Returns [`DomainError::InvalidPeriodYear`] or
490    /// [`DomainError::InvalidPeriodQuarter`] when either component is outside
491    /// its accepted range.
492    pub fn quarterly(year: i32, quarter: u8) -> Result<Self, DomainError> {
493        Ok(Self::Quarter {
494            year: PeriodYear::new(year)?,
495            quarter: QuarterOfYear::new(quarter)?,
496        })
497    }
498
499    /// Builds a validated annual period.
500    ///
501    /// # Errors
502    /// Returns [`DomainError::InvalidPeriodYear`] when `year` is outside
503    /// `0..=9999`.
504    pub fn annual(year: i32) -> Result<Self, DomainError> {
505        Ok(Self::Year {
506            year: PeriodYear::new(year)?,
507        })
508    }
509
510    /// Builds a validated date period.
511    ///
512    /// # Errors
513    /// Returns [`DomainError::InvalidPeriodYear`] when `date.year()` is
514    /// outside `0..=9999`.
515    pub fn date(date: NaiveDate) -> Result<Self, DomainError> {
516        Ok(Self::Date(PeriodDate::new(date)?))
517    }
518
519    /// Builds an unknown period token, rejecting tokens modeled by [`ReportingPeriod`].
520    ///
521    /// # Errors
522    ///
523    /// Returns an error if `input` is empty, cannot be canonicalized, parses to
524    /// a modeled [`ReportingPeriod`] variant, or matches a supported structured
525    /// period shape with invalid components.
526    ///
527    /// Partial modeled-looking provider labels that do not match a supported
528    /// structured parser, such as `FY`, may still be accepted as
529    /// [`ReportingPeriod::Other`].
530    pub fn other(input: &str) -> Result<Self, DomainError> {
531        OtherPeriod::new(input).map(Self::Other)
532    }
533
534    /// Returns the canonical display/serde code for this period.
535    #[must_use]
536    pub fn code(&self) -> Cow<'_, str> {
537        match self {
538            Self::Quarter { year, quarter } => Cow::Owned(format!("{year}Q{quarter}")),
539            Self::Year { year } => Cow::Owned(year.to_string()),
540            Self::Date(date) => Cow::Owned(date.to_string()),
541            Self::Other(s) => Cow::Borrowed(s.as_ref()),
542        }
543    }
544    /// Returns the year for this period, if applicable
545    #[must_use]
546    pub const fn year(&self) -> Option<i32> {
547        match self {
548            Self::Quarter { year, .. } | Self::Year { year } => Some(year.get()),
549            _ => None,
550        }
551    }
552
553    /// Returns the validated year component for this period, if applicable.
554    #[must_use]
555    pub const fn period_year(&self) -> Option<PeriodYear> {
556        match self {
557            Self::Quarter { year, .. } | Self::Year { year } => Some(*year),
558            _ => None,
559        }
560    }
561
562    /// Returns the quarter number for quarterly periods
563    #[must_use]
564    pub const fn quarter(&self) -> Option<u8> {
565        match self {
566            Self::Quarter { quarter, .. } => Some(quarter.get()),
567            _ => None,
568        }
569    }
570
571    /// Returns the validated quarter component for quarterly periods.
572    #[must_use]
573    pub const fn quarter_of_year(&self) -> Option<QuarterOfYear> {
574        match self {
575            Self::Quarter { quarter, .. } => Some(*quarter),
576            _ => None,
577        }
578    }
579
580    /// Returns true if this is a quarterly period
581    #[must_use]
582    pub const fn is_quarterly(&self) -> bool {
583        matches!(self, Self::Quarter { .. })
584    }
585
586    /// Returns true if this is an annual period
587    #[must_use]
588    pub const fn is_annual(&self) -> bool {
589        matches!(self, Self::Year { .. })
590    }
591
592    /// Returns true if this is a specific date period
593    #[must_use]
594    pub const fn is_date(&self) -> bool {
595        matches!(self, Self::Date(_))
596    }
597}
598
599/// Calendar period bucket with date-boundary helpers.
600///
601/// `CalendarPeriod` is closed over actual calendar years, quarters, and dates.
602/// It intentionally has no provider-specific `Other` variant and rejects fiscal
603/// aliases such as `FY2023`; use [`ReportingPeriod`] for fiscal/provider labels.
604#[derive(Debug, Clone, PartialEq, Eq, Hash)]
605#[non_exhaustive]
606pub enum CalendarPeriod {
607    /// Calendar quarter with year and quarter number.
608    Quarter {
609        /// The calendar year of the quarter.
610        year: PeriodYear,
611        /// The quarter number (1-4).
612        quarter: QuarterOfYear,
613    },
614    /// Calendar year.
615    Year {
616        /// The calendar year.
617        year: PeriodYear,
618    },
619    /// Specific calendar date.
620    Date(
621        /// Validated calendar date.
622        PeriodDate,
623    ),
624}
625
626impl CalendarPeriod {
627    /// Builds a validated calendar quarter.
628    ///
629    /// # Errors
630    /// Returns [`DomainError::InvalidPeriodYear`] or
631    /// [`DomainError::InvalidPeriodQuarter`] when either component is outside
632    /// its accepted range.
633    pub fn quarterly(year: i32, quarter: u8) -> Result<Self, DomainError> {
634        Ok(Self::Quarter {
635            year: PeriodYear::new(year)?,
636            quarter: QuarterOfYear::new(quarter)?,
637        })
638    }
639
640    /// Builds a validated calendar year.
641    ///
642    /// # Errors
643    /// Returns [`DomainError::InvalidPeriodYear`] when `year` is outside
644    /// `0..=9999`.
645    pub fn annual(year: i32) -> Result<Self, DomainError> {
646        Ok(Self::Year {
647            year: PeriodYear::new(year)?,
648        })
649    }
650
651    /// Builds a validated calendar date.
652    ///
653    /// # Errors
654    /// Returns [`DomainError::InvalidPeriodYear`] when `date.year()` is
655    /// outside `0..=9999`.
656    pub fn date(date: NaiveDate) -> Result<Self, DomainError> {
657        Ok(Self::Date(PeriodDate::new(date)?))
658    }
659
660    /// Returns the canonical display/serde code for this calendar period.
661    #[must_use]
662    pub fn code(&self) -> Cow<'_, str> {
663        match self {
664            Self::Quarter { year, quarter } => Cow::Owned(format!("{year}Q{quarter}")),
665            Self::Year { year } => Cow::Owned(year.to_string()),
666            Self::Date(date) => Cow::Owned(date.to_string()),
667        }
668    }
669
670    /// Returns the year for this calendar period, if applicable.
671    #[must_use]
672    pub const fn year(&self) -> Option<i32> {
673        match self {
674            Self::Quarter { year, .. } | Self::Year { year } => Some(year.get()),
675            Self::Date(_) => None,
676        }
677    }
678
679    /// Returns the validated year component for this calendar period, if applicable.
680    #[must_use]
681    pub const fn period_year(&self) -> Option<PeriodYear> {
682        match self {
683            Self::Quarter { year, .. } | Self::Year { year } => Some(*year),
684            Self::Date(_) => None,
685        }
686    }
687
688    /// Returns the quarter number for calendar quarters.
689    #[must_use]
690    pub const fn quarter(&self) -> Option<u8> {
691        match self {
692            Self::Quarter { quarter, .. } => Some(quarter.get()),
693            Self::Year { .. } | Self::Date(_) => None,
694        }
695    }
696
697    /// Returns the validated quarter component for calendar quarters.
698    #[must_use]
699    pub const fn quarter_of_year(&self) -> Option<QuarterOfYear> {
700        match self {
701            Self::Quarter { quarter, .. } => Some(*quarter),
702            Self::Year { .. } | Self::Date(_) => None,
703        }
704    }
705
706    /// Returns true if this is a calendar quarter.
707    #[must_use]
708    pub const fn is_quarterly(&self) -> bool {
709        matches!(self, Self::Quarter { .. })
710    }
711
712    /// Returns true if this is a calendar year.
713    #[must_use]
714    pub const fn is_annual(&self) -> bool {
715        matches!(self, Self::Year { .. })
716    }
717
718    /// Returns true if this is a specific calendar date.
719    #[must_use]
720    pub const fn is_date(&self) -> bool {
721        matches!(self, Self::Date(_))
722    }
723
724    /// Returns the next chronological quarter bucket after this calendar period.
725    ///
726    /// - For `Date`, computes the quarter containing the date, then returns the next quarter.
727    /// - For `Quarter`, returns the next quarter (wrapping to Q1 of the next year).
728    /// - For `Year`, returns `Q1` of the next year.
729    #[must_use]
730    pub fn next_quarter(&self) -> Option<Self> {
731        match self {
732            Self::Date(d) => {
733                let (year, quarter) = quarter_for_date(d.get())?;
734                let (year, quarter) = increment_quarter(year, quarter)?;
735                Some(Self::Quarter { year, quarter })
736            }
737            Self::Quarter { year, quarter } => {
738                let (year, quarter) = increment_quarter(*year, *quarter)?;
739                Some(Self::Quarter { year, quarter })
740            }
741            Self::Year { year } => {
742                let next_year = PeriodYear::new(year.get() + 1).ok()?;
743                Some(Self::Quarter {
744                    year: next_year,
745                    quarter: QuarterOfYear::Q1,
746                })
747            }
748        }
749    }
750
751    /// Returns the last calendar date of the year this period belongs to.
752    ///
753    /// - For `Date`, uses the date's year.
754    /// - For `Quarter`, uses the quarter's calendar year.
755    /// - For `Year`, uses that year.
756    #[must_use]
757    pub fn year_end(&self) -> NaiveDate {
758        let y = match self {
759            Self::Date(d) => d.get().year(),
760            Self::Quarter { year, .. } | Self::Year { year } => year.get(),
761        };
762        expect_valid_date(y, 12, 31)
763    }
764
765    /// Returns the first calendar date covered by this period.
766    ///
767    /// - For `Date`, returns the date itself.
768    /// - For `Quarter`, returns the first day of that calendar quarter.
769    /// - For `Year`, returns January 1 of that year.
770    #[must_use]
771    pub const fn start_date(&self) -> NaiveDate {
772        match self {
773            Self::Date(d) => d.get(),
774            Self::Quarter { year, quarter } => {
775                let month = match quarter.get() {
776                    1 => 1,
777                    2 => 4,
778                    3 => 7,
779                    4 => 10,
780                    _ => unreachable!(),
781                };
782                expect_valid_date(year.get(), month, 1)
783            }
784            Self::Year { year } => expect_valid_date(year.get(), 1, 1),
785        }
786    }
787
788    /// Returns the last calendar date covered by this period.
789    ///
790    /// - For `Date`, returns the date itself.
791    /// - For `Quarter`, returns the last day of that calendar quarter.
792    /// - For `Year`, returns December 31 of that year.
793    #[must_use]
794    pub const fn end_date(&self) -> NaiveDate {
795        match self {
796            Self::Date(d) => d.get(),
797            Self::Quarter { year, quarter } => {
798                let (month, day) = match quarter.get() {
799                    1 => (3, 31),
800                    2 => (6, 30),
801                    3 => (9, 30),
802                    4 => (12, 31),
803                    _ => unreachable!(),
804                };
805                expect_valid_date(year.get(), month, day)
806            }
807            Self::Year { year } => expect_valid_date(year.get(), 12, 31),
808        }
809    }
810
811    /// Returns true if this calendar period overlaps `other`.
812    ///
813    /// Calendar periods are closed ranges over dates, so adjacent quarters do
814    /// not overlap, while a year overlaps every quarter and date inside that
815    /// calendar year.
816    #[must_use]
817    pub fn overlaps(&self, other: &Self) -> bool {
818        self.start_date() <= other.end_date() && other.start_date() <= self.end_date()
819    }
820
821    /// Returns true if this calendar period fully contains `other`.
822    ///
823    /// Containment is directional: a year contains its quarters and dates, but
824    /// a quarter or date does not contain the year.
825    #[must_use]
826    pub fn contains(&self, other: &Self) -> bool {
827        self.start_date() <= other.start_date() && self.end_date() >= other.end_date()
828    }
829
830    /// Returns true if both values are the same exact calendar bucket.
831    ///
832    /// Cross-granularity containment is not an exact match: a calendar year
833    /// and one of its quarters overlap, but they are not the same bucket.
834    #[must_use]
835    pub fn is_same_exact_bucket_as(&self, other: &Self) -> bool {
836        self == other
837    }
838}
839
840// Per-format parser results.
841//
842// `Some(Ok(p))` means the input fully matched the format and produced a valid
843// `ReportingPeriod`. `Some(Err(()))` means the input matched the format structurally
844// (i.e., the original regex would have matched) but the captured values were
845// invalid (e.g., `2023Q5`, `2023-13-01`); the caller treats this as
846// `InvalidPeriodFormat`. `None` means the input does not match this format
847// and the caller should try the next one.
848type ReportingPeriodAttempt = Option<Result<ReportingPeriod, ()>>;
849
850#[inline]
851const fn expect_valid_date(year: i32, month: u32, day: u32) -> NaiveDate {
852    let Some(date) = NaiveDate::from_ymd_opt(year, month, day) else {
853        unreachable!();
854    };
855    date
856}
857
858#[inline]
859fn read_4_digits(b: &[u8], start: usize) -> Option<i32> {
860    if start + 4 > b.len() {
861        return None;
862    }
863    let mut v: i32 = 0;
864    for &c in &b[start..start + 4] {
865        if !c.is_ascii_digit() {
866            return None;
867        }
868        v = v * 10 + i32::from(c - b'0');
869    }
870    Some(v)
871}
872
873#[inline]
874fn read_1_or_2_digits(b: &[u8], start: usize) -> Option<(u32, usize)> {
875    let &first = b.get(start)?;
876    if !first.is_ascii_digit() {
877        return None;
878    }
879    let d1 = u32::from(first - b'0');
880    if let Some(&second) = b.get(start + 1)
881        && second.is_ascii_digit()
882    {
883        Some((d1 * 10 + u32::from(second - b'0'), 2))
884    } else {
885        Some((d1, 1))
886    }
887}
888
889#[inline]
890fn date_or_err(year: i32, month: u32, day: u32) -> Result<ReportingPeriod, ()> {
891    NaiveDate::from_ymd_opt(year, month, day)
892        .ok_or(())
893        .and_then(|date| ReportingPeriod::date(date).map_err(|_| ()))
894}
895
896fn calendar_year(s: &str) -> Option<PeriodYear> {
897    let b = s.as_bytes();
898    if b.len() != 4 {
899        return None;
900    }
901    PeriodYear::new(read_4_digits(b, 0)?).ok()
902}
903
904fn quarter_for_date(d: NaiveDate) -> Option<(PeriodYear, QuarterOfYear)> {
905    let year = PeriodYear::new(d.year()).ok()?;
906    let m = d.month();
907    let quarter = match m {
908        1..=3 => QuarterOfYear::Q1,
909        4..=6 => QuarterOfYear::Q2,
910        7..=9 => QuarterOfYear::Q3,
911        _ => QuarterOfYear::Q4,
912    };
913    Some((year, quarter))
914}
915
916fn increment_quarter(
917    year: PeriodYear,
918    quarter: QuarterOfYear,
919) -> Option<(PeriodYear, QuarterOfYear)> {
920    if quarter.get() < QuarterOfYear::MAX {
921        let next_quarter = QuarterOfYear::new(quarter.get() + 1).ok()?;
922        Some((year, next_quarter))
923    } else {
924        let next_year = PeriodYear::new(year.get() + 1).ok()?;
925        Some((next_year, QuarterOfYear::Q1))
926    }
927}
928
929impl ReportingPeriod {
930    /// Parse quarterly period format: "2023Q4", "2023-Q4", "2023 Q4",
931    /// "2023  Q4", "2023\tQ4", "2023 \t Q4".
932    fn parse_quarterly(s: &str) -> ReportingPeriodAttempt {
933        let b = s.as_bytes();
934        // Minimum form is `YYYYQ#` (6 bytes).
935        if b.len() < 6 {
936            return None;
937        }
938
939        let year = PeriodYear::new(read_4_digits(b, 0)?).ok()?;
940        let mut idx = 4;
941
942        // Optional separator between the year and the `Q`:
943        //   - a single `-`, or
944        //   - a run of ASCII whitespace (matches `parse_year`'s "Fiscal "
945        //     handling — `is_ascii_whitespace` covers space, tab, CR, LF and
946        //     form feed but, importantly, no Unicode whitespace).
947        // The two forms are mutually exclusive: we don't mix `-` with spaces.
948        if b[idx] == b'-' {
949            idx += 1;
950        } else {
951            while idx < b.len() && b[idx].is_ascii_whitespace() {
952                idx += 1;
953            }
954        }
955        if idx >= b.len() {
956            return None;
957        }
958
959        // Case-insensitive 'Q'.
960        if b[idx] != b'Q' && b[idx] != b'q' {
961            return None;
962        }
963        idx += 1;
964
965        let q_bytes = b.get(idx..)?;
966        if q_bytes.is_empty() {
967            return None;
968        }
969
970        // Valid quarters are always exactly one digit. Multi-digit runs of
971        // digits structurally match the original `Q\d+` regex but are
972        // out-of-range, so they're a structural-only match (caller turns into
973        // `InvalidPeriodFormat`). A multi-byte tail with any non-digit is
974        // simply not a quarterly token at all.
975        if q_bytes.len() > 1 {
976            return q_bytes.iter().all(u8::is_ascii_digit).then_some(Err(()));
977        }
978
979        let c = q_bytes[0];
980        if !c.is_ascii_digit() {
981            return None;
982        }
983        let quarter = c - b'0';
984        let Ok(quarter) = QuarterOfYear::new(quarter) else {
985            return Some(Err(()));
986        };
987
988        Some(Ok(Self::Quarter { year, quarter }))
989    }
990
991    /// Parse year period format: "2023", "FY2023", "Fiscal 2023".
992    fn parse_year(s: &str) -> Option<Self> {
993        let b = s.as_bytes();
994        let digits_start = match b.len() {
995            4 => 0,
996            6 if b[..2].eq_ignore_ascii_case(b"FY") => 2,
997            n if n >= 11 && b[..6].eq_ignore_ascii_case(b"FISCAL") => {
998                let mut i = 6;
999                while i < n && b[i].is_ascii_whitespace() {
1000                    i += 1;
1001                }
1002                if i == 6 {
1003                    return None;
1004                }
1005                i
1006            }
1007            _ => return None,
1008        };
1009
1010        if b.len() - digits_start != 4 {
1011            return None;
1012        }
1013        let year = PeriodYear::new(read_4_digits(b, digits_start)?).ok()?;
1014        Some(Self::Year { year })
1015    }
1016
1017    /// Parse date period: ISO `YYYY[-/]M[M][-/]D[D]`, US `M[M]/D[D]/YYYY`,
1018    /// or day-first `D[D]-M[M]-YYYY`.
1019    fn parse_date(s: &str) -> ReportingPeriodAttempt {
1020        let b = s.as_bytes();
1021        if !(8..=10).contains(&b.len()) {
1022            return None;
1023        }
1024
1025        // ISO: `YYYY[-/]M[M][-/]D[D]`.
1026        if let Some(year) = read_4_digits(b, 0)
1027            && (b[4] == b'-' || b[4] == b'/')
1028        {
1029            let sep = b[4];
1030            let (month, m_len) = read_1_or_2_digits(b, 5)?;
1031            let after_m = 5 + m_len;
1032            if b.get(after_m).copied() == Some(sep) {
1033                let (day, d_len) = read_1_or_2_digits(b, after_m + 1)?;
1034                if after_m + 1 + d_len == b.len() {
1035                    return Some(date_or_err(year, month, day));
1036                }
1037            }
1038            // Leading `YYYY[-/]` cannot match the US or day-first shapes
1039            // (those need 1-2 digits before the first separator), so a
1040            // partial ISO match means no date format matches.
1041            return None;
1042        }
1043
1044        // US (`/`-separated, year last) and day-first (`-`-separated, year
1045        // last) share a common prefix of 1-2 digits + separator + 1-2 digits
1046        // + same separator + 4-digit year.
1047        let (first, first_len) = read_1_or_2_digits(b, 0)?;
1048        let sep = *b.get(first_len)?;
1049        if sep != b'/' && sep != b'-' {
1050            return None;
1051        }
1052
1053        let (second, second_len) = read_1_or_2_digits(b, first_len + 1)?;
1054        let after_second = first_len + 1 + second_len;
1055        if b.get(after_second).copied() != Some(sep) {
1056            return None;
1057        }
1058
1059        let year_start = after_second + 1;
1060        if b.len() - year_start != 4 {
1061            return None;
1062        }
1063        let year = read_4_digits(b, year_start)?;
1064
1065        let (month, day) = if sep == b'/' {
1066            (first, second)
1067        } else {
1068            (second, first)
1069        };
1070
1071        Some(date_or_err(year, month, day))
1072    }
1073}
1074
1075impl From<ReportingPeriod> for String {
1076    fn from(val: ReportingPeriod) -> Self {
1077        val.code().into_owned()
1078    }
1079}
1080
1081impl fmt::Display for ReportingPeriod {
1082    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1083        f.write_str(&self.code())
1084    }
1085}
1086
1087impl Serialize for ReportingPeriod {
1088    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1089    where
1090        S: Serializer,
1091    {
1092        serializer.serialize_str(&self.code())
1093    }
1094}
1095
1096impl<'de> Deserialize<'de> for ReportingPeriod {
1097    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1098    where
1099        D: Deserializer<'de>,
1100    {
1101        let raw = String::deserialize(deserializer)?;
1102        raw.parse::<Self>().map_err(DeError::custom)
1103    }
1104}
1105
1106impl std::str::FromStr for ReportingPeriod {
1107    type Err = DomainError;
1108
1109    /// Invariant: the canonical form of a `ReportingPeriod::Other` produced here is
1110    /// guaranteed not to parse as any structured variant on a subsequent
1111    /// deserialize. Without this, inputs like `"-2023Q4"` (rejected by the
1112    /// structured parsers because of the leading `-`) would canonicalize to
1113    /// `"2023Q4"` and serialize back to a string that re-parses as
1114    /// `ReportingPeriod::Quarter`, breaking round-trip identity.
1115    ///
1116    /// To maintain the invariant without accepting malformed aliases, we
1117    /// re-run the structured parsers on the canonicalized form before
1118    /// returning `Other`. If any parser recognizes the canonical form, we
1119    /// return `InvalidPeriodFormat`; otherwise a malformed input such as
1120    /// `"-2023Q4"` would silently become `ReportingPeriod::Quarter`.
1121    #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", err))]
1122    fn from_str(s: &str) -> Result<Self, Self::Err> {
1123        let trimmed = s.trim();
1124
1125        if trimmed.is_empty() {
1126            return Err(DomainError::InvalidPeriodFormat {
1127                format: s.to_string(),
1128            });
1129        }
1130
1131        let invalid = || DomainError::InvalidPeriodFormat {
1132            format: s.to_string(),
1133        };
1134
1135        match Self::parse_quarterly(trimmed) {
1136            Some(Ok(period)) => return Ok(period),
1137            Some(Err(())) => return Err(invalid()),
1138            None => {}
1139        }
1140
1141        if let Some(period) = Self::parse_year(trimmed) {
1142            return Ok(period);
1143        }
1144
1145        match Self::parse_date(trimmed) {
1146            Some(Ok(period)) => return Ok(period),
1147            Some(Err(())) => return Err(invalid()),
1148            None => {}
1149        }
1150
1151        let canonical = Canonical::try_new(trimmed).map_err(|_| invalid())?;
1152
1153        // Re-run the structured parsers against the canonical token. Any
1154        // structured match is rejected: supported aliases have already matched
1155        // above, so reaching this point means canonicalization would otherwise
1156        // convert a malformed spelling into a modeled value.
1157        let canonical_str = canonical.as_ref();
1158        if Self::parse_quarterly(canonical_str).is_some() {
1159            return Err(invalid());
1160        }
1161        if Self::parse_year(canonical_str).is_some() {
1162            return Err(invalid());
1163        }
1164        if Self::parse_date(canonical_str).is_some() {
1165            return Err(invalid());
1166        }
1167
1168        Ok(Self::Other(OtherPeriod::from_canonical_unchecked(
1169            canonical,
1170        )))
1171    }
1172}
1173
1174impl TryFrom<String> for ReportingPeriod {
1175    type Error = DomainError;
1176
1177    fn try_from(s: String) -> Result<Self, Self::Error> {
1178        s.as_str().parse()
1179    }
1180}
1181
1182impl TryFrom<ReportingPeriod> for CalendarPeriod {
1183    type Error = DomainError;
1184
1185    fn try_from(period: ReportingPeriod) -> Result<Self, Self::Error> {
1186        match period {
1187            ReportingPeriod::Quarter { year, quarter } => Ok(Self::Quarter { year, quarter }),
1188            ReportingPeriod::Year { year } => Ok(Self::Year { year }),
1189            ReportingPeriod::Date(date) => Ok(Self::Date(date)),
1190            ReportingPeriod::Other(other) => Err(DomainError::InvalidPeriodFormat {
1191                format: other.to_string(),
1192            }),
1193        }
1194    }
1195}
1196
1197impl From<CalendarPeriod> for ReportingPeriod {
1198    fn from(period: CalendarPeriod) -> Self {
1199        match period {
1200            CalendarPeriod::Quarter { year, quarter } => Self::Quarter { year, quarter },
1201            CalendarPeriod::Year { year } => Self::Year { year },
1202            CalendarPeriod::Date(date) => Self::Date(date),
1203        }
1204    }
1205}
1206
1207impl From<CalendarPeriod> for String {
1208    fn from(val: CalendarPeriod) -> Self {
1209        val.code().into_owned()
1210    }
1211}
1212
1213impl fmt::Display for CalendarPeriod {
1214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1215        f.write_str(&self.code())
1216    }
1217}
1218
1219impl Serialize for CalendarPeriod {
1220    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1221    where
1222        S: Serializer,
1223    {
1224        serializer.serialize_str(&self.code())
1225    }
1226}
1227
1228impl<'de> Deserialize<'de> for CalendarPeriod {
1229    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1230    where
1231        D: Deserializer<'de>,
1232    {
1233        let raw = String::deserialize(deserializer)?;
1234        raw.parse::<Self>().map_err(DeError::custom)
1235    }
1236}
1237
1238impl std::str::FromStr for CalendarPeriod {
1239    type Err = DomainError;
1240
1241    /// Parses calendar-only period tokens.
1242    ///
1243    /// Unlike [`ReportingPeriod`], this parser rejects fiscal aliases such as
1244    /// `FY2023` and unknown provider-specific labels.
1245    #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", err))]
1246    fn from_str(s: &str) -> Result<Self, Self::Err> {
1247        let trimmed = s.trim();
1248
1249        if trimmed.is_empty() {
1250            return Err(DomainError::InvalidPeriodFormat {
1251                format: s.to_string(),
1252            });
1253        }
1254
1255        let invalid = || DomainError::InvalidPeriodFormat {
1256            format: s.to_string(),
1257        };
1258
1259        match ReportingPeriod::parse_quarterly(trimmed) {
1260            Some(Ok(ReportingPeriod::Quarter { year, quarter })) => {
1261                return Ok(Self::Quarter { year, quarter });
1262            }
1263            Some(Ok(_)) => unreachable!("quarter parser only emits quarter periods"),
1264            Some(Err(())) => return Err(invalid()),
1265            None => {}
1266        }
1267
1268        if let Some(year) = calendar_year(trimmed) {
1269            return Ok(Self::Year { year });
1270        }
1271
1272        match ReportingPeriod::parse_date(trimmed) {
1273            Some(Ok(ReportingPeriod::Date(date))) => return Ok(Self::Date(date)),
1274            Some(Ok(_)) => unreachable!("date parser only emits date periods"),
1275            Some(Err(())) => return Err(invalid()),
1276            None => {}
1277        }
1278
1279        Err(invalid())
1280    }
1281}
1282
1283impl TryFrom<String> for CalendarPeriod {
1284    type Error = DomainError;
1285
1286    fn try_from(s: String) -> Result<Self, Self::Error> {
1287        s.as_str().parse()
1288    }
1289}