Skip to main content

tzif_codec/
builder.rs

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    /// Builds a `TZif` file for a fixed-offset zone.
276    ///
277    /// # Errors
278    ///
279    /// Returns an error if the designation, UTC offset, requested version, or generated
280    /// POSIX footer cannot be represented as valid `TZif`.
281    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    /// Builds a `TZif` file from explicitly supplied transitions and local time types.
390    ///
391    /// # Errors
392    ///
393    /// Returns an error if designations are invalid or duplicated, transitions are not
394    /// strictly ascending, referenced local time types are missing, or the requested
395    /// version cannot represent the supplied data.
396    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 &timestamp 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}