Skip to main content

tix/
ics.rs

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}