1use crate::raw::RawProperty;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct LogicalLine<'a> {
28 pub name: String,
30 pub params: Vec<(String, String)>,
33 pub value: &'a str,
35}
36
37impl<'a> LogicalLine<'a> {
38 pub fn to_raw_property(&self, source_index: u32) -> RawProperty {
42 RawProperty {
43 name: self.name.clone(),
44 params: self.params.clone(),
45 value: self.value.to_string(),
46 source_index,
47 }
48 }
49}
50
51pub fn parse_logical_line(line: &str) -> Option<LogicalLine<'_>> {
60 let colon = line.find(':')?;
61 let prefix = &line[..colon];
62 let value = &line[colon + 1..];
63
64 let mut parts = prefix.split(';');
65 let raw_name = parts.next()?;
66 if raw_name.is_empty() {
67 return None;
68 }
69 let name = raw_name.to_uppercase();
70 let mut params = Vec::new();
71 for p in parts {
72 if let Some((k, v)) = p.split_once('=') {
73 let v = v.trim_matches('"');
74 params.push((k.to_uppercase(), v.to_string()));
75 }
76 }
77 Some(LogicalLine {
78 name,
79 params,
80 value,
81 })
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87
88 #[test]
89 fn basic_name_value() {
90 let ll = parse_logical_line("UID:abc-123").unwrap();
91 assert_eq!(ll.name, "UID");
92 assert!(ll.params.is_empty());
93 assert_eq!(ll.value, "abc-123");
94 }
95
96 #[test]
97 fn name_uppercase_normalization() {
98 let ll = parse_logical_line("uid:abc").unwrap();
99 assert_eq!(ll.name, "UID");
100 }
101
102 #[test]
103 fn single_param() {
104 let ll = parse_logical_line("DTSTART;VALUE=DATE:20260101").unwrap();
105 assert_eq!(ll.name, "DTSTART");
106 assert_eq!(ll.params, vec![("VALUE".to_string(), "DATE".to_string())]);
107 assert_eq!(ll.value, "20260101");
108 }
109
110 #[test]
111 fn multiple_params_preserve_order() {
112 let ll =
113 parse_logical_line("DTSTART;TZID=Asia/Tokyo;VALUE=DATE-TIME:20260101T090000").unwrap();
114 assert_eq!(
115 ll.params,
116 vec![
117 ("TZID".to_string(), "Asia/Tokyo".to_string()),
118 ("VALUE".to_string(), "DATE-TIME".to_string()),
119 ]
120 );
121 assert_eq!(ll.value, "20260101T090000");
122 }
123
124 #[test]
125 fn param_keys_uppercase_values_keep_case() {
126 let ll = parse_logical_line("X-FOO;lang=ja-JP:hello").unwrap();
127 assert_eq!(ll.params, vec![("LANG".to_string(), "ja-JP".to_string())]);
128 }
129
130 #[test]
131 fn quoted_param_value_strips_quotes() {
132 let ll = parse_logical_line(r#"X-FOO;LANG="ja-JP":hello"#).unwrap();
133 assert_eq!(ll.params, vec![("LANG".to_string(), "ja-JP".to_string())]);
134 }
135
136 #[test]
137 fn missing_colon_yields_none() {
138 assert!(parse_logical_line("UIDabc").is_none());
139 }
140
141 #[test]
142 fn empty_name_yields_none() {
143 assert!(parse_logical_line(":value").is_none());
144 }
145
146 #[test]
147 fn empty_value_is_ok() {
148 let ll = parse_logical_line("UID:").unwrap();
149 assert_eq!(ll.value, "");
150 }
151
152 #[test]
153 fn to_raw_property_copies_name_params_and_assigns_index() {
154 let ll = parse_logical_line("X-CUSTOM-FOO;LANG=en:hello").unwrap();
155 let rp = ll.to_raw_property(7);
156 assert_eq!(rp.name, "X-CUSTOM-FOO");
157 assert_eq!(rp.params, vec![("LANG".to_string(), "en".to_string())]);
158 assert_eq!(rp.value, "hello");
159 assert_eq!(rp.source_index, 7);
160 }
161
162 #[test]
163 fn value_can_contain_colon() {
164 let ll = parse_logical_line("DESCRIPTION:Meeting at 10:00").unwrap();
166 assert_eq!(ll.value, "Meeting at 10:00");
167 }
168
169 #[test]
170 fn multibyte_utf8_in_value() {
171 let ll = parse_logical_line("SUMMARY:憲法記念日").unwrap();
172 assert_eq!(ll.value, "憲法記念日");
173 }
174}