Skip to main content

tzif_codec/
tzdist.rs

1use crate::{interop::is_placeholder_type, validate::validate_file, TzdistError, TzifFile};
2
3const APPLICATION_TZIF: &str = "application/tzif";
4const APPLICATION_TZIF_LEAP: &str = "application/tzif-leap";
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum TzifMediaType {
8    Tzif,
9    TzifLeap,
10}
11
12impl TzifMediaType {
13    pub const APPLICATION_TZIF: &'static str = APPLICATION_TZIF;
14    pub const APPLICATION_TZIF_LEAP: &'static str = APPLICATION_TZIF_LEAP;
15
16    #[must_use]
17    pub const fn as_str(self) -> &'static str {
18        match self {
19            Self::Tzif => APPLICATION_TZIF,
20            Self::TzifLeap => APPLICATION_TZIF_LEAP,
21        }
22    }
23}
24
25impl TryFrom<&str> for TzifMediaType {
26    type Error = TzdistError;
27
28    fn try_from(value: &str) -> Result<Self, Self::Error> {
29        match value {
30            APPLICATION_TZIF => Ok(Self::Tzif),
31            APPLICATION_TZIF_LEAP => Ok(Self::TzifLeap),
32            value => Err(TzdistError::UnsupportedMediaType(value.to_string())),
33        }
34    }
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub struct TzdistTruncation {
39    pub start: Option<i64>,
40    pub end: Option<i64>,
41}
42
43impl TzdistTruncation {
44    #[must_use]
45    pub const fn start(start: i64) -> Self {
46        Self {
47            start: Some(start),
48            end: None,
49        }
50    }
51
52    #[must_use]
53    pub const fn end(end: i64) -> Self {
54        Self {
55            start: None,
56            end: Some(end),
57        }
58    }
59
60    #[must_use]
61    pub const fn range(start: i64, end: i64) -> Self {
62        Self {
63            start: Some(start),
64            end: Some(end),
65        }
66    }
67}
68
69/// Validates a TZDIST capability format list for `TZif` media types.
70///
71/// # Errors
72///
73/// Returns an error if `application/tzif-leap` is advertised without also
74/// advertising `application/tzif`.
75pub fn validate_tzdist_capability_formats<'a, I>(formats: I) -> Result<(), TzdistError>
76where
77    I: IntoIterator<Item = &'a str>,
78{
79    let mut has_tzif = false;
80    let mut has_tzif_leap = false;
81    for format in formats {
82        has_tzif |= format == APPLICATION_TZIF;
83        has_tzif_leap |= format == APPLICATION_TZIF_LEAP;
84    }
85    if has_tzif_leap && !has_tzif {
86        return Err(TzdistError::TzifLeapCapabilityRequiresTzif);
87    }
88    Ok(())
89}
90
91impl TzifFile {
92    #[must_use]
93    pub fn has_leap_seconds(&self) -> bool {
94        !self.v1.leap_seconds.is_empty()
95            || self
96                .v2_plus
97                .as_ref()
98                .is_some_and(|block| !block.leap_seconds.is_empty())
99    }
100
101    #[must_use]
102    pub fn suggested_media_type(&self) -> &'static str {
103        if self.has_leap_seconds() {
104            APPLICATION_TZIF_LEAP
105        } else {
106            APPLICATION_TZIF
107        }
108    }
109
110    /// Validates that this file can be served as the requested media type.
111    ///
112    /// # Errors
113    ///
114    /// Returns an error if the file is invalid, or if leap seconds are present in
115    /// an `application/tzif` response.
116    pub fn validate_for_media_type(&self, media_type: TzifMediaType) -> Result<(), TzdistError> {
117        validate_file(self).map_err(TzdistError::InvalidTzif)?;
118        if media_type == TzifMediaType::Tzif && self.has_leap_seconds() {
119            return Err(TzdistError::LeapSecondsNotAllowedForApplicationTzif);
120        }
121        Ok(())
122    }
123
124    /// Validates the structural `TZif` requirements for a TZDIST truncation response.
125    ///
126    /// # Errors
127    ///
128    /// Returns an error if the file is not version 2 or later, lacks required
129    /// transitions, has mismatched truncation boundaries, or does not use the
130    /// required `-00` placeholder types.
131    pub fn validate_tzdist_truncation(
132        &self,
133        truncation: TzdistTruncation,
134    ) -> Result<(), TzdistError> {
135        let block = self
136            .v2_plus
137            .as_ref()
138            .ok_or(TzdistError::TruncationRequiresVersion2Plus)?;
139        if block.transition_times.is_empty() {
140            return Err(TzdistError::TruncationRequiresVersion2PlusTransitions);
141        }
142
143        if let Some(start) = truncation.start {
144            let first = *block
145                .transition_times
146                .first()
147                .ok_or(TzdistError::TruncationRequiresVersion2PlusTransitions)?;
148            if first != start {
149                return Err(TzdistError::StartTruncationTransitionMismatch {
150                    expected: start,
151                    actual: first,
152                });
153            }
154        }
155
156        if let Some(end) = truncation.end {
157            let last = *block
158                .transition_times
159                .last()
160                .ok_or(TzdistError::TruncationRequiresVersion2PlusTransitions)?;
161            if last != end {
162                return Err(TzdistError::EndTruncationTransitionMismatch {
163                    expected: end,
164                    actual: last,
165                });
166            }
167            if self
168                .footer
169                .as_deref()
170                .is_some_and(|footer| !footer.is_empty())
171            {
172                return Err(TzdistError::EndTruncationRequiresEmptyFooter);
173            }
174        }
175
176        validate_file(self).map_err(TzdistError::InvalidTzif)?;
177
178        if let Some(start) = truncation.start {
179            let first = *block
180                .transition_times
181                .first()
182                .ok_or(TzdistError::TruncationRequiresVersion2PlusTransitions)?;
183            if first != start {
184                return Err(TzdistError::StartTruncationTransitionMismatch {
185                    expected: start,
186                    actual: first,
187                });
188            }
189            if !is_placeholder_type(block, 0) {
190                return Err(TzdistError::StartTruncationTypeZeroMustBePlaceholder);
191            }
192        }
193
194        if truncation.end.is_some() {
195            let last_type = usize::from(
196                *block
197                    .transition_types
198                    .last()
199                    .ok_or(TzdistError::TruncationRequiresVersion2PlusTransitions)?,
200            );
201            if !is_placeholder_type(block, last_type) {
202                return Err(TzdistError::EndTruncationLastTypeMustBePlaceholder);
203            }
204        }
205
206        if let (Some(start), Some(end)) = (truncation.start, truncation.end) {
207            if start >= end {
208                return Err(TzdistError::InvalidTruncationRange { start, end });
209            }
210        }
211
212        Ok(())
213    }
214}