1use crate::error::{Result, TixError};
2
3pub struct Event {
4 pub uid: String,
5 pub summary: String,
6 pub description: Option<String>,
7 pub dtstart: Option<String>,
8 pub dtend: Option<String>,
9}
10
11fn escape_text(value: &str) -> String {
12 value
13 .replace('\\', "\\\\")
14 .replace(';', "\\;")
15 .replace(',', "\\,")
16 .replace('\n', "\\n")
17}
18
19fn fold_line(line: &str) -> String {
20 if line.len() <= 75 {
21 return format!("{line}\r\n");
22 }
23
24 let mut out = String::new();
25 let mut start = 0usize;
26 let bytes = line.as_bytes();
27 while start < bytes.len() {
28 let mut count = 0usize;
29 let mut end = start;
30 let mut last_space = None;
31 for (off, ch) in line[start..].char_indices() {
32 let ch_len = ch.len_utf8();
33 if count + ch_len > 75 {
34 break;
35 }
36 count += ch_len;
37 end = start + off + ch_len;
38 if ch == ' ' {
39 last_space = Some(end);
40 }
41 }
42
43 if end == start {
44 break;
45 }
46
47 let cut = if end < bytes.len() {
48 last_space.unwrap_or(end)
49 } else {
50 end
51 };
52
53 out.push_str(&line[start..cut]);
54 if cut < bytes.len() {
55 out.push_str("\r\n ");
56 start = cut;
57 while start < bytes.len() && line.as_bytes()[start] == b' ' {
58 start += 1;
59 }
60 } else {
61 start = cut;
62 }
63 }
64 out.push_str("\r\n");
65 out
66}
67
68pub fn write_vcalendar(events: &[Event]) -> Result<String> {
69 let mut out = String::new();
70 out.push_str("BEGIN:VCALENDAR\r\n");
71 out.push_str("VERSION:2.0\r\n");
72 out.push_str("PRODID:-//tix//tix-tracker//EN\r\n");
73 out.push_str("CALSCALE:GREGORIAN\r\n");
74 out.push_str("METHOD:PUBLISH\r\n");
75 for event in events {
76 out.push_str("BEGIN:VEVENT\r\n");
77 out.push_str(&fold_line(&format!("UID:{}", escape_text(&event.uid))));
78 out.push_str(&fold_line(&format!(
79 "SUMMARY:{}",
80 escape_text(&event.summary)
81 )));
82 if let Some(desc) = &event.description {
83 out.push_str(&fold_line(&format!("DESCRIPTION:{}", escape_text(desc))));
84 }
85 if let Some(start) = &event.dtstart {
86 out.push_str(&fold_line(&format!("DTSTART;VALUE=DATE:{start}")));
87 }
88 if let Some(end) = &event.dtend {
89 out.push_str(&fold_line(&format!("DTEND;VALUE=DATE:{end}")));
90 }
91 out.push_str("END:VEVENT\r\n");
92 }
93 out.push_str("END:VCALENDAR\r\n");
94 Ok(out)
95}
96
97pub fn write_to_path(path: &std::path::Path, contents: &str) -> Result<()> {
98 let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
99 std::fs::create_dir_all(parent).map_err(TixError::Io)?;
100 let tmp = parent.join(format!(
101 ".{}.tmp",
102 path.file_name()
103 .and_then(|n| n.to_str())
104 .unwrap_or("calendar.ics")
105 ));
106 std::fs::write(&tmp, contents).map_err(TixError::Io)?;
107 std::fs::rename(&tmp, path).map_err(TixError::Io)
108}
109
110pub fn ymd_to_ics(date: &str) -> Option<String> {
111 let mut parts = date.split('-');
112 let y = parts.next()?;
113 let m = parts.next()?;
114 let d = parts.next()?;
115 if parts.next().is_some()
116 || y.len() != 4
117 || m.len() != 2
118 || d.len() != 2
119 || !y.bytes().all(|b| b.is_ascii_digit())
120 || !m.bytes().all(|b| b.is_ascii_digit())
121 || !d.bytes().all(|b| b.is_ascii_digit())
122 {
123 return None;
124 }
125 Some(format!("{y}{m}{d}"))
126}
127
128pub fn plus_one_day(date: &str) -> Option<String> {
129 let mut parts = date.split('-');
130 let year: i32 = parts.next()?.parse().ok()?;
131 let month: i32 = parts.next()?.parse().ok()?;
132 let day: i32 = parts.next()?.parse().ok()?;
133 if parts.next().is_some() || !(1..=12).contains(&month) {
134 return None;
135 }
136 let mdays = match month {
137 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
138 4 | 6 | 9 | 11 => 30,
139 2 => {
140 if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
141 29
142 } else {
143 28
144 }
145 }
146 _ => return None,
147 };
148 if !(1..=mdays).contains(&day) {
149 return None;
150 }
151 let (ny, nm, nd) = if day < mdays {
152 (year, month, day + 1)
153 } else if month < 12 {
154 (year, month + 1, 1)
155 } else {
156 (year + 1, 1, 1)
157 };
158 Some(format!("{ny:04}-{nm:02}-{nd:02}"))
159}
160
161#[cfg(test)]
162mod tests {
163 #[allow(clippy::wildcard_imports)]
164 use super::*;
165
166 #[test]
167 fn escaping_summary_and_description() {
168 let events = vec![Event {
169 uid: "u1".into(),
170 summary: "A, B; C\\D".into(),
171 description: Some("line1\nline2,semi;slash\\".into()),
172 dtstart: Some("20260101".into()),
173 dtend: Some("20260102".into()),
174 }];
175 let out = write_vcalendar(&events).unwrap();
176 assert!(out.contains("SUMMARY:A\\, B\\; C\\\\D\r\n"));
177 assert!(out.contains("DESCRIPTION:line1\\nline2\\,semi\\;slash\\\\\r\n"));
178 }
179
180 #[test]
181 fn line_folding_boundaries() {
182 let v74 = "A".repeat(74);
183 let v75 = "A".repeat(75);
184 let v76 = "A".repeat(76);
185 assert_eq!(fold_line(&v74), format!("{v74}\r\n"));
186 assert_eq!(fold_line(&v75), format!("{v75}\r\n"));
187 let folded = fold_line(&v76);
188 assert!(
189 folded.contains("\r\n "),
190 "expected fold marker, got {folded:?}"
191 );
192 }
193
194 #[test]
195 fn output_is_crlf_only() {
196 let events = vec![Event {
197 uid: "u1".into(),
198 summary: "S".into(),
199 description: None,
200 dtstart: Some("20260101".into()),
201 dtend: None,
202 }];
203 let out = write_vcalendar(&events).unwrap();
204 let stripped = out.replace("\r\n", "");
205 assert!(!stripped.contains('\n'), "found bare LF in {out:?}");
206 assert!(out.contains("\r\n"));
207 }
208
209 #[test]
210 fn ymd_and_plus_one_day_helpers() {
211 assert_eq!(ymd_to_ics("2026-09-21").as_deref(), Some("20260921"));
212 assert_eq!(plus_one_day("2026-02-28").as_deref(), Some("2026-03-01"));
213 assert_eq!(plus_one_day("2024-02-28").as_deref(), Some("2024-02-29"));
214 assert_eq!(plus_one_day("2026-12-31").as_deref(), Some("2027-01-01"));
215 assert_eq!(ymd_to_ics("2026-9-1"), None);
216 }
217}