• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

lennart-k / ical-rs / #73

12 Jan 2026 01:50PM UTC coverage: 77.771% (-0.03%) from 77.802%
#73

Pull #2

lennart-k
IcalCalendarObjectBuilder: Make attributes public
Pull Request #2: Major overhaul

908 of 1184 new or added lines in 30 files covered. (76.69%)

32 existing lines in 10 files now uncovered.

1221 of 1570 relevant lines covered (77.77%)

2.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.14
/src/types/vcard/partial_time.rs
1
use crate::{
2
    parser::{ParseProp, ParserError},
3
    types::Value,
4
};
5
use std::{collections::HashMap, sync::OnceLock};
6

7
static RE_TIME: OnceLock<[regex::Regex; 2]> = OnceLock::new();
8

9
#[inline]
10
fn re_time() -> &'static [regex::Regex] {
4✔
11
    RE_TIME.get_or_init(|| {
5✔
12
        [
13
        regex::Regex::new(
1✔
NEW
14
            r"^(?:(?<hour>\d{2})|-)((?:(?<minute>\d{2})|-)(?<second>\d{2})?)?(?:(?<utc>Z)|(?:(?<offsign>[-+])(?<offhour>\d{2})(?<offminute>\d{2})?))?$",
×
15
        )
16
        .unwrap(),
1✔
17
        regex::Regex::new(
1✔
NEW
18
                r"^(?:(?<hour>\d{2})|-)(:(?:(?<minute>\d{2})|-)(?::(?<second>\d{2}))?)?(?:(?<utc>Z)|(?:(?<offsign>[-+])(?<offhour>\d{2})(?::(?<offminute>\d{2}))?))?$"
×
19
        )
20
        .unwrap(),
1✔
21
        ]
22
    })
23
}
24

25
/// A unified type meant to encapsulate TIME, DATE-TIME, and DATE-AND-OR-TIME from RFC 6350
26
/// Allowed combinations:
27
/// hour:minute:second
28
/// hour:minute
29
/// hour
30
/// minute:second
31
/// minute
32
/// second
33
#[derive(Debug, Clone, PartialEq, Eq, Default)]
34
pub struct PartialTime {
35
    pub(crate) hour: Option<u8>,
36
    pub(crate) minute: Option<u8>,
37
    pub(crate) second: Option<u8>,
38
    pub(crate) offset_hour: Option<i8>,
39
    pub(crate) offset_minute: Option<i8>,
40
}
41

42
impl PartialTime {
43
    pub fn parse(value: &str) -> Result<Self, ParserError> {
3✔
44
        // A time of day as specified in [ISO.8601.2004], Section 4.2.
45
        //
46
        // Reduced accuracy, as specified in [ISO.8601.2004], Section 4.2.2.3,
47
        // is permitted.
48
        //
49
        // Representation with decimal fraction, as specified in
50
        // [ISO.8601.2004], Section 4.2.2.4, is forbidden.
51
        //
52
        // The midnight hour is always represented by 00, never 24 (see
53
        // [ISO.8601.2004], Section 4.2.3).
54
        //
55
        // Truncated representation, as specified in [ISO.8601.2000], Sections
56
        // 5.3.1.4 a), b), and c), is permitted.
57
        if let Some(captures) = re_time().iter().find_map(|pattern| pattern.captures(value)) {
12✔
58
            let (offset_hour, offset_minute) = if captures.name("utc").is_some() {
13✔
59
                (Some(0), Some(0))
1✔
60
            } else {
61
                (
62
                    captures.name("offhour").map(|s| {
9✔
63
                        let sign =
64
                            matches!(captures.name("offsign").map(|s| s.as_str()), Some("-"))
6✔
65
                                .then_some(-1)
3✔
66
                                .unwrap_or(1);
3✔
67
                        sign * s.as_str().parse::<i8>().unwrap()
3✔
68
                    }),
69
                    captures.name("offminute").map(|s| {
6✔
70
                        let sign =
71
                            matches!(captures.name("offsign").map(|s| s.as_str()), Some("-"))
6✔
72
                                .then_some(-1)
2✔
73
                                .unwrap_or(1);
2✔
74
                        sign * s.as_str().parse::<i8>().unwrap()
3✔
75
                    }),
76
                )
77
            };
78
            if let Some(offset_hour) = offset_hour
4✔
79
                && !(-12..=14).contains(&offset_hour)
2✔
80
            {
81
                return Err(ParserError::InvalidPropertyValue(value.to_owned()));
1✔
82
            }
83
            if let Some(offset_minute) = offset_minute
5✔
84
                && offset_minute.abs() > 59
3✔
85
            {
86
                return Err(ParserError::InvalidPropertyValue(value.to_owned()));
1✔
87
            }
88
            let hour = captures.name("hour").map(|h| h.as_str().parse().unwrap());
14✔
89
            let minute = captures.name("minute").map(|m| m.as_str().parse().unwrap());
5✔
90
            let second = captures.name("second").map(|s| s.as_str().parse().unwrap());
5✔
91
            if let Some(hour) = hour
2✔
92
                && hour > 23
2✔
93
            {
94
                return Err(ParserError::InvalidPropertyValue(value.to_owned()));
1✔
95
            }
96
            if let Some(minute) = minute
7✔
97
                && minute > 59
3✔
98
            {
99
                return Err(ParserError::InvalidPropertyValue(value.to_owned()));
1✔
100
            }
101
            if let Some(second) = second
3✔
102
                && second > 59
2✔
103
            {
104
                return Err(ParserError::InvalidPropertyValue(value.to_owned()));
1✔
105
            }
106
            if hour.is_some() && minute.is_none() && second.is_some() {
5✔
107
                return Err(ParserError::InvalidPropertyValue(value.to_owned()));
1✔
108
            }
109
            return Ok(Self {
4✔
110
                hour,
111
                minute,
112
                second,
113
                offset_hour,
114
                offset_minute,
115
            });
116
        }
117

NEW
118
        Err(ParserError::InvalidPropertyValue(value.to_owned()))
×
119
    }
120
}
121

122
impl Value for PartialTime {
NEW
123
    fn value_type(&self) -> Option<&'static str> {
×
124
        Some("TIME")
125
    }
126

127
    fn value(&self) -> String {
4✔
128
        let tz_suffix = if let (Some(0), Some(0)) = (self.offset_hour, self.offset_minute) {
5✔
129
            "Z".to_owned()
1✔
130
        } else if self.offset_hour.is_some() || self.offset_minute.is_some() {
8✔
131
            // Must have same sign
NEW
132
            assert!(
×
133
                self.offset_hour.unwrap_or_default() * self.offset_minute.unwrap_or_default() >= 0
134
            );
135
            let off_hour = self.offset_hour.unwrap_or_default();
1✔
136
            let off_minute = self
1✔
137
                .offset_minute
138
                .map(|min| format!("{min:02}", min = min.abs()))
7✔
139
                .unwrap_or_default();
140
            let sign = if off_hour >= 0 { "+" } else { "-" };
2✔
141
            format!("{sign}{hour:02}{off_minute}", hour = off_hour.abs())
4✔
142
        } else {
143
            String::new()
4✔
144
        };
145
        if let Some(hour) = self.hour {
7✔
146
            assert!(!(self.minute.is_none() && self.second.is_some()));
7✔
147
            let minute = self
2✔
148
                .minute
149
                .map(|minute| format!("{minute:02}"))
7✔
150
                .unwrap_or_default();
151
            let second = self
1✔
152
                .second
153
                .map(|second| format!("{second:02}"))
3✔
154
                .unwrap_or_default();
155
            format!("{hour:02}{minute}{second}{tz_suffix}")
4✔
156
        } else {
157
            let minute = self
2✔
158
                .minute
159
                .map(|minute| format!("{minute:02}"))
4✔
160
                .unwrap_or("-".to_owned());
2✔
161
            let second = self
1✔
162
                .second
163
                .map(|second| format!("{second:02}"))
3✔
164
                .unwrap_or_default();
165
            format!("-{minute}{second}{tz_suffix}")
2✔
166
        }
167
    }
168
}
169

170
impl ParseProp for PartialTime {
NEW
171
    fn parse_prop(
×
172
        prop: &crate::property::ContentLine,
173
        _timezones: Option<&HashMap<String, Option<chrono_tz::Tz>>>,
174
        _default_type: &str,
175
    ) -> Result<Self, ParserError> {
NEW
176
        Self::parse(prop.value.as_deref().unwrap_or_default())
×
177
    }
178
}
179

180
#[cfg(test)]
181
mod tests {
182
    use crate::types::{PartialTime, Value};
183
    use rstest::rstest;
184

185
    #[rstest]
186
    #[case("102200", PartialTime {hour: Some(10), minute: Some(22), second: Some(0), ..Default::default()})]
187
    #[case("1022", PartialTime {hour: Some(10), minute: Some(22), ..Default::default()})]
188
    #[case("10", PartialTime {hour: Some(10), ..Default::default()})]
189
    #[case("-2200", PartialTime {minute: Some(22), second: Some(0), ..Default::default()})]
190
    #[case("--00", PartialTime {second: Some(0), ..Default::default()})]
191
    #[case("102200Z", PartialTime {hour: Some(10), minute: Some(22), second: Some(0), offset_hour: Some(0), offset_minute: Some(0)})]
192
    #[case("102200-08", PartialTime {hour: Some(10), minute: Some(22), second: Some(0), offset_hour: Some(-8), ..Default::default()})]
193
    #[case("102200-0800", PartialTime {hour: Some(10), minute: Some(22), second: Some(0), offset_hour: Some(-8), offset_minute: Some(0)})]
194
    #[case("10:22:00", PartialTime {hour: Some(10), minute: Some(22), second: Some(0), ..Default::default()})]
195
    #[case("10:22", PartialTime {hour: Some(10), minute: Some(22), ..Default::default()})]
196
    #[case("-:-:00", PartialTime {second: Some(0), ..Default::default()})]
197
    #[case("152746+0100", PartialTime {hour: Some(15), minute: Some(27), second: Some(46), offset_hour: Some(1), offset_minute: Some(0)})]
198
    #[case("15:27:46+01", PartialTime {hour: Some(15), minute: Some(27), second: Some(46), offset_hour: Some(1), ..Default::default()})]
199
    #[case("15:27:46-05:00", PartialTime {hour: Some(15), minute: Some(27), second: Some(46), offset_hour: Some(-5), offset_minute: Some(0)})]
200
    fn test_parse_time(#[case] input: &str, #[case] value: PartialTime) {
201
        let parsed = PartialTime::parse(input).unwrap();
202
        assert!(!parsed.value().ends_with('-'));
203
        let roundtrip = PartialTime::parse(&parsed.value()).unwrap();
204
        assert_eq!(parsed, value);
205
        assert_eq!(roundtrip, value);
206
    }
207

208
    #[rstest]
209
    #[case("10-00")]
210
    #[case("250000")]
211
    #[case("236000")]
212
    #[case("235060")]
213
    #[case("100000+0070")]
214
    #[case("100000-4000")]
215
    fn test_parse_time_invalid(#[case] input: &str) {
216
        assert!(PartialTime::parse(input).is_err());
217
    }
218
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc