daaki-message 0.2.0

RFC 5322 email message parser and builder
Documentation
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;

#[test]
fn split_header_body_crlf() {
    let input = b"From: a@b.com\r\nSubject: test\r\n\r\nBody";
    let (h, b) = split_header_body(input);
    assert_eq!(h, b"From: a@b.com\r\nSubject: test");
    assert_eq!(b, b"Body");
}

#[test]
fn split_header_body_lf() {
    let input = b"From: a@b.com\nSubject: test\n\nBody";
    let (h, b) = split_header_body(input);
    assert_eq!(h, b"From: a@b.com\nSubject: test");
    assert_eq!(b, b"Body");
}

#[test]
fn split_header_body_cr() {
    let input = b"From: a@b.com\rSubject: test\r\rBody";
    let (h, b) = split_header_body(input);
    assert_eq!(h, b"From: a@b.com\rSubject: test");
    assert_eq!(b, b"Body");
}

#[test]
fn split_header_body_empty_headers() {
    let input = b"\r\nBody text";
    let (h, b) = split_header_body(input);
    assert!(h.is_empty());
    assert_eq!(b, b"Body text");
}

#[test]
fn split_header_body_no_separator() {
    let input = b"From: a@b.com\r\nSubject: test";
    let (h, b) = split_header_body(input);
    assert_eq!(h, input.as_slice());
    assert!(b.is_empty());
}

#[test]
fn parse_headers_basic() {
    let raw = b"From: a@b.com\r\nSubject: Test\r\n";
    let headers = parse_headers(raw);
    assert_eq!(headers.len(), 2);
    assert_eq!(headers[0], ("from".to_string(), "a@b.com".to_string()));
    assert_eq!(headers[1], ("subject".to_string(), "Test".to_string()));
}

#[test]
fn parse_headers_bare_cr_line_endings() {
    let raw = b"From: a@b.com\rSubject: Test\r";
    let headers = parse_headers(raw);
    assert_eq!(headers.len(), 2);
    assert_eq!(headers[0], ("from".to_string(), "a@b.com".to_string()));
    assert_eq!(headers[1], ("subject".to_string(), "Test".to_string()));
}

#[test]
fn parse_headers_continuation_line() {
    let raw = b"Subject: This is a very long\r\n subject line that wraps\r\n";
    let headers = parse_headers(raw);
    assert_eq!(headers.len(), 1);
    assert_eq!(headers[0].1, "This is a very long subject line that wraps");
}

/// RFC 5322 Section 2.2.3: unfolding removes only the CRLF, not the
/// WSP that begins the continuation line. If the field body starts on
/// the first continuation line, that leading WSP is still part of the
/// unfolded field body.
#[test]
fn parse_headers_first_continuation_line_preserves_leading_wsp() {
    let raw = b"Subject:\r\n\tWorld\r\n";
    let headers = parse_headers(raw);
    assert_eq!(headers.len(), 1);
    assert_eq!(
        headers[0].1, "\tWorld",
        "RFC 5322 Section 2.2.3: unfolding must preserve the continuation line's leading WSP"
    );
}

/// RFC 5322 Section 2.2.3: unfolding removes only the CRLF. If the first
/// physical line contains only WSP after `field-name:`, that WSP remains
/// part of the unfolded field body when the first visible octet appears on
/// the continuation line.
#[test]
fn parse_headers_preserves_initial_wsp_before_first_continuation_content() {
    let raw = b"Subject: \r\n\tWorld\r\n";
    let headers = parse_headers(raw);
    assert_eq!(headers.len(), 1);
    assert_eq!(
        headers[0].1, " \tWorld",
        "RFC 5322 Section 2.2.3: unfolding must preserve both the first line's WSP and the continuation line's WSP"
    );
}

#[test]
fn parse_headers_invalid_name_skipped() {
    let raw = b"Valid: yes\r\n: no-name\r\nAlso-Valid: yes\r\n";
    let headers = parse_headers(raw);
    assert_eq!(headers.len(), 2);
    assert_eq!(headers[0].0, "valid");
    assert_eq!(headers[1].0, "also-valid");
}

#[test]
fn is_valid_header_name_rejects_controls() {
    assert!(!is_valid_header_name(""));
    assert!(!is_valid_header_name("foo:bar"));
    assert!(!is_valid_header_name("foo bar"));
    assert!(!is_valid_header_name("\x00bad"));
}

#[test]
fn is_valid_header_name_accepts_ascii() {
    assert!(is_valid_header_name("From"));
    assert!(is_valid_header_name("X-Custom-Header"));
}

#[test]
fn looks_like_headerless_body_text() {
    assert!(looks_like_headerless_body(b"Hello from the body"));
    assert!(!looks_like_headerless_body(b"\x00binary"));
    assert!(!looks_like_headerless_body(b""));
}

#[test]
fn split_mime_parts_basic() {
    let body = b"--boundary\r\nContent-Type: text/plain\r\n\r\nHello\r\n--boundary--";
    let parts = split_mime_parts(body, "boundary");
    assert_eq!(parts.len(), 1);
    assert_eq!(parts[0], b"Content-Type: text/plain\r\n\r\nHello");
}

#[test]
fn split_mime_parts_truncated() {
    let body = b"--boundary\r\nContent-Type: text/plain\r\n\r\nHello";
    let parts = split_mime_parts(body, "boundary");
    assert_eq!(parts.len(), 1);
    assert_eq!(parts[0], b"Content-Type: text/plain\r\n\r\nHello");
}

#[test]
fn find_subsequence_found() {
    assert_eq!(find_subsequence(b"hello world", b"world"), Some(6));
    assert_eq!(find_subsequence(b"hello world", b"hello"), Some(0));
}

#[test]
fn find_subsequence_not_found() {
    assert_eq!(find_subsequence(b"hello", b"world"), None);
}

#[test]
fn parse_wire_empty_input() {
    let result = parse_wire(b"");
    assert!(matches!(result, Err(Error::EmptyInput)));
}

#[test]
fn parse_wire_basic_message() {
    let raw = b"From: a@b.com\r\nSubject: Test\r\n\r\nBody";
    let wire = parse_wire(raw).unwrap();
    assert_eq!(wire.headers.len(), 2);
    assert_eq!(wire.body, b"Body");
    assert_eq!(wire.size, raw.len() as u64);
    assert!(!wire.headerless);
    assert!(wire.raw_headers.contains("From: a@b.com"));
}

#[test]
fn parse_wire_bare_cr_line_endings() {
    let raw = b"From: a@b.com\rSubject: Test\r\rBody";
    let wire = parse_wire(raw).unwrap();
    assert_eq!(wire.headers.len(), 2);
    assert_eq!(wire.body, b"Body");
    assert!(!wire.headerless);
}

#[test]
fn parse_wire_headerless_body() {
    let raw = b"Hello from body only";
    let wire = parse_wire(raw).unwrap();
    assert!(wire.headers.is_empty());
    assert_eq!(wire.body, raw);
    assert!(wire.headerless);
    assert!(wire.raw_headers.is_empty());
}

/// RFC 5322 Section 2.2 / RFC 6532 Section 3.2: field names remain
/// ASCII-only even when field bodies allow UTF-8.
#[test]
fn parse_headers_non_ascii_name_skipped() {
    let input = "X-\u{0130}: value\r\n\r\n";
    let headers = parse_headers(input.as_bytes());
    assert!(headers.is_empty(), "non-ASCII header names must be skipped");
}

/// MSG-009: A leading bare CR followed by a printable ASCII character (potential
/// header field name) must NOT be treated as an empty-header separator.
/// Only treat a leading bare CR as a separator when followed by another CR or LF
/// (Postel's law, RFC 1122 Section 1.2.2).
#[test]
fn regression_msg009_leading_bare_cr_does_not_eat_headers() {
    // The \r at position 0 is NOT followed by \n, \r, or end-of-input;
    // it is followed by 'F' (start of "From:"). The parser must NOT
    // treat it as an empty header separator.
    let input = b"\rFrom: user@example.com\r\nSubject: Test\r\n\r\nBody";
    let (headers, body) = split_header_body(input);
    // Headers must contain the From and Subject fields, not be empty.
    assert!(
        !headers.is_empty(),
        "Leading bare CR followed by header field name must not produce empty headers"
    );
    let parsed_headers = parse_headers(headers);
    assert!(
        parsed_headers.iter().any(|(k, _)| k == "from"),
        "From header must be parsed; got headers: {parsed_headers:?}",
    );
    assert_eq!(body, b"Body");
}