aimcal_ical/property/
datetime.rs

1// SPDX-FileCopyrightText: 2025-2026 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Date and Time Properties (RFC 5545 Section 3.8.2)
6//!
7//! This module contains property types for the "Date and Time Properties"
8//! section of RFC 5545, including:
9//!
10//! ## Base Types
11//!
12//! - `DateTime`: Core date/time representation (floating, UTC, or timezone-aware)
13//! - `Period`: Time period with start/end or start/duration
14//! - `Time`: Time of day representation
15//!
16//! ## Property Wrapper Types
17//!
18//! Each wrapper type implements `Deref` and `DerefMut` to `DateTime` for convenient
19//! access to the underlying date/time data, and includes a `kind()` method for
20//! property validation:
21//!
22//! - 3.8.2.1: `Completed` - Date-Time Completed
23//! - 3.8.2.2: `DtEnd` - Date-Time End
24//! - 3.8.2.3: `Due` - Date-Time Due
25//! - 3.8.2.4: `DtStart` - Date-Time Start
26//! - 3.8.2.5: `Duration` - Duration of time
27//! - 3.8.2.6: `FreeBusy` - Free/busy time information
28//! - 3.8.2.7: `TimeTransparency` - Time transparency (OPAQUE/TRANSPARENT)
29//!
30//! - 3.8.7.1: `Created` - Date-Time Created
31//! - 3.8.7.2: `DtStamp` - Date-Time Stamp
32//! - 3.8.7.3: `LastModified` - Last Modified
33//!
34//! All wrapper types validate their property kind during conversion from
35//! `ParsedProperty`, ensuring type safety throughout the parsing pipeline.
36
37use std::convert::TryFrom;
38
39use crate::keyword::{KW_TRANSP_OPAQUE, KW_TRANSP_TRANSPARENT};
40use crate::parameter::{FreeBusyType, Parameter, ValueType};
41use crate::property::PropertyKind;
42use crate::property::common::{take_single_text, take_single_value};
43use crate::string_storage::{Segments, StringStorage};
44use crate::syntax::RawParameter;
45use crate::typed::{ParsedProperty, TypedError};
46use crate::value::{Value, ValueDate, ValueDuration, ValuePeriod, ValueTime};
47
48/// Date and time representation
49#[derive(Debug, Clone)]
50pub enum DateTime<S: StringStorage> {
51    /// Date and time without timezone (floating time)
52    Floating {
53        /// Date part
54        date: ValueDate,
55        /// Time part
56        time: Time,
57        /// X-name parameters (custom experimental parameters)
58        x_parameters: Vec<RawParameter<S>>,
59        /// Unrecognized / Non-standard parameters (preserved for round-trip)
60        retained_parameters: Vec<Parameter<S>>,
61    },
62
63    /// Date and time with specific timezone
64    Zoned {
65        /// Date part
66        date: ValueDate,
67        /// Time part
68        time: Time,
69        /// Timezone identifier
70        tz_id: S,
71        /// Cached parsed timezone (available with jiff feature)
72        #[cfg(feature = "jiff")]
73        tz_jiff: jiff::tz::TimeZone,
74        /// X-name parameters (custom experimental parameters)
75        x_parameters: Vec<RawParameter<S>>,
76        /// Unrecognized / Non-standard parameters (preserved for round-trip)
77        retained_parameters: Vec<Parameter<S>>,
78    },
79
80    /// Date and time in UTC
81    Utc {
82        /// Date part
83        date: ValueDate,
84        /// Time part
85        time: Time,
86        /// X-name parameters (custom experimental parameters)
87        x_parameters: Vec<RawParameter<S>>,
88        /// Unrecognized / Non-standard parameters (preserved for round-trip)
89        retained_parameters: Vec<Parameter<S>>,
90    },
91
92    /// Date-only value
93    Date {
94        /// Date part
95        date: ValueDate,
96        /// X-name parameters (custom experimental parameters)
97        x_parameters: Vec<RawParameter<S>>,
98        /// Unrecognized / Non-standard parameters (preserved for round-trip)
99        retained_parameters: Vec<Parameter<S>>,
100    },
101}
102
103impl<S: StringStorage> DateTime<S> {
104    /// Get the date part of this `DateTime`
105    #[must_use]
106    pub fn date(&self) -> ValueDate {
107        match self {
108            DateTime::Floating { date, .. }
109            | DateTime::Zoned { date, .. }
110            | DateTime::Utc { date, .. }
111            | DateTime::Date { date, .. } => *date,
112        }
113    }
114
115    /// Get the time part if this is not a date-only value
116    #[must_use]
117    pub fn time(&self) -> Option<Time> {
118        match self {
119            DateTime::Floating { time, .. }
120            | DateTime::Zoned { time, .. }
121            | DateTime::Utc { time, .. } => Some(*time),
122            DateTime::Date { .. } => None,
123        }
124    }
125
126    /// Get the timezone ID if this is a zoned value
127    #[must_use]
128    pub fn tz_id(&self) -> Option<&S> {
129        match self {
130            DateTime::Zoned { tz_id, .. } => Some(tz_id),
131            _ => None,
132        }
133    }
134
135    /// Get the timezone if this is a zoned value (when jiff feature is enabled)
136    #[cfg(feature = "jiff")]
137    #[must_use]
138    pub fn timezone(&self) -> Option<&jiff::tz::TimeZone> {
139        match self {
140            DateTime::Zoned { tz_jiff, .. } => Some(tz_jiff),
141            _ => None,
142        }
143    }
144
145    /// Check if this is a date-only value
146    #[must_use]
147    pub fn is_date_only(&self) -> bool {
148        matches!(self, DateTime::Date { .. })
149    }
150
151    /// Check if this is a UTC value
152    #[must_use]
153    pub fn is_utc(&self) -> bool {
154        matches!(self, DateTime::Utc { .. })
155    }
156
157    /// Check if this is a floating (no timezone) value
158    #[must_use]
159    pub fn is_floating(&self) -> bool {
160        matches!(self, DateTime::Floating { .. })
161    }
162
163    /// Get the combined date and time as `jiff::civil::DateTime` (when jiff feature is enabled).
164    ///
165    /// Returns `None` for date-only values.
166    #[cfg(feature = "jiff")]
167    #[must_use]
168    pub fn civil_date_time(&self) -> Option<jiff::civil::DateTime> {
169        match self {
170            DateTime::Floating { date, time, .. }
171            | DateTime::Zoned { date, time, .. }
172            | DateTime::Utc { date, time, .. } => Some(jiff::civil::DateTime::from_parts(
173                date.civil_date(),
174                time.civil_time(),
175            )),
176            DateTime::Date { .. } => None,
177        }
178    }
179}
180
181impl<'src> TryFrom<ParsedProperty<'src>> for DateTime<Segments<'src>> {
182    type Error = Vec<TypedError<'src>>;
183
184    #[expect(clippy::too_many_lines)]
185    fn try_from(prop: ParsedProperty<'src>) -> Result<Self, Self::Error> {
186        let mut errors: Vec<TypedError<'src>> = Vec::new();
187
188        let value = match take_single_value(&prop.kind, prop.value) {
189            Ok(v) => v,
190            Err(mut e) => {
191                errors.append(&mut e);
192                return Err(errors);
193            }
194        };
195
196        // Get TZID parameter
197        let mut tz_id = None;
198        #[cfg(feature = "jiff")]
199        let mut tz_jiff = None;
200        let mut x_parameters = Vec::new();
201        let mut retained_parameters = Vec::new();
202
203        for param in prop.parameters {
204            match param {
205                p @ Parameter::TimeZoneIdentifier { .. } if tz_id.is_some() => {
206                    errors.push(TypedError::ParameterDuplicated {
207                        span: p.span(),
208                        parameter: p.kind().into(),
209                    });
210                }
211                Parameter::TimeZoneIdentifier {
212                    value,
213                    #[cfg(feature = "jiff")]
214                    tz,
215                    ..
216                } => {
217                    tz_id = Some(value);
218                    #[cfg(feature = "jiff")]
219                    {
220                        tz_jiff = Some(tz);
221                    }
222                }
223                Parameter::XName(raw) => x_parameters.push(raw),
224                p @ Parameter::Unrecognized { .. } => retained_parameters.push(p),
225                p => {
226                    // Preserve other parameters not used by this property for round-trip
227                    retained_parameters.push(p);
228                }
229            }
230        }
231
232        // Return all errors if any occurred
233        if !errors.is_empty() {
234            return Err(errors);
235        }
236
237        // Try with timezone if available, otherwise fallback to basic conversion
238        if let Some(tz_id_value) = tz_id {
239            match value {
240                Value::DateTime { mut values, .. } if values.len() == 1 => {
241                    let dt = values.pop().unwrap();
242                    if dt.time.utc {
243                        Ok(DateTime::Utc {
244                            date: dt.date,
245                            time: dt.time.into(),
246                            x_parameters,
247                            retained_parameters,
248                        })
249                    } else {
250                        Ok(DateTime::Zoned {
251                            date: dt.date,
252                            time: dt.time.into(),
253                            tz_id: tz_id_value,
254                            #[cfg(feature = "jiff")]
255                            tz_jiff: tz_jiff.unwrap(), // SAFETY: set above
256                            x_parameters,
257                            retained_parameters,
258                        })
259                    }
260                }
261
262                _ => Err(vec![TypedError::PropertyInvalidValue {
263                    property: prop.kind,
264                    value: "Expected date-time value".to_string(),
265                    span: value.span(),
266                }]),
267            }
268        } else {
269            match value {
270                Value::Date { mut values, .. } if values.len() == 1 => {
271                    let date = values.pop().unwrap();
272                    Ok(DateTime::Date {
273                        date,
274                        x_parameters,
275                        retained_parameters,
276                    })
277                }
278                Value::DateTime { mut values, .. } if values.len() == 1 => {
279                    let dt = values.pop().unwrap();
280                    if dt.time.utc {
281                        Ok(DateTime::Utc {
282                            date: dt.date,
283                            time: dt.time.into(),
284                            x_parameters,
285                            retained_parameters,
286                        })
287                    } else {
288                        Ok(DateTime::Floating {
289                            date: dt.date,
290                            time: dt.time.into(),
291                            x_parameters,
292                            retained_parameters,
293                        })
294                    }
295                }
296                _ => {
297                    const EXPECTED: &[ValueType<String>] = &[ValueType::Date, ValueType::DateTime];
298                    Err(vec![TypedError::PropertyUnexpectedValue {
299                        property: PropertyKind::DtStart, // Default fallback
300                        expected: EXPECTED,
301                        found: value.kind().into(),
302                        span: value.span(),
303                    }])
304                }
305            }
306        }
307    }
308}
309
310impl DateTime<Segments<'_>> {
311    /// Convert borrowed `DateTime` to owned `DateTime`
312    #[must_use]
313    pub fn to_owned(&self) -> DateTime<String> {
314        match self {
315            DateTime::Floating {
316                date,
317                time,
318                x_parameters,
319                retained_parameters,
320            } => DateTime::Floating {
321                date: *date,
322                time: *time,
323                x_parameters: x_parameters.iter().map(RawParameter::to_owned).collect(),
324                retained_parameters: retained_parameters
325                    .iter()
326                    .map(Parameter::to_owned)
327                    .collect(),
328            },
329            DateTime::Zoned {
330                date,
331                time,
332                tz_id,
333                #[cfg(feature = "jiff")]
334                tz_jiff,
335                x_parameters,
336                retained_parameters,
337            } => DateTime::Zoned {
338                date: *date,
339                time: *time,
340                tz_id: tz_id.to_owned(),
341                #[cfg(feature = "jiff")]
342                tz_jiff: tz_jiff.clone(),
343                x_parameters: x_parameters.iter().map(RawParameter::to_owned).collect(),
344                retained_parameters: retained_parameters
345                    .iter()
346                    .map(Parameter::to_owned)
347                    .collect(),
348            },
349            DateTime::Utc {
350                date,
351                time,
352                x_parameters,
353                retained_parameters,
354            } => DateTime::Utc {
355                date: *date,
356                time: *time,
357                x_parameters: x_parameters.iter().map(RawParameter::to_owned).collect(),
358                retained_parameters: retained_parameters
359                    .iter()
360                    .map(Parameter::to_owned)
361                    .collect(),
362            },
363            DateTime::Date {
364                date,
365                x_parameters,
366                retained_parameters,
367            } => DateTime::Date {
368                date: *date,
369                x_parameters: x_parameters.iter().map(RawParameter::to_owned).collect(),
370                retained_parameters: retained_parameters
371                    .iter()
372                    .map(Parameter::to_owned)
373                    .collect(),
374            },
375        }
376    }
377}
378
379/// Time representation
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381pub struct Time {
382    /// Hour component (0-23)
383    pub hour: u8,
384
385    /// Minute component (0-59)
386    pub minute: u8,
387
388    /// Second component (0-60)
389    pub second: u8,
390
391    /// Cached `jiff::civil::Time` representation
392    #[cfg(feature = "jiff")]
393    pub(crate) jiff: jiff::civil::Time,
394}
395
396impl Time {
397    /// Create a new `Time` instance.
398    ///
399    /// # Errors
400    /// If hour, minute, or second are out of valid ranges.
401    #[expect(clippy::cast_possible_wrap)]
402    pub fn new(hour: u8, minute: u8, second: u8) -> Result<Self, String> {
403        Ok(Time {
404            hour,
405            minute,
406            second,
407            #[cfg(feature = "jiff")]
408            jiff: jiff::civil::Time::new(
409                hour as i8,
410                minute as i8,
411                second.min(59) as i8, // NOTE: we clamp second to 59 here because jiff does not support leap seconds
412                0,
413            )
414            .map_err(|e| e.to_string())?,
415        })
416    }
417
418    /// Get reference to cached `jiff::civil::Time`.
419    #[cfg(feature = "jiff")]
420    #[must_use]
421    pub const fn civil_time(&self) -> jiff::civil::Time {
422        self.jiff
423    }
424}
425
426impl From<ValueTime> for Time {
427    fn from(value: ValueTime) -> Self {
428        Time {
429            hour: value.hour,
430            minute: value.minute,
431            second: value.second,
432            #[cfg(feature = "jiff")]
433            jiff: value.jiff,
434        }
435    }
436}
437
438/// Period of time (RFC 5545 Section 3.8.2.6)
439///
440/// This type separates UTC, floating, and zoned time periods at the type level,
441/// ensuring that start and end times always have consistent timezone semantics.
442///
443/// Per RFC 5545 Section 3.3.9, periods have two forms:
444/// - Explicit: start and end date-times
445/// - Duration: start date-time and duration
446#[derive(Debug, Clone)]
447pub enum Period<S: StringStorage> {
448    /// Start and end date/time in UTC
449    ExplicitUtc {
450        /// Start date
451        start_date: ValueDate,
452        /// Start time
453        start_time: Time,
454        /// End date
455        end_date: ValueDate,
456        /// End time
457        end_time: Time,
458    },
459
460    /// Start and end date/time in floating time (no timezone)
461    ExplicitFloating {
462        /// Start date
463        start_date: ValueDate,
464        /// Start time
465        start_time: Time,
466        /// End date
467        end_date: ValueDate,
468        /// End time
469        end_time: Time,
470    },
471
472    /// Start and end date/time with timezone reference
473    ExplicitZoned {
474        /// Start date
475        start_date: ValueDate,
476        /// Start time
477        start_time: Time,
478        /// End date
479        end_date: ValueDate,
480        /// End time
481        end_time: Time,
482        /// Timezone ID (same for both start and end)
483        tz_id: S,
484        /// Cached parsed timezone (available with jiff feature)
485        #[cfg(feature = "jiff")]
486        tz_jiff: jiff::tz::TimeZone,
487    },
488
489    /// Start date/time in UTC with a duration
490    DurationUtc {
491        /// Start date
492        start_date: ValueDate,
493        /// Start time
494        start_time: Time,
495        /// Duration from the start
496        duration: ValueDuration,
497    },
498
499    /// Start date/time in floating time with a duration
500    DurationFloating {
501        /// Start date
502        start_date: ValueDate,
503        /// Start time
504        start_time: Time,
505        /// Duration from the start
506        duration: ValueDuration,
507    },
508
509    /// Start date/time with timezone reference and a duration
510    DurationZoned {
511        /// Start date
512        start_date: ValueDate,
513        /// Start time
514        start_time: Time,
515        /// Duration from the start
516        duration: ValueDuration,
517        /// Start timezone ID
518        tz_id: S,
519        /// Cached parsed timezone (available with jiff feature)
520        #[cfg(feature = "jiff")]
521        tz_jiff: jiff::tz::TimeZone,
522    },
523}
524
525impl<S: StringStorage> Period<S> {
526    /// Get the timezone ID if this is a zoned period
527    #[must_use]
528    pub fn tz_id(&self) -> Option<&S> {
529        match self {
530            Period::ExplicitZoned { tz_id, .. } | Period::DurationZoned { tz_id, .. } => {
531                Some(tz_id)
532            }
533            _ => None,
534        }
535    }
536
537    /// Get the timezone if this is a zoned period (when jiff feature is enabled)
538    #[cfg(feature = "jiff")]
539    #[must_use]
540    pub fn jiff_timezone(&self) -> Option<&jiff::tz::TimeZone> {
541        match self {
542            Period::ExplicitZoned { tz_jiff: tz, .. }
543            | Period::DurationZoned { tz_jiff: tz, .. } => Some(tz),
544            _ => None,
545        }
546    }
547
548    /// Get the start as a `DateTime`.
549    #[must_use]
550    pub fn start(&self) -> DateTime<S> {
551        match self {
552            Period::ExplicitUtc {
553                start_date,
554                start_time,
555                ..
556            }
557            | Period::DurationUtc {
558                start_date,
559                start_time,
560                ..
561            } => DateTime::Utc {
562                date: *start_date,
563                time: *start_time,
564                x_parameters: Vec::new(),
565                retained_parameters: Vec::new(),
566            },
567            Period::ExplicitFloating {
568                start_date,
569                start_time,
570                ..
571            }
572            | Period::DurationFloating {
573                start_date,
574                start_time,
575                ..
576            } => DateTime::Floating {
577                date: *start_date,
578                time: *start_time,
579                x_parameters: Vec::new(),
580                retained_parameters: Vec::new(),
581            },
582            Period::ExplicitZoned {
583                start_date,
584                start_time,
585                tz_id,
586                #[cfg(feature = "jiff")]
587                tz_jiff,
588                ..
589            }
590            | Period::DurationZoned {
591                start_date,
592                start_time,
593                tz_id,
594                #[cfg(feature = "jiff")]
595                tz_jiff,
596                ..
597            } => DateTime::Zoned {
598                date: *start_date,
599                time: *start_time,
600                tz_id: tz_id.clone(),
601                #[cfg(feature = "jiff")]
602                tz_jiff: tz_jiff.clone(),
603                x_parameters: Vec::new(),
604                retained_parameters: Vec::new(),
605            },
606        }
607    }
608
609    /// Get the end as a `DateTime` (when jiff feature is enabled).
610    ///
611    /// For duration-based periods, calculates the end by adding the duration to the start.
612    #[cfg(feature = "jiff")]
613    #[expect(clippy::missing_panics_doc, clippy::too_many_lines)]
614    #[must_use]
615    pub fn end(&self) -> DateTime<S> {
616        match self {
617            Period::ExplicitUtc {
618                end_date, end_time, ..
619            } => DateTime::Utc {
620                date: *end_date,
621                time: *end_time,
622                x_parameters: Vec::new(),
623                retained_parameters: Vec::new(),
624            },
625            Period::ExplicitFloating {
626                end_date, end_time, ..
627            } => DateTime::Floating {
628                date: *end_date,
629                time: *end_time,
630                x_parameters: Vec::new(),
631                retained_parameters: Vec::new(),
632            },
633            Period::ExplicitZoned {
634                end_date,
635                end_time,
636                tz_id,
637                tz_jiff,
638                ..
639            } => DateTime::Zoned {
640                date: *end_date,
641                time: *end_time,
642                tz_id: tz_id.clone(),
643                tz_jiff: tz_jiff.clone(),
644                x_parameters: Vec::new(),
645                retained_parameters: Vec::new(),
646            },
647            Period::DurationUtc {
648                start_date,
649                start_time,
650                duration,
651                ..
652            } => {
653                let start = jiff::civil::DateTime::from_parts(
654                    start_date.civil_date(),
655                    start_time.civil_time(),
656                );
657                let end = add_duration(start, duration);
658                DateTime::Utc {
659                    date: ValueDate {
660                        year: end.year(),
661                        month: end.month(),
662                        day: end.day(),
663                    },
664                    #[expect(clippy::cast_sign_loss)]
665                    time: Time::new(end.hour() as u8, end.minute() as u8, end.second() as u8)
666                        .map_err(|e| format!("invalid time: {e}"))
667                        .unwrap(), // SAFETY: hour, minute, second are within valid ranges
668                    x_parameters: Vec::new(),
669                    retained_parameters: Vec::new(),
670                }
671            }
672            Period::DurationFloating {
673                start_date,
674                start_time,
675                duration,
676                ..
677            } => {
678                let start = jiff::civil::DateTime::from_parts(
679                    start_date.civil_date(),
680                    start_time.civil_time(),
681                );
682                let end = add_duration(start, duration);
683                DateTime::Floating {
684                    date: ValueDate {
685                        year: end.year(),
686                        month: end.month(),
687                        day: end.day(),
688                    },
689                    #[expect(clippy::cast_sign_loss)]
690                    time: Time::new(end.hour() as u8, end.minute() as u8, end.second() as u8)
691                        .map_err(|e| format!("invalid time: {e}"))
692                        .unwrap(), // SAFETY: hour, minute, second are within valid ranges
693                    x_parameters: Vec::new(),
694                    retained_parameters: Vec::new(),
695                }
696            }
697            Period::DurationZoned {
698                start_date,
699                start_time,
700                tz_id,
701                tz_jiff,
702                duration,
703            } => {
704                let start = jiff::civil::DateTime::from_parts(
705                    start_date.civil_date(),
706                    start_time.civil_time(),
707                );
708                let end = add_duration(start, duration);
709                DateTime::Zoned {
710                    date: ValueDate {
711                        year: end.year(),
712                        month: end.month(),
713                        day: end.day(),
714                    },
715                    #[expect(clippy::cast_sign_loss)]
716                    time: Time::new(end.hour() as u8, end.minute() as u8, end.second() as u8)
717                        .expect("invalid time"),
718                    tz_id: tz_id.clone(),
719                    tz_jiff: tz_jiff.clone(),
720                    x_parameters: Vec::new(),
721                    retained_parameters: Vec::new(),
722                }
723            }
724        }
725    }
726
727    /// Get the start date and time as `jiff::civil::DateTime` (when jiff feature is enabled).
728    #[cfg(feature = "jiff")]
729    #[must_use]
730    #[rustfmt::skip]
731    pub fn start_civil(&self) -> jiff::civil::DateTime {
732        match self {
733            Period::ExplicitUtc { start_date, start_time, .. }
734            | Period::ExplicitFloating { start_date, start_time, .. }
735            | Period::ExplicitZoned { start_date, start_time, .. }
736            | Period::DurationUtc { start_date, start_time, .. }
737            | Period::DurationFloating { start_date, start_time, .. }
738            | Period::DurationZoned { start_date, start_time, .. } => {
739                jiff::civil::DateTime::from_parts(start_date.civil_date(), start_time.civil_time())
740            }
741        }
742    }
743
744    /// Get the end date and time as `jiff::civil::DateTime` (when jiff feature is enabled).
745    ///
746    /// For duration-based periods, calculates the end by adding the duration to the start.
747    #[cfg(feature = "jiff")]
748    #[must_use]
749    pub fn end_civil(&self) -> jiff::civil::DateTime {
750        self.start().civil_date_time().unwrap_or_default() // SAFETY: start() never returns Date-only
751    }
752}
753
754impl<'src> TryFrom<Value<Segments<'src>>> for Period<Segments<'src>> {
755    type Error = Vec<TypedError<'src>>;
756
757    fn try_from(value: Value<Segments<'src>>) -> Result<Self, Self::Error> {
758        let span = value.span();
759        match value {
760            Value::Period { mut values, .. } if values.len() == 1 => {
761                let value_period = values.pop().unwrap();
762                match value_period {
763                    // Both start and end have the same UTC flag (guaranteed by parser)
764                    ValuePeriod::Explicit { start, end } if start.time.utc => {
765                        Ok(Period::ExplicitUtc {
766                            start_date: start.date,
767                            start_time: start.time.into(),
768                            end_date: end.date,
769                            end_time: end.time.into(),
770                        })
771                    }
772                    ValuePeriod::Explicit { start, end } => Ok(Period::ExplicitFloating {
773                        start_date: start.date,
774                        start_time: start.time.into(),
775                        end_date: end.date,
776                        end_time: end.time.into(),
777                    }),
778                    ValuePeriod::Duration { start, duration } => {
779                        // Only positive durations are valid for periods
780                        if !matches!(duration, ValueDuration::DateTime { positive: true, .. })
781                            && !matches!(duration, ValueDuration::Week { positive: true, .. })
782                        {
783                            return Err(vec![TypedError::PropertyInvalidValue {
784                                property: PropertyKind::FreeBusy,
785                                value: "Duration must be positive for periods".to_string(),
786                                span,
787                            }]);
788                        }
789
790                        if start.time.utc {
791                            Ok(Period::DurationUtc {
792                                start_date: start.date,
793                                start_time: start.time.into(),
794                                duration,
795                            })
796                        } else {
797                            Ok(Period::DurationFloating {
798                                start_date: start.date,
799                                start_time: start.time.into(),
800                                duration,
801                            })
802                        }
803                    }
804                }
805            }
806            _ => Err(vec![TypedError::ValueTypeDisallowed {
807                property: PropertyKind::FreeBusy,
808                value_type: value.kind().into(),
809                expected_types: &[ValueType::Period],
810                span,
811            }]),
812        }
813    }
814}
815
816impl Period<Segments<'_>> {
817    /// Convert borrowed `Period` to owned `Period`
818    #[must_use]
819    pub fn to_owned(&self) -> Period<String> {
820        match self {
821            Period::ExplicitUtc {
822                start_date,
823                start_time,
824                end_date,
825                end_time,
826            } => Period::ExplicitUtc {
827                start_date: *start_date,
828                start_time: *start_time,
829                end_date: *end_date,
830                end_time: *end_time,
831            },
832            Period::ExplicitFloating {
833                start_date,
834                start_time,
835                end_date,
836                end_time,
837            } => Period::ExplicitFloating {
838                start_date: *start_date,
839                start_time: *start_time,
840                end_date: *end_date,
841                end_time: *end_time,
842            },
843            Period::ExplicitZoned {
844                start_date,
845                start_time,
846                end_date,
847                end_time,
848                tz_id,
849                #[cfg(feature = "jiff")]
850                tz_jiff,
851            } => Period::ExplicitZoned {
852                start_date: *start_date,
853                start_time: *start_time,
854                end_date: *end_date,
855                end_time: *end_time,
856                tz_id: tz_id.to_owned(),
857                #[cfg(feature = "jiff")]
858                tz_jiff: tz_jiff.clone(),
859            },
860            Period::DurationUtc {
861                start_date,
862                start_time,
863                duration,
864            } => Period::DurationUtc {
865                start_date: *start_date,
866                start_time: *start_time,
867                duration: *duration,
868            },
869            Period::DurationFloating {
870                start_date,
871                start_time,
872                duration,
873            } => Period::DurationFloating {
874                start_date: *start_date,
875                start_time: *start_time,
876                duration: *duration,
877            },
878            Period::DurationZoned {
879                start_date,
880                start_time,
881                duration,
882                tz_id,
883                #[cfg(feature = "jiff")]
884                tz_jiff,
885            } => Period::DurationZoned {
886                start_date: *start_date,
887                start_time: *start_time,
888                duration: *duration,
889                tz_id: tz_id.to_owned(),
890                #[cfg(feature = "jiff")]
891                tz_jiff: tz_jiff.clone(),
892            },
893        }
894    }
895}
896
897#[cfg(feature = "jiff")]
898fn add_duration(start: jiff::civil::DateTime, duration: &ValueDuration) -> jiff::civil::DateTime {
899    match duration {
900        ValueDuration::DateTime {
901            positive,
902            day,
903            hour,
904            minute,
905            second,
906        } => {
907            let span = jiff::Span::new()
908                .try_days(i64::from(*day))
909                .unwrap()
910                .try_hours(i64::from(*hour))
911                .unwrap()
912                .try_minutes(i64::from(*minute))
913                .unwrap()
914                .try_seconds(i64::from(*second))
915                .unwrap();
916
917            if *positive {
918                start.checked_add(span).unwrap()
919            } else {
920                start.checked_sub(span).unwrap()
921            }
922        }
923        ValueDuration::Week { positive, week } => {
924            let span = jiff::Span::new().try_weeks(i64::from(*week)).unwrap();
925            if *positive {
926                start.checked_add(span).unwrap()
927            } else {
928                start.checked_sub(span).unwrap()
929            }
930        }
931    }
932}
933
934// DateTime wrapper types for specific properties
935
936simple_property_wrapper!(
937    /// Date-Time Completed property wrapper (RFC 5545 Section 3.8.2.1)
938    pub Completed<S> => DateTime
939);
940
941impl Completed<String> {
942    /// Create a new `Completed<String>` from a `DateTime` value.
943    #[must_use]
944    pub fn new(value: DateTime<String>) -> Self {
945        Self {
946            inner: value,
947            span: (),
948        }
949    }
950}
951
952simple_property_wrapper!(
953    /// Date-Time End property wrapper (RFC 5545 Section 3.8.2.2)
954    pub DtEnd<S> => DateTime
955);
956
957impl DtEnd<String> {
958    /// Create a new `DtEnd<String>` from a `DateTime` value.
959    #[must_use]
960    pub fn new(value: DateTime<String>) -> Self {
961        Self {
962            inner: value,
963            span: (),
964        }
965    }
966}
967
968simple_property_wrapper!(
969    /// Time Transparency property wrapper (RFC 5545 Section 3.8.2.3)
970    pub Due<S> => DateTime
971);
972
973impl Due<String> {
974    /// Create a new `Due<String>` from a `DateTime` value.
975    #[must_use]
976    pub fn new(value: DateTime<String>) -> Self {
977        Self {
978            inner: value,
979            span: (),
980        }
981    }
982}
983
984simple_property_wrapper!(
985    /// Date-Time Start property wrapper (RFC 5545 Section 3.8.2.4)
986    pub DtStart<S> => DateTime
987);
988
989impl DtStart<String> {
990    /// Create a new `DtStart<String>` from a `DateTime` value.
991    #[must_use]
992    pub fn new(value: DateTime<String>) -> Self {
993        Self {
994            inner: value,
995            span: (),
996        }
997    }
998}
999
1000/// Duration (RFC 5545 Section 3.8.2.5)
1001///
1002/// This property specifies a duration of time.
1003#[derive(Debug, Clone)]
1004pub struct Duration<S: StringStorage> {
1005    /// Duration value
1006    pub value: ValueDuration,
1007    /// X-name parameters (custom experimental parameters)
1008    pub x_parameters: Vec<RawParameter<S>>,
1009    /// Unrecognized / Non-standard parameters (preserved for round-trip)
1010    pub retained_parameters: Vec<Parameter<S>>,
1011    /// Span of the property in the source
1012    pub span: S::Span,
1013}
1014
1015impl<'src> TryFrom<ParsedProperty<'src>> for Duration<Segments<'src>> {
1016    type Error = Vec<TypedError<'src>>;
1017
1018    fn try_from(prop: ParsedProperty<'src>) -> Result<Self, Self::Error> {
1019        if !matches!(prop.kind, PropertyKind::Duration) {
1020            return Err(vec![TypedError::PropertyUnexpectedKind {
1021                expected: PropertyKind::Duration,
1022                found: prop.kind,
1023                span: prop.span,
1024            }]);
1025        }
1026
1027        let mut x_parameters = Vec::new();
1028        let mut retained_parameters = Vec::new();
1029
1030        for param in prop.parameters {
1031            match param {
1032                Parameter::XName(raw) => x_parameters.push(raw),
1033                p @ Parameter::Unrecognized { .. } => retained_parameters.push(p),
1034                p => {
1035                    // Preserve other parameters not used by this property for round-trip
1036                    retained_parameters.push(p);
1037                }
1038            }
1039        }
1040
1041        match take_single_value(&PropertyKind::Duration, prop.value) {
1042            Ok(Value::Duration { values, .. }) if values.is_empty() => {
1043                Err(vec![TypedError::PropertyMissingValue {
1044                    property: prop.kind,
1045                    span: prop.span,
1046                }])
1047            }
1048            Ok(Value::Duration { values, .. }) if values.len() != 1 => {
1049                Err(vec![TypedError::PropertyInvalidValueCount {
1050                    property: prop.kind,
1051                    expected: 1,
1052                    found: values.len(),
1053                    span: prop.span,
1054                }])
1055            }
1056            Ok(Value::Duration { mut values, .. }) => Ok(Self {
1057                value: values.pop().unwrap(), // SAFETY: checked above
1058                x_parameters,
1059                retained_parameters,
1060                span: prop.span,
1061            }),
1062            Ok(v) => {
1063                const EXPECTED: &[ValueType<String>] = &[ValueType::Duration];
1064                let span = v.span();
1065                Err(vec![TypedError::PropertyUnexpectedValue {
1066                    property: prop.kind,
1067                    expected: EXPECTED,
1068                    found: v.kind().into(),
1069                    span,
1070                }])
1071            }
1072            Err(e) => Err(e),
1073        }
1074    }
1075}
1076
1077impl Duration<Segments<'_>> {
1078    /// Convert borrowed `Duration` to owned `Duration`
1079    #[must_use]
1080    pub fn to_owned(&self) -> Duration<String> {
1081        Duration {
1082            value: self.value,
1083            x_parameters: self
1084                .x_parameters
1085                .iter()
1086                .map(RawParameter::to_owned)
1087                .collect(),
1088            retained_parameters: self
1089                .retained_parameters
1090                .iter()
1091                .map(Parameter::to_owned)
1092                .collect(),
1093            span: (),
1094        }
1095    }
1096}
1097
1098/// Free/Busy Time (RFC 5545 Section 3.8.2.6)
1099///
1100/// This property defines one or more free or busy time intervals.
1101#[derive(Debug, Clone)]
1102pub struct FreeBusy<S: StringStorage> {
1103    /// Free/Busy type parameter
1104    pub fb_type: FreeBusyType<S>,
1105    /// List of free/busy time periods
1106    pub values: Vec<Period<S>>,
1107    /// X-name parameters (custom experimental parameters)
1108    pub x_parameters: Vec<RawParameter<S>>,
1109    /// Unrecognized / Non-standard parameters (preserved for round-trip)
1110    pub retained_parameters: Vec<Parameter<S>>,
1111    /// Span of the property in the source
1112    pub span: S::Span,
1113}
1114
1115impl<'src> TryFrom<ParsedProperty<'src>> for FreeBusy<Segments<'src>> {
1116    type Error = Vec<TypedError<'src>>;
1117
1118    fn try_from(prop: ParsedProperty<'src>) -> Result<Self, Self::Error> {
1119        if !matches!(prop.kind, PropertyKind::FreeBusy) {
1120            return Err(vec![TypedError::PropertyUnexpectedKind {
1121                expected: PropertyKind::FreeBusy,
1122                found: prop.kind,
1123                span: prop.span,
1124            }]);
1125        }
1126
1127        // Extract FBTYPE parameter (defaults to BUSY)
1128        let mut fb_type: FreeBusyType<Segments<'src>> = FreeBusyType::Busy;
1129        let mut x_parameters = Vec::new();
1130        let mut retained_parameters = Vec::new();
1131
1132        for param in prop.parameters {
1133            match param {
1134                Parameter::FreeBusyType { value, .. } => {
1135                    fb_type = value.clone();
1136                }
1137                Parameter::XName(raw) => x_parameters.push(raw),
1138                p @ Parameter::Unrecognized { .. } => retained_parameters.push(p),
1139                p => {
1140                    // Preserve other parameters not used by this property for round-trip
1141                    retained_parameters.push(p);
1142                }
1143            }
1144        }
1145
1146        let (periods, value_span) = match prop.value {
1147            Value::Period { values, span: _ } if values.is_empty() => {
1148                return Err(vec![TypedError::PropertyMissingValue {
1149                    property: prop.kind,
1150                    span: prop.span,
1151                }]);
1152            }
1153            Value::Period { values, span } => (values, span),
1154            v => {
1155                const EXPECTED: &[ValueType<String>] = &[ValueType::Period];
1156                let span = v.span();
1157                return Err(vec![TypedError::PropertyUnexpectedValue {
1158                    property: prop.kind,
1159                    expected: EXPECTED,
1160                    found: v.kind().into(),
1161                    span,
1162                }]);
1163            }
1164        };
1165
1166        let mut values = Vec::with_capacity(periods.len());
1167        let mut errors: Vec<TypedError<'src>> = Vec::new();
1168        for value_period in periods {
1169            let period = match value_period {
1170                // Both start and end have the same UTC flag (guaranteed by parser)
1171                ValuePeriod::Explicit { start, end } if start.time.utc => Period::ExplicitUtc {
1172                    start_date: start.date,
1173                    start_time: start.time.into(),
1174                    end_date: end.date,
1175                    end_time: end.time.into(),
1176                },
1177                ValuePeriod::Explicit { start, end } => Period::ExplicitFloating {
1178                    start_date: start.date,
1179                    start_time: start.time.into(),
1180                    end_date: end.date,
1181                    end_time: end.time.into(),
1182                },
1183                ValuePeriod::Duration { start, duration } => {
1184                    // Only positive durations are valid for periods
1185                    if !matches!(duration, ValueDuration::DateTime { positive: true, .. })
1186                        && !matches!(duration, ValueDuration::Week { positive: true, .. })
1187                    {
1188                        // Use the overall value span since individual periods don't have spans
1189                        errors.push(TypedError::PropertyInvalidValue {
1190                            property: prop.kind.clone(),
1191                            value: "Duration must be positive for periods".to_string(),
1192                            span: value_span,
1193                        });
1194                        continue;
1195                    }
1196
1197                    if start.time.utc {
1198                        Period::DurationUtc {
1199                            start_date: start.date,
1200                            start_time: start.time.into(),
1201                            duration,
1202                        }
1203                    } else {
1204                        Period::DurationFloating {
1205                            start_date: start.date,
1206                            start_time: start.time.into(),
1207                            duration,
1208                        }
1209                    }
1210                }
1211            };
1212
1213            values.push(period);
1214        }
1215
1216        if !errors.is_empty() {
1217            return Err(errors);
1218        }
1219
1220        Ok(FreeBusy {
1221            fb_type,
1222            values,
1223            x_parameters,
1224            retained_parameters,
1225            span: prop.span,
1226        })
1227    }
1228}
1229
1230impl FreeBusy<Segments<'_>> {
1231    /// Convert borrowed `FreeBusy` to owned `FreeBusy`
1232    #[must_use]
1233    pub fn to_owned(&self) -> FreeBusy<String> {
1234        FreeBusy {
1235            fb_type: self.fb_type.to_owned(),
1236            values: self.values.iter().map(Period::to_owned).collect(),
1237            x_parameters: self
1238                .x_parameters
1239                .iter()
1240                .map(RawParameter::to_owned)
1241                .collect(),
1242            retained_parameters: self
1243                .retained_parameters
1244                .iter()
1245                .map(Parameter::to_owned)
1246                .collect(),
1247            span: (),
1248        }
1249    }
1250}
1251
1252define_prop_value_enum! {
1253    /// Time transparency value (RFC 5545 Section 3.8.2.7)
1254    #[derive(Default)]
1255    pub enum TimeTransparencyValue {
1256        /// Event blocks time
1257        #[default]
1258        Opaque => KW_TRANSP_OPAQUE,
1259        /// Event does not block time
1260        Transparent => KW_TRANSP_TRANSPARENT,
1261    }
1262}
1263
1264/// Time transparency for events (RFC 5545 Section 3.8.2.7)
1265#[derive(Debug, Clone, Default)]
1266pub struct TimeTransparency<S: StringStorage> {
1267    /// Transparency value
1268    pub value: TimeTransparencyValue,
1269    /// X-name parameters (custom experimental parameters)
1270    pub x_parameters: Vec<RawParameter<S>>,
1271    /// Unrecognized / Non-standard parameters (preserved for round-trip)
1272    pub retained_parameters: Vec<Parameter<S>>,
1273    /// Span of the property in the source
1274    pub span: S::Span,
1275}
1276
1277impl<'src> TryFrom<ParsedProperty<'src>> for TimeTransparency<Segments<'src>> {
1278    type Error = Vec<TypedError<'src>>;
1279
1280    fn try_from(prop: ParsedProperty<'src>) -> Result<Self, Self::Error> {
1281        if !matches!(prop.kind, PropertyKind::Transp) {
1282            return Err(vec![TypedError::PropertyUnexpectedKind {
1283                expected: PropertyKind::Transp,
1284                found: prop.kind,
1285                span: prop.span,
1286            }]);
1287        }
1288
1289        let mut x_parameters = Vec::new();
1290        let mut retained_parameters = Vec::new();
1291
1292        for param in prop.parameters {
1293            match param {
1294                Parameter::XName(raw) => x_parameters.push(raw),
1295                p @ Parameter::Unrecognized { .. } => retained_parameters.push(p),
1296                p => {
1297                    // Preserve other parameters not used by this property for round-trip
1298                    retained_parameters.push(p);
1299                }
1300            }
1301        }
1302
1303        let value_span = prop.value.span();
1304        let text = take_single_text(&PropertyKind::Transp, prop.value)?;
1305        let value = text.try_into().map_err(|value| {
1306            vec![TypedError::PropertyInvalidValue {
1307                property: PropertyKind::Transp,
1308                value: format!("Invalid time transparency value: {value}"),
1309                span: value_span,
1310            }]
1311        })?;
1312
1313        Ok(TimeTransparency {
1314            value,
1315            x_parameters,
1316            retained_parameters,
1317            span: prop.span,
1318        })
1319    }
1320}
1321
1322impl TimeTransparency<Segments<'_>> {
1323    /// Convert borrowed `TimeTransparency` to owned `TimeTransparency`
1324    #[must_use]
1325    pub fn to_owned(&self) -> TimeTransparency<String> {
1326        TimeTransparency {
1327            value: self.value,
1328            x_parameters: self
1329                .x_parameters
1330                .iter()
1331                .map(RawParameter::to_owned)
1332                .collect(),
1333            retained_parameters: self
1334                .retained_parameters
1335                .iter()
1336                .map(Parameter::to_owned)
1337                .collect(),
1338            span: (),
1339        }
1340    }
1341}