use crate::common::property::{Parameter, Property};
use crate::error::{Error, Result};
const MAX_LINE_OCTETS: usize = 75;
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(); if chars.peek() == Some(&' ') || chars.peek() == Some(&'\t') {
chars.next(); } else {
result.push('\r');
result.push('\n');
}
} else {
result.push('\r');
}
} else if ch == '\n' {
if chars.peek() == Some(&' ') || chars.peek() == Some(&'\t') {
chars.next(); } else {
result.push('\n');
}
} else {
result.push(ch);
}
}
result
}
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 };
let end = std::cmp::min(pos + max_chunk, bytes.len());
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
}
pub fn parse_content_line(line: &str, line_number: usize) -> Result<Property> {
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..];
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(),
})
}
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
}
fn parse_name_and_params(s: &str, line_number: usize) -> Result<(String, Vec<Parameter>)> {
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))
}
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..];
let values = split_param_values(value_str);
Ok(Parameter { name, values })
}
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
}
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
}
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);
for part in folded.split("\r\n") {
if !part.is_empty() {
assert!(
part.len() <= MAX_LINE_OCTETS,
"Line too long ({} octets): {}",
part.len(),
part
);
}
}
let unfolded = unfold(&folded);
let unfolded = unfolded.trim_end_matches("\r\n");
assert_eq!(unfolded, line);
}
#[test]
fn fold_multibyte_chars() {
let line = "SUMMARY:".to_string() + &"a".repeat(65) + "日本語テスト";
let folded = fold_line(&line);
for part in folded.split("\r\n") {
assert!(part.len() <= MAX_LINE_OCTETS || !part.is_ascii());
}
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");
}
}