use std::fmt::Write as _;
use jiff::civil::Date;
use crate::calendar::Week;
#[derive(Debug, Clone)]
pub struct Release {
pub project: String,
pub version: String,
pub title: Option<String>,
pub week: Week,
}
#[derive(Debug, Clone)]
pub struct Focus {
pub week: Week,
pub items: Vec<String>,
}
pub fn render(releases: &[Release], focus: &[Focus], stamp: &str) -> String {
let mut lines = vec![
"BEGIN:VCALENDAR".to_string(),
"VERSION:2.0".to_string(),
format!("PRODID:-//rigger//rigger {}//EN", env!("CARGO_PKG_VERSION")),
"CALSCALE:GREGORIAN".to_string(),
"X-WR-CALNAME:rigger".to_string(),
];
for week in focus {
let mut summary = format!("Focus: {}", week.items.join(", "));
if week.items.is_empty() {
summary = "Focus".to_string();
}
lines.extend(event(
&format!("focus-{}@rigger", week.week),
stamp,
week.week.monday(),
week.week.friday().tomorrow().unwrap_or(week.week.friday()),
&summary,
&format!("The week {} - releases on {}", week.week, week.week.friday()),
));
}
for release in releases {
let summary = match &release.title {
Some(title) => format!("{} {} · {title}", release.project, release.version),
None => format!("{} {}", release.project, release.version),
};
let friday = release.week.friday();
lines.extend(event(
&format!("release-{}-{}@rigger", uid_part(&release.project), uid_part(&release.version)),
stamp,
friday,
friday.tomorrow().unwrap_or(friday),
&summary,
&format!("Aimed at {}", release.week),
));
}
lines.push("END:VCALENDAR".to_string());
let mut out = String::new();
for line in lines {
let _ = write!(out, "{}\r\n", fold(&line));
}
out
}
fn event(uid: &str, stamp: &str, start: Date, end: Date, summary: &str, description: &str) -> Vec<String> {
vec![
"BEGIN:VEVENT".to_string(),
format!("UID:{uid}"),
format!("DTSTAMP:{stamp}"),
format!("DTSTART;VALUE=DATE:{}", basic(start)),
format!("DTEND;VALUE=DATE:{}", basic(end)),
format!("SUMMARY:{}", escape(summary)),
format!("DESCRIPTION:{}", escape(description)),
"TRANSP:TRANSPARENT".to_string(),
"END:VEVENT".to_string(),
]
}
fn basic(date: Date) -> String {
date.to_string().replace('-', "")
}
pub fn stamp(now: jiff::Timestamp) -> String {
now.strftime("%Y%m%dT%H%M%SZ").to_string()
}
fn uid_part(text: &str) -> String {
text.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' { c } else { '_' })
.collect()
}
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'\\' => out.push_str("\\\\"),
';' => out.push_str("\\;"),
',' => out.push_str("\\,"),
'\n' => out.push_str("\\n"),
'\r' => {}
other => out.push(other),
}
}
out
}
fn fold(line: &str) -> String {
const LIMIT: usize = 75;
if line.len() <= LIMIT {
return line.to_string();
}
let mut out = String::new();
let mut width = 0;
for c in line.chars() {
if width + c.len_utf8() > LIMIT {
out.push_str("\r\n ");
width = 1;
}
out.push(c);
width += c.len_utf8();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn week(text: &str) -> Week {
Week::parse(text).unwrap()
}
#[test]
fn a_release_is_a_whole_day_on_its_friday() {
let file = render(
&[Release {
project: "widget".into(),
version: "v0.2.0".into(),
title: Some("Second".into()),
week: week("2026-W39"),
}],
&[],
"20260922T100000Z",
);
assert!(file.starts_with("BEGIN:VCALENDAR\r\n"), "{file}");
assert!(file.ends_with("END:VCALENDAR\r\n"), "{file}");
assert!(file.contains("UID:release-widget-v0.2.0@rigger\r\n"), "{file}");
assert!(file.contains("DTSTART;VALUE=DATE:20260925\r\n"), "{file}");
assert!(file.contains("DTEND;VALUE=DATE:20260926\r\n"), "{file}");
assert!(file.contains("SUMMARY:widget v0.2.0 · Second\r\n"), "{file}");
assert!(!file.replace("\r\n", "").contains('\n'), "{file}");
}
#[test]
fn a_focus_spans_the_working_week() {
let file = render(
&[],
&[Focus {
week: week("2026-W39"),
items: vec!["widget v0.2.0".into(), "sample v0.3.0".into()],
}],
"20260922T100000Z",
);
assert!(file.contains("UID:focus-2026-W39@rigger\r\n"), "{file}");
assert!(file.contains("DTSTART;VALUE=DATE:20260921\r\n"), "{file}");
assert!(file.contains("DTEND;VALUE=DATE:20260926\r\n"), "{file}");
assert!(file.contains("SUMMARY:Focus: widget v0.2.0\\, sample v0.3.0\r\n"), "{file}");
}
#[test]
fn text_is_escaped_and_long_lines_fold_between_characters() {
assert_eq!(escape("a;b,c\\d\ne"), "a\\;b\\,c\\\\d\\ne");
let long = format!("SUMMARY:{}", "Ритм и факты ".repeat(10));
let folded = fold(&long);
for piece in folded.split("\r\n") {
assert!(piece.len() <= 75, "{} octets: {piece}", piece.len());
}
assert_eq!(folded.replace("\r\n ", ""), long);
}
#[test]
fn a_uid_is_made_of_what_the_event_is_about() {
assert_eq!(uid_part("my app/v1"), "my_app_v1");
}
}