tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
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);
    }
}