ezcal 0.3.4

Ergonomic iCalendar + vCard library for Rust
Documentation
use crate::common::property::{Parameter, Property};
use crate::error::{Error, Result};

/// Maximum line length in octets before folding (per RFC 5545 §3.1).
const MAX_LINE_OCTETS: usize = 75;

/// Unfold content lines per RFC 5545 §3.1.
///
/// Lines that begin with a single space or horizontal tab are continuations
/// of the previous line. The CRLF + whitespace sequence is removed.
pub fn unfold(input: &str) -> String {
    let mut result = String::with_capacity(input.len());
    let mut chars = input.chars().peekable();
    while let Some(ch) = chars.next() {
        if ch == '\r' {
            if chars.peek() == Some(&'\n') {
                chars.next(); // consume \n
                // If next char is a space or tab, it's a continuation
                if chars.peek() == Some(&' ') || chars.peek() == Some(&'\t') {
                    chars.next(); // consume the folding whitespace
                } else {
                    result.push('\r');
                    result.push('\n');
                }
            } else {
                result.push('\r');
            }
        } else if ch == '\n' {
            // Bare LF (be lenient)
            if chars.peek() == Some(&' ') || chars.peek() == Some(&'\t') {
                chars.next(); // consume the folding whitespace
            } else {
                result.push('\n');
            }
        } else {
            result.push(ch);
        }
    }
    result
}

/// Fold a single content line to respect the 75-octet limit per RFC 5545 §3.1.
///
/// The line should NOT include a trailing CRLF; this function adds CRLF at the end.
pub fn fold_line(line: &str) -> String {
    let bytes = line.as_bytes();
    if bytes.len() <= MAX_LINE_OCTETS {
        let mut s = line.to_string();
        s.push_str("\r\n");
        return s;
    }

    let mut result = String::with_capacity(bytes.len() + bytes.len() / MAX_LINE_OCTETS * 3);
    let mut pos = 0;
    let mut first = true;

    while pos < bytes.len() {
        let max_chunk = if first {
            MAX_LINE_OCTETS
        } else {
            MAX_LINE_OCTETS - 1 // account for the leading space
        };

        let end = std::cmp::min(pos + max_chunk, bytes.len());

        // Don't split in the middle of a multi-byte UTF-8 character
        let mut split_at = end;
        while split_at > pos && !line.is_char_boundary(split_at) {
            split_at -= 1;
        }

        if !first {
            result.push_str("\r\n ");
        }
        result.push_str(&line[pos..split_at]);

        pos = split_at;
        first = false;
    }

    result.push_str("\r\n");
    result
}

/// Parse a single unfolded content line into a `Property`.
///
/// Format: `name *(";" param) ":" value`
pub fn parse_content_line(line: &str, line_number: usize) -> Result<Property> {
    // Find the first unquoted colon to separate name+params from value
    let colon_pos = find_value_separator(line).ok_or_else(|| {
        Error::parse(
            line_number,
            format!(
                "missing ':' separator in content line: {}",
                truncate(line, 60)
            ),
        )
    })?;

    let name_and_params = &line[..colon_pos];
    let value = &line[colon_pos + 1..];

    // Split name from params at the first semicolon
    let (name, params) = parse_name_and_params(name_and_params, line_number)?;

    if name.is_empty() {
        return Err(Error::parse(line_number, "empty property name"));
    }

    Ok(Property {
        name: name.to_uppercase(),
        params,
        value: value.to_string(),
    })
}

/// Find the position of the colon that separates the property name+params from the value.
/// Must skip colons inside quoted parameter values.
fn find_value_separator(line: &str) -> Option<usize> {
    let mut in_quotes = false;
    for (i, ch) in line.char_indices() {
        match ch {
            '"' => in_quotes = !in_quotes,
            ':' if !in_quotes => return Some(i),
            _ => {}
        }
    }
    None
}

/// Parse the part before the colon into a property name and parameters.
fn parse_name_and_params(s: &str, line_number: usize) -> Result<(String, Vec<Parameter>)> {
    // Split on semicolons, respecting quotes
    let parts = split_respecting_quotes(s, ';');
    let name = parts
        .first()
        .ok_or_else(|| Error::parse(line_number, "empty content line"))?
        .to_string();

    let mut params = Vec::new();
    for part in &parts[1..] {
        params.push(parse_parameter(part, line_number)?);
    }

    Ok((name, params))
}

/// Parse a single parameter string like `TZID=America/New_York` or `TYPE=WORK,VOICE`.
fn parse_parameter(s: &str, line_number: usize) -> Result<Parameter> {
    let eq_pos = s.find('=').ok_or_else(|| {
        Error::parse(
            line_number,
            format!("missing '=' in parameter: {}", truncate(s, 40)),
        )
    })?;

    let name = s[..eq_pos].to_uppercase();
    let value_str = &s[eq_pos + 1..];

    // Split values on commas, respecting quotes
    let values = split_param_values(value_str);

    Ok(Parameter { name, values })
}

/// Split parameter values on commas, stripping quotes.
fn split_param_values(s: &str) -> Vec<String> {
    let mut values = Vec::new();
    let mut current = String::new();
    let mut in_quotes = false;

    for ch in s.chars() {
        match ch {
            '"' => in_quotes = !in_quotes,
            ',' if !in_quotes => {
                values.push(current.clone());
                current.clear();
            }
            _ => current.push(ch),
        }
    }
    values.push(current);
    values
}

/// Split a string on a delimiter, respecting quoted sections.
fn split_respecting_quotes(s: &str, delim: char) -> Vec<String> {
    let mut parts = Vec::new();
    let mut current = String::new();
    let mut in_quotes = false;

    for ch in s.chars() {
        match ch {
            '"' => {
                in_quotes = !in_quotes;
                current.push(ch);
            }
            c if c == delim && !in_quotes => {
                parts.push(current.clone());
                current.clear();
            }
            _ => current.push(ch),
        }
    }
    parts.push(current);
    parts
}

/// Split unfolded text into individual content lines.
pub fn split_lines(unfolded: &str) -> Vec<&str> {
    let mut lines = Vec::new();
    for line in unfolded.split('\n') {
        let line = line.strip_suffix('\r').unwrap_or(line);
        if !line.is_empty() {
            lines.push(line);
        }
    }
    lines
}

fn truncate(s: &str, max: usize) -> &str {
    if s.len() <= max { s } else { &s[..max] }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn unfold_simple() {
        let input =
            "DESCRIPTION:This is a long\r\n  description that spans\r\n  multiple lines.\r\n";
        let expected = "DESCRIPTION:This is a long description that spans multiple lines.\r\n";
        assert_eq!(unfold(input), expected);
    }

    #[test]
    fn unfold_bare_lf() {
        let input = "DESCRIPTION:This is\n continued\n";
        let expected = "DESCRIPTION:This iscontinued\n";
        assert_eq!(unfold(input), expected);
    }

    #[test]
    fn unfold_tab_continuation() {
        let input = "DESCRIPTION:This is\r\n\tcontinued\r\n";
        let expected = "DESCRIPTION:This iscontinued\r\n";
        assert_eq!(unfold(input), expected);
    }

    #[test]
    fn fold_short_line() {
        let line = "SUMMARY:Short";
        let folded = fold_line(line);
        assert_eq!(folded, "SUMMARY:Short\r\n");
    }

    #[test]
    fn fold_long_line() {
        let line = "DESCRIPTION:".to_string() + &"x".repeat(100);
        let folded = fold_line(&line);

        // Verify all lines are <= 75 octets
        for part in folded.split("\r\n") {
            if !part.is_empty() {
                assert!(
                    part.len() <= MAX_LINE_OCTETS,
                    "Line too long ({} octets): {}",
                    part.len(),
                    part
                );
            }
        }

        // Verify round-trip
        let unfolded = unfold(&folded);
        let unfolded = unfolded.trim_end_matches("\r\n");
        assert_eq!(unfolded, line);
    }

    #[test]
    fn fold_multibyte_chars() {
        // 70 ASCII chars + some multi-byte chars to push past 75
        let line = "SUMMARY:".to_string() + &"a".repeat(65) + "日本語テスト";
        let folded = fold_line(&line);

        // Verify no split in the middle of a UTF-8 character
        for part in folded.split("\r\n") {
            // Each part should be valid UTF-8 (it is, since it's a &str)
            assert!(part.len() <= MAX_LINE_OCTETS || !part.is_ascii());
        }

        // Verify round-trip
        let unfolded = unfold(&folded);
        let unfolded = unfolded.trim_end_matches("\r\n");
        assert_eq!(unfolded, line);
    }

    #[test]
    fn parse_simple_property() {
        let prop = parse_content_line("SUMMARY:Team Standup", 1).unwrap();
        assert_eq!(prop.name, "SUMMARY");
        assert_eq!(prop.value, "Team Standup");
        assert!(prop.params.is_empty());
    }

    #[test]
    fn parse_property_with_params() {
        let prop = parse_content_line(
            "DTSTART;VALUE=DATE-TIME;TZID=America/New_York:20260315T090000",
            1,
        )
        .unwrap();
        assert_eq!(prop.name, "DTSTART");
        assert_eq!(prop.value, "20260315T090000");
        assert_eq!(prop.params.len(), 2);
        assert_eq!(prop.params[0].name, "VALUE");
        assert_eq!(prop.params[0].values, vec!["DATE-TIME"]);
        assert_eq!(prop.params[1].name, "TZID");
        assert_eq!(prop.params[1].values, vec!["America/New_York"]);
    }

    #[test]
    fn parse_property_with_quoted_param() {
        let prop =
            parse_content_line("ATTENDEE;CN=\"John Doe\":mailto:john@example.com", 1).unwrap();
        assert_eq!(prop.name, "ATTENDEE");
        assert_eq!(prop.value, "mailto:john@example.com");
        assert_eq!(prop.params[0].values, vec!["John Doe"]);
    }

    #[test]
    fn parse_property_with_multi_value_param() {
        let prop = parse_content_line("TEL;TYPE=WORK,VOICE:+1-555-0123", 1).unwrap();
        assert_eq!(prop.name, "TEL");
        assert_eq!(prop.value, "+1-555-0123");
        assert_eq!(prop.params[0].values, vec!["WORK", "VOICE"]);
    }

    #[test]
    fn parse_missing_colon() {
        let result = parse_content_line("INVALID LINE", 5);
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(err.contains("line 5"));
        assert!(err.contains("missing ':'"));
    }

    #[test]
    fn split_lines_basic() {
        let input = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n";
        let lines = split_lines(input);
        assert_eq!(
            lines,
            vec!["BEGIN:VCALENDAR", "VERSION:2.0", "END:VCALENDAR"]
        );
    }

    #[test]
    fn colon_in_value() {
        let prop = parse_content_line("DESCRIPTION:Meeting at 10:30", 1).unwrap();
        assert_eq!(prop.name, "DESCRIPTION");
        assert_eq!(prop.value, "Meeting at 10:30");
    }
}