1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::{
4 footer::footer_uses_tz_string_extension, DataBlock, LocalTimeType, TzifBuildError, TzifFile,
5 Version,
6};
7
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9pub enum VersionPolicy {
10 #[default]
11 Auto,
12 Exact(Version),
13}
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct PosixFooter {
17 standard_designation: String,
18 standard_offset_seconds: i32,
19 daylight: Option<PosixDaylight>,
20}
21
22impl PosixFooter {
23 #[must_use]
24 pub fn fixed(designation: impl Into<String>, offset_seconds: i32) -> Self {
25 Self {
26 standard_designation: designation.into(),
27 standard_offset_seconds: offset_seconds,
28 daylight: None,
29 }
30 }
31
32 #[must_use]
33 #[expect(
34 clippy::too_many_arguments,
35 reason = "a POSIX daylight-saving footer is defined by these six fields"
36 )]
37 pub fn daylight_saving(
38 standard_designation: impl Into<String>,
39 standard_offset_seconds: i32,
40 daylight_designation: impl Into<String>,
41 daylight_offset_seconds: i32,
42 start: PosixTransitionRule,
43 end: PosixTransitionRule,
44 ) -> Self {
45 Self {
46 standard_designation: standard_designation.into(),
47 standard_offset_seconds,
48 daylight: Some(PosixDaylight {
49 designation: daylight_designation.into(),
50 offset_seconds: daylight_offset_seconds,
51 start,
52 end,
53 start_time: PosixTransitionTime::DEFAULT,
54 end_time: PosixTransitionTime::DEFAULT,
55 }),
56 }
57 }
58
59 #[must_use]
60 pub const fn start_time(mut self, time: PosixTransitionTime) -> Self {
61 if let Some(daylight) = &mut self.daylight {
62 daylight.start_time = time;
63 }
64 self
65 }
66
67 #[must_use]
68 pub const fn end_time(mut self, time: PosixTransitionTime) -> Self {
69 if let Some(daylight) = &mut self.daylight {
70 daylight.end_time = time;
71 }
72 self
73 }
74
75 fn to_tz_string(&self, strict_designation: bool) -> Result<String, TzifBuildError> {
76 validate_designation(&self.standard_designation, strict_designation)?;
77 validate_posix_offset(self.standard_offset_seconds)?;
78 let mut value = format!(
79 "{}{}",
80 posix_designation(&self.standard_designation),
81 posix_offset(self.standard_offset_seconds)?
82 );
83 let Some(daylight) = &self.daylight else {
84 return Ok(value);
85 };
86 validate_designation(&daylight.designation, strict_designation)?;
87 validate_utc_offset(daylight.offset_seconds)?;
88 daylight.start.validate()?;
89 daylight.end.validate()?;
90 daylight.start_time.validate()?;
91 daylight.end_time.validate()?;
92
93 value.push_str(&posix_designation(&daylight.designation));
94 if Some(daylight.offset_seconds) != self.standard_offset_seconds.checked_add(3600) {
95 validate_posix_offset(daylight.offset_seconds)?;
96 value.push_str(&posix_offset(daylight.offset_seconds)?);
97 }
98 value.push(',');
99 value.push_str(&daylight.start.to_tz_string());
100 value.push_str(&daylight.start_time.to_tz_suffix());
101 value.push(',');
102 value.push_str(&daylight.end.to_tz_string());
103 value.push_str(&daylight.end_time.to_tz_suffix());
104 Ok(value)
105 }
106
107 fn uses_tz_string_extension(&self) -> bool {
108 self.daylight.as_ref().is_some_and(|daylight| {
109 daylight.start_time.uses_tz_string_extension()
110 || daylight.end_time.uses_tz_string_extension()
111 })
112 }
113}
114
115#[derive(Clone, Debug, PartialEq, Eq)]
116struct PosixDaylight {
117 designation: String,
118 offset_seconds: i32,
119 start: PosixTransitionRule,
120 end: PosixTransitionRule,
121 start_time: PosixTransitionTime,
122 end_time: PosixTransitionTime,
123}
124
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126pub enum PosixTransitionRule {
127 JulianWithoutLeapDay { day: u16 },
128 ZeroBasedDay { day: u16 },
129 MonthWeekday { month: u8, week: u8, weekday: u8 },
130}
131
132impl PosixTransitionRule {
133 #[must_use]
134 pub const fn julian_without_leap_day(day: u16) -> Self {
135 Self::JulianWithoutLeapDay { day }
136 }
137
138 #[must_use]
139 pub const fn zero_based_day(day: u16) -> Self {
140 Self::ZeroBasedDay { day }
141 }
142
143 #[must_use]
144 pub const fn month_weekday(month: u8, week: u8, weekday: u8) -> Self {
145 Self::MonthWeekday {
146 month,
147 week,
148 weekday,
149 }
150 }
151
152 fn validate(self) -> Result<(), TzifBuildError> {
153 match self {
154 Self::JulianWithoutLeapDay { day } if !(1..=365).contains(&day) => {
155 Err(TzifBuildError::InvalidPosixJulianDay { day })
156 }
157 Self::ZeroBasedDay { day } if day > 365 => {
158 Err(TzifBuildError::InvalidPosixZeroBasedDay { day })
159 }
160 Self::MonthWeekday { month, .. } if !(1..=12).contains(&month) => {
161 Err(TzifBuildError::InvalidPosixMonth { month })
162 }
163 Self::MonthWeekday { week, .. } if !(1..=5).contains(&week) => {
164 Err(TzifBuildError::InvalidPosixWeek { week })
165 }
166 Self::MonthWeekday { weekday, .. } if weekday > 6 => {
167 Err(TzifBuildError::InvalidPosixWeekday { weekday })
168 }
169 _ => Ok(()),
170 }
171 }
172
173 fn to_tz_string(self) -> String {
174 match self {
175 Self::JulianWithoutLeapDay { day } => format!("J{day}"),
176 Self::ZeroBasedDay { day } => day.to_string(),
177 Self::MonthWeekday {
178 month,
179 week,
180 weekday,
181 } => format!("M{month}.{week}.{weekday}"),
182 }
183 }
184}
185
186#[derive(Clone, Copy, Debug, PartialEq, Eq)]
187pub struct PosixTransitionTime {
188 seconds: i32,
189}
190
191impl PosixTransitionTime {
192 const DEFAULT: Self = Self { seconds: 2 * 3600 };
193 const MIN_SECONDS: i32 = -167 * 3600;
194 const MAX_SECONDS: i32 = 167 * 3600;
195
196 #[must_use]
197 pub const fn seconds(seconds: i32) -> Self {
198 Self { seconds }
199 }
200
201 #[must_use]
202 pub fn hms(hours: i32, minutes: u8, seconds: u8) -> Self {
203 if minutes > 59 || seconds > 59 {
204 return Self { seconds: i32::MAX };
205 }
206 let sign = if hours < 0 { -1 } else { 1 };
207 let seconds = hours
208 .checked_mul(3600)
209 .and_then(|value| value.checked_add(sign * i32::from(minutes) * 60))
210 .and_then(|value| value.checked_add(sign * i32::from(seconds)))
211 .unwrap_or(i32::MAX);
212 Self { seconds }
213 }
214
215 fn validate(self) -> Result<(), TzifBuildError> {
216 if !(Self::MIN_SECONDS..=Self::MAX_SECONDS).contains(&self.seconds) {
217 return Err(TzifBuildError::InvalidPosixTransitionTime {
218 seconds: self.seconds,
219 });
220 }
221 Ok(())
222 }
223
224 fn to_tz_suffix(self) -> String {
225 if self == Self::DEFAULT {
226 String::new()
227 } else {
228 format!("/{}", posix_time(self.seconds))
229 }
230 }
231
232 const fn uses_tz_string_extension(self) -> bool {
233 self.seconds < 0 || self.seconds / 3600 > 24
234 }
235}
236
237pub struct TzifBuilder;
238
239impl TzifBuilder {
240 #[must_use]
241 pub fn fixed_offset(designation: impl Into<String>, offset_seconds: i32) -> FixedOffsetBuilder {
242 FixedOffsetBuilder {
243 designation: designation.into(),
244 offset_seconds,
245 version_policy: VersionPolicy::Auto,
246 }
247 }
248
249 #[must_use]
250 pub const fn transitions() -> ExplicitTransitionsBuilder {
251 ExplicitTransitionsBuilder::new()
252 }
253}
254
255#[derive(Clone, Debug)]
256pub struct FixedOffsetBuilder {
257 designation: String,
258 offset_seconds: i32,
259 version_policy: VersionPolicy,
260}
261
262impl FixedOffsetBuilder {
263 #[must_use]
264 pub const fn version_policy(mut self, version_policy: VersionPolicy) -> Self {
265 self.version_policy = version_policy;
266 self
267 }
268
269 #[must_use]
270 pub const fn version(mut self, version: Version) -> Self {
271 self.version_policy = VersionPolicy::Exact(version);
272 self
273 }
274
275 pub fn build(self) -> Result<TzifFile, TzifBuildError> {
282 let has_footer = !matches!(self.version_policy, VersionPolicy::Exact(Version::V1));
283 validate_designation(&self.designation, true)?;
284 validate_utc_offset(self.offset_seconds)?;
285 let block = DataBlock::new(
286 vec![LocalTimeType {
287 utc_offset: self.offset_seconds,
288 is_dst: false,
289 designation_index: 0,
290 }],
291 designation_table([self.designation.as_str()]),
292 );
293 let version = resolve_version(self.version_policy, &[], has_footer, false)?;
294 Ok(match version {
295 Version::V1 => TzifFile::v1(block),
296 Version::V2 => TzifFile::v2(
297 block.clone(),
298 block,
299 fixed_offset_footer(&self.designation, self.offset_seconds)?,
300 ),
301 Version::V3 => TzifFile::v3(
302 block.clone(),
303 block,
304 fixed_offset_footer(&self.designation, self.offset_seconds)?,
305 ),
306 Version::V4 => TzifFile::v4(
307 block.clone(),
308 block,
309 fixed_offset_footer(&self.designation, self.offset_seconds)?,
310 ),
311 })
312 }
313}
314
315#[derive(Clone, Debug)]
316pub struct ExplicitTransitionsBuilder {
317 designations: Vec<String>,
318 local_time_types: Vec<PendingLocalTimeType>,
319 transitions: Vec<PendingTransition>,
320 footer: Option<PendingFooter>,
321 version_policy: VersionPolicy,
322}
323
324impl ExplicitTransitionsBuilder {
325 const fn new() -> Self {
326 Self {
327 designations: Vec::new(),
328 local_time_types: Vec::new(),
329 transitions: Vec::new(),
330 footer: None,
331 version_policy: VersionPolicy::Auto,
332 }
333 }
334
335 #[must_use]
336 pub fn designation(mut self, designation: impl Into<String>) -> Self {
337 self.designations.push(designation.into());
338 self
339 }
340
341 #[must_use]
342 pub fn local_time_type(
343 mut self,
344 designation: impl Into<String>,
345 offset_seconds: i32,
346 is_dst: bool,
347 ) -> Self {
348 self.local_time_types.push(PendingLocalTimeType {
349 designation: designation.into(),
350 offset_seconds,
351 is_dst,
352 });
353 self
354 }
355
356 #[must_use]
357 pub fn transition(mut self, timestamp: i64, designation: impl Into<String>) -> Self {
358 self.transitions.push(PendingTransition {
359 timestamp,
360 designation: designation.into(),
361 });
362 self
363 }
364
365 #[must_use]
366 pub fn footer(mut self, footer: impl Into<String>) -> Self {
367 self.footer = Some(PendingFooter::Raw(footer.into()));
368 self
369 }
370
371 #[must_use]
372 pub fn posix_footer(mut self, footer: PosixFooter) -> Self {
373 self.footer = Some(PendingFooter::Posix(footer));
374 self
375 }
376
377 #[must_use]
378 pub const fn version_policy(mut self, version_policy: VersionPolicy) -> Self {
379 self.version_policy = version_policy;
380 self
381 }
382
383 #[must_use]
384 pub const fn version(mut self, version: Version) -> Self {
385 self.version_policy = VersionPolicy::Exact(version);
386 self
387 }
388
389 pub fn build(self) -> Result<TzifFile, TzifBuildError> {
397 let designations = self.normalized_designations()?;
398 let designation_indexes = designation_indexes(&designations)?;
399 let local_time_types = self.build_local_time_types(&designation_indexes)?;
400 let type_indexes = local_time_type_indexes(&self.local_time_types)?;
401 let transitions = self.build_transitions(&type_indexes)?;
402 let transition_times: Vec<i64> = transitions
403 .iter()
404 .map(|transition| transition.timestamp)
405 .collect();
406 let transition_types: Vec<u8> = transitions
407 .iter()
408 .map(|transition| transition.local_time_type_index)
409 .collect();
410 let block = DataBlock {
411 transition_times,
412 transition_types,
413 local_time_types,
414 designations: designation_table(designations.iter().map(String::as_str)),
415 leap_seconds: vec![],
416 standard_wall_indicators: vec![],
417 ut_local_indicators: vec![],
418 };
419 let footer = self
420 .footer
421 .map(|footer| {
422 Ok::<_, TzifBuildError>(BuiltFooter {
423 value: footer.to_tz_string(true)?,
424 uses_tz_string_extension: footer.uses_tz_string_extension(),
425 })
426 })
427 .transpose()?;
428 let version = resolve_version(
429 self.version_policy,
430 &block.transition_times,
431 footer.is_some(),
432 footer
433 .as_ref()
434 .is_some_and(|footer| footer.uses_tz_string_extension),
435 )?;
436 Ok(match version {
437 Version::V1 => {
438 if footer.is_some() {
439 return Err(TzifBuildError::VersionCannotIncludeFooter { version });
440 }
441 TzifFile::v1(block)
442 }
443 Version::V2 => TzifFile::v2(
444 version_one_compatible_block(&block),
445 block,
446 footer.map(|footer| footer.value).unwrap_or_default(),
447 ),
448 Version::V3 => TzifFile::v3(
449 version_one_compatible_block(&block),
450 block,
451 footer.map(|footer| footer.value).unwrap_or_default(),
452 ),
453 Version::V4 => TzifFile::v4(
454 version_one_compatible_block(&block),
455 block,
456 footer.map(|footer| footer.value).unwrap_or_default(),
457 ),
458 })
459 }
460
461 fn normalized_designations(&self) -> Result<Vec<String>, TzifBuildError> {
462 let mut values = self.designations.clone();
463 for local_time_type in &self.local_time_types {
464 if !values.contains(&local_time_type.designation) {
465 values.push(local_time_type.designation.clone());
466 }
467 }
468 for designation in &values {
469 validate_designation(designation, true)?;
470 }
471 let mut seen = BTreeSet::new();
472 for designation in &values {
473 if !seen.insert(designation.clone()) {
474 return Err(TzifBuildError::DuplicateDesignation(designation.clone()));
475 }
476 }
477 Ok(values)
478 }
479
480 fn build_local_time_types(
481 &self,
482 designation_indexes: &BTreeMap<String, u8>,
483 ) -> Result<Vec<LocalTimeType>, TzifBuildError> {
484 if self.local_time_types.is_empty() {
485 return Err(TzifBuildError::UnknownDesignation(
486 "local time type".to_string(),
487 ));
488 }
489 self.local_time_types
490 .iter()
491 .map(|local_time_type| {
492 let designation_index = *designation_indexes
493 .get(&local_time_type.designation)
494 .ok_or_else(|| {
495 TzifBuildError::UnknownDesignation(local_time_type.designation.clone())
496 })?;
497 validate_utc_offset(local_time_type.offset_seconds)?;
498 Ok(LocalTimeType {
499 utc_offset: local_time_type.offset_seconds,
500 is_dst: local_time_type.is_dst,
501 designation_index,
502 })
503 })
504 .collect()
505 }
506
507 fn build_transitions(
508 &self,
509 type_indexes: &BTreeMap<String, u8>,
510 ) -> Result<Vec<BuiltTransition>, TzifBuildError> {
511 let mut previous = None;
512 let mut values = Vec::with_capacity(self.transitions.len());
513 for transition in &self.transitions {
514 if previous.is_some_and(|previous| transition.timestamp <= previous) {
515 return Err(TzifBuildError::UnsortedTransitions);
516 }
517 previous = Some(transition.timestamp);
518 let local_time_type_index =
519 *type_indexes.get(&transition.designation).ok_or_else(|| {
520 TzifBuildError::UnknownDesignation(transition.designation.clone())
521 })?;
522 values.push(BuiltTransition {
523 timestamp: transition.timestamp,
524 local_time_type_index,
525 });
526 }
527 Ok(values)
528 }
529}
530
531#[derive(Clone, Debug)]
532struct PendingLocalTimeType {
533 designation: String,
534 offset_seconds: i32,
535 is_dst: bool,
536}
537
538#[derive(Clone, Debug)]
539struct PendingTransition {
540 timestamp: i64,
541 designation: String,
542}
543
544#[derive(Clone, Copy, Debug)]
545struct BuiltTransition {
546 timestamp: i64,
547 local_time_type_index: u8,
548}
549
550#[derive(Clone, Debug)]
551enum PendingFooter {
552 Raw(String),
553 Posix(PosixFooter),
554}
555
556#[derive(Clone, Debug)]
557struct BuiltFooter {
558 value: String,
559 uses_tz_string_extension: bool,
560}
561
562impl PendingFooter {
563 fn to_tz_string(&self, strict_designation: bool) -> Result<String, TzifBuildError> {
564 match self {
565 Self::Raw(value) => Ok(value.clone()),
566 Self::Posix(footer) => footer.to_tz_string(strict_designation),
567 }
568 }
569
570 fn uses_tz_string_extension(&self) -> bool {
571 match self {
572 Self::Raw(value) => footer_uses_tz_string_extension(value),
573 Self::Posix(footer) => footer.uses_tz_string_extension(),
574 }
575 }
576}
577
578fn validate_designation(designation: &str, strict: bool) -> Result<(), TzifBuildError> {
579 if designation.is_empty() {
580 return Err(TzifBuildError::EmptyDesignation);
581 }
582 if !designation.is_ascii() {
583 return Err(TzifBuildError::NonAsciiDesignation {
584 designation: designation.to_string(),
585 });
586 }
587 for character in designation.chars() {
588 if !(character.is_ascii_alphanumeric() || character == '+' || character == '-') {
589 return Err(TzifBuildError::UnsupportedDesignationCharacter {
590 designation: designation.to_string(),
591 character,
592 });
593 }
594 }
595 if strict && designation.len() < 3 {
596 return Err(TzifBuildError::DesignationTooShort {
597 designation: designation.to_string(),
598 });
599 }
600 if strict && designation.len() > 6 {
601 return Err(TzifBuildError::DesignationTooLong {
602 designation: designation.to_string(),
603 });
604 }
605 Ok(())
606}
607
608const fn validate_utc_offset(offset_seconds: i32) -> Result<(), TzifBuildError> {
609 if offset_seconds == i32::MIN {
610 return Err(TzifBuildError::InvalidUtcOffset);
611 }
612 Ok(())
613}
614
615fn validate_posix_offset(offset_seconds: i32) -> Result<(), TzifBuildError> {
616 validate_utc_offset(offset_seconds)?;
617 let seconds = offset_seconds
618 .checked_neg()
619 .ok_or(TzifBuildError::InvalidUtcOffset)?;
620 if seconds.abs() > 24 * 3600 + 59 * 60 + 59 {
621 return Err(TzifBuildError::PosixOffsetOutOfRange {
622 seconds: offset_seconds,
623 });
624 }
625 Ok(())
626}
627
628fn designation_table<'a>(designations: impl IntoIterator<Item = &'a str>) -> Vec<u8> {
629 let mut bytes = Vec::new();
630 for designation in designations {
631 bytes.extend_from_slice(designation.as_bytes());
632 bytes.push(0);
633 }
634 bytes
635}
636
637fn designation_indexes(designations: &[String]) -> Result<BTreeMap<String, u8>, TzifBuildError> {
638 let mut indexes = BTreeMap::new();
639 let mut next = 0usize;
640 for designation in designations {
641 let index = u8::try_from(next).map_err(|_| {
642 TzifBuildError::InvalidTzif(crate::TzifError::CountOverflow {
643 field: "charcnt",
644 count: next,
645 })
646 })?;
647 indexes.insert(designation.clone(), index);
648 next += designation.len() + 1;
649 }
650 Ok(indexes)
651}
652
653fn local_time_type_indexes(
654 local_time_types: &[PendingLocalTimeType],
655) -> Result<BTreeMap<String, u8>, TzifBuildError> {
656 let mut indexes = BTreeMap::new();
657 for (index, local_time_type) in local_time_types.iter().enumerate() {
658 if indexes.contains_key(&local_time_type.designation) {
659 return Err(TzifBuildError::DuplicateDesignation(
660 local_time_type.designation.clone(),
661 ));
662 }
663 let index = u8::try_from(index).map_err(|_| {
664 TzifBuildError::InvalidTzif(crate::TzifError::TooManyLocalTimeTypes(index))
665 })?;
666 indexes.insert(local_time_type.designation.clone(), index);
667 }
668 Ok(indexes)
669}
670
671fn resolve_version(
672 policy: VersionPolicy,
673 transition_times: &[i64],
674 has_footer: bool,
675 footer_uses_tz_string_extension: bool,
676) -> Result<Version, TzifBuildError> {
677 let auto = if footer_uses_tz_string_extension {
678 Version::V3
679 } else {
680 Version::V2
681 };
682 let version = match policy {
683 VersionPolicy::Auto => auto,
684 VersionPolicy::Exact(version) => version,
685 };
686 if version == Version::V1 && has_footer {
687 return Err(TzifBuildError::VersionCannotIncludeFooter { version });
688 }
689 if version < Version::V3 && footer_uses_tz_string_extension {
690 return Err(TzifBuildError::VersionCannotRepresentFooterExtension { version });
691 }
692 if version == Version::V1 {
693 for ×tamp in transition_times {
694 if i32::try_from(timestamp).is_err() {
695 return Err(TzifBuildError::TransitionOutOfRangeForVersion { version, timestamp });
696 }
697 }
698 }
699 Ok(version)
700}
701
702fn version_one_compatible_block(block: &DataBlock) -> DataBlock {
703 let mut v1 = DataBlock {
704 transition_times: Vec::new(),
705 transition_types: Vec::new(),
706 local_time_types: block.local_time_types.clone(),
707 designations: block.designations.clone(),
708 leap_seconds: block
709 .leap_seconds
710 .iter()
711 .copied()
712 .filter(|leap_second| i32::try_from(leap_second.occurrence).is_ok())
713 .collect(),
714 standard_wall_indicators: block.standard_wall_indicators.clone(),
715 ut_local_indicators: block.ut_local_indicators.clone(),
716 };
717
718 for (&transition_time, &transition_type) in block
719 .transition_times
720 .iter()
721 .zip(block.transition_types.iter())
722 {
723 if i32::try_from(transition_time).is_ok() {
724 v1.transition_times.push(transition_time);
725 v1.transition_types.push(transition_type);
726 }
727 }
728
729 v1
730}
731
732fn fixed_offset_footer(designation: &str, offset_seconds: i32) -> Result<String, TzifBuildError> {
733 validate_posix_offset(offset_seconds)?;
734 Ok(format!(
735 "{}{}",
736 posix_designation(designation),
737 posix_offset(offset_seconds)?
738 ))
739}
740
741fn posix_designation(designation: &str) -> String {
742 if designation.bytes().all(|byte| byte.is_ascii_alphabetic()) {
743 designation.to_string()
744 } else {
745 format!("<{designation}>")
746 }
747}
748
749fn posix_offset(offset_seconds: i32) -> Result<String, TzifBuildError> {
750 validate_posix_offset(offset_seconds)?;
751 let seconds = offset_seconds
752 .checked_neg()
753 .ok_or(TzifBuildError::InvalidUtcOffset)?;
754 Ok(posix_duration(seconds))
755}
756
757fn posix_time(seconds: i32) -> String {
758 posix_duration(seconds)
759}
760
761fn posix_duration(seconds: i32) -> String {
762 let sign = if seconds < 0 { "-" } else { "" };
763 let seconds = seconds.abs();
764 let hours = seconds / 3600;
765 let minutes = (seconds % 3600) / 60;
766 let seconds = seconds % 60;
767 if seconds != 0 {
768 format!("{sign}{hours}:{minutes:02}:{seconds:02}")
769 } else if minutes != 0 {
770 format!("{sign}{hours}:{minutes:02}")
771 } else {
772 format!("{sign}{hours}")
773 }
774}