use crate::error::{Result, TixError};
pub struct Event {
pub uid: String,
pub summary: String,
pub description: Option<String>,
pub dtstart: Option<String>,
pub dtend: Option<String>,
}
fn escape_text(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace(';', "\\;")
.replace(',', "\\,")
.replace('\n', "\\n")
}
fn fold_line(line: &str) -> String {
if line.len() <= 75 {
return format!("{line}\r\n");
}
let mut out = String::new();
let mut start = 0usize;
let bytes = line.as_bytes();
while start < bytes.len() {
let mut count = 0usize;
let mut end = start;
let mut last_space = None;
for (off, ch) in line[start..].char_indices() {
let ch_len = ch.len_utf8();
if count + ch_len > 75 {
break;
}
count += ch_len;
end = start + off + ch_len;
if ch == ' ' {
last_space = Some(end);
}
}
if end == start {
break;
}
let cut = if end < bytes.len() {
last_space.unwrap_or(end)
} else {
end
};
out.push_str(&line[start..cut]);
if cut < bytes.len() {
out.push_str("\r\n ");
start = cut;
while start < bytes.len() && line.as_bytes()[start] == b' ' {
start += 1;
}
} else {
start = cut;
}
}
out.push_str("\r\n");
out
}
pub fn write_vcalendar(events: &[Event]) -> Result<String> {
let mut out = String::new();
out.push_str("BEGIN:VCALENDAR\r\n");
out.push_str("VERSION:2.0\r\n");
out.push_str("PRODID:-//tix//tix-tracker//EN\r\n");
out.push_str("CALSCALE:GREGORIAN\r\n");
out.push_str("METHOD:PUBLISH\r\n");
for event in events {
out.push_str("BEGIN:VEVENT\r\n");
out.push_str(&fold_line(&format!("UID:{}", escape_text(&event.uid))));
out.push_str(&fold_line(&format!(
"SUMMARY:{}",
escape_text(&event.summary)
)));
if let Some(desc) = &event.description {
out.push_str(&fold_line(&format!("DESCRIPTION:{}", escape_text(desc))));
}
if let Some(start) = &event.dtstart {
out.push_str(&fold_line(&format!("DTSTART;VALUE=DATE:{start}")));
}
if let Some(end) = &event.dtend {
out.push_str(&fold_line(&format!("DTEND;VALUE=DATE:{end}")));
}
out.push_str("END:VEVENT\r\n");
}
out.push_str("END:VCALENDAR\r\n");
Ok(out)
}
pub fn write_to_path(path: &std::path::Path, contents: &str) -> Result<()> {
let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
std::fs::create_dir_all(parent).map_err(TixError::Io)?;
let tmp = parent.join(format!(
".{}.tmp",
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("calendar.ics")
));
std::fs::write(&tmp, contents).map_err(TixError::Io)?;
std::fs::rename(&tmp, path).map_err(TixError::Io)
}
pub fn ymd_to_ics(date: &str) -> Option<String> {
let mut parts = date.split('-');
let y = parts.next()?;
let m = parts.next()?;
let d = parts.next()?;
if parts.next().is_some()
|| y.len() != 4
|| m.len() != 2
|| d.len() != 2
|| !y.bytes().all(|b| b.is_ascii_digit())
|| !m.bytes().all(|b| b.is_ascii_digit())
|| !d.bytes().all(|b| b.is_ascii_digit())
{
return None;
}
Some(format!("{y}{m}{d}"))
}
pub fn plus_one_day(date: &str) -> Option<String> {
let mut parts = date.split('-');
let year: i32 = parts.next()?.parse().ok()?;
let month: i32 = parts.next()?.parse().ok()?;
let day: i32 = parts.next()?.parse().ok()?;
if parts.next().is_some() || !(1..=12).contains(&month) {
return None;
}
let mdays = match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
29
} else {
28
}
}
_ => return None,
};
if !(1..=mdays).contains(&day) {
return None;
}
let (ny, nm, nd) = if day < mdays {
(year, month, day + 1)
} else if month < 12 {
(year, month + 1, 1)
} else {
(year + 1, 1, 1)
};
Some(format!("{ny:04}-{nm:02}-{nd:02}"))
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
#[test]
fn escaping_summary_and_description() {
let events = vec![Event {
uid: "u1".into(),
summary: "A, B; C\\D".into(),
description: Some("line1\nline2,semi;slash\\".into()),
dtstart: Some("20260101".into()),
dtend: Some("20260102".into()),
}];
let out = write_vcalendar(&events).unwrap();
assert!(out.contains("SUMMARY:A\\, B\\; C\\\\D\r\n"));
assert!(out.contains("DESCRIPTION:line1\\nline2\\,semi\\;slash\\\\\r\n"));
}
#[test]
fn line_folding_boundaries() {
let v74 = "A".repeat(74);
let v75 = "A".repeat(75);
let v76 = "A".repeat(76);
assert_eq!(fold_line(&v74), format!("{v74}\r\n"));
assert_eq!(fold_line(&v75), format!("{v75}\r\n"));
let folded = fold_line(&v76);
assert!(
folded.contains("\r\n "),
"expected fold marker, got {folded:?}"
);
}
#[test]
fn output_is_crlf_only() {
let events = vec![Event {
uid: "u1".into(),
summary: "S".into(),
description: None,
dtstart: Some("20260101".into()),
dtend: None,
}];
let out = write_vcalendar(&events).unwrap();
let stripped = out.replace("\r\n", "");
assert!(!stripped.contains('\n'), "found bare LF in {out:?}");
assert!(out.contains("\r\n"));
}
#[test]
fn ymd_and_plus_one_day_helpers() {
assert_eq!(ymd_to_ics("2026-09-21").as_deref(), Some("20260921"));
assert_eq!(plus_one_day("2026-02-28").as_deref(), Some("2026-03-01"));
assert_eq!(plus_one_day("2024-02-28").as_deref(), Some("2024-02-29"));
assert_eq!(plus_one_day("2026-12-31").as_deref(), Some("2027-01-01"));
assert_eq!(ymd_to_ics("2026-9-1"), None);
}
}