From 01c60388db1d109198fbe3922a7b4020f94b25ed Mon Sep 17 00:00:00 2001 From: Fintan Halpenny Date: Fri, 27 Feb 2026 08:18:21 +0000 Subject: [PATCH] git-metadata: Add parsing of `CommitData` This change adds parsing of `CommitData` from raw bytes, i.e. `&[u8]`. The intended use is to allow the `radicle-*` crates to be able to parse raw commit data from any underlying Git implementation, such as the `git2` and `gix` crates. The tests are broken down into `success` cases, `error` cases, and `unit` tests. --- crates/radicle-git-metadata/src/commit.rs | 43 +++- .../src/commit/headers/parse.rs | 98 +++++++++ .../radicle-git-metadata/src/commit/parse.rs | 189 +++++++++++++++++ .../src/commit/parse/test.rs | 3 + .../src/commit/parse/test/error.rs | 154 ++++++++++++++ .../src/commit/parse/test/success.rs | 198 ++++++++++++++++++ .../src/commit/parse/test/unit.rs | 38 ++++ 7 files changed, 722 insertions(+), 1 deletion(-) create mode 100644 crates/radicle-git-metadata/src/commit/headers/parse.rs create mode 100644 crates/radicle-git-metadata/src/commit/parse.rs create mode 100644 crates/radicle-git-metadata/src/commit/parse/test.rs create mode 100644 crates/radicle-git-metadata/src/commit/parse/test/error.rs create mode 100644 crates/radicle-git-metadata/src/commit/parse/test/success.rs create mode 100644 crates/radicle-git-metadata/src/commit/parse/test/unit.rs diff --git a/crates/radicle-git-metadata/src/commit.rs b/crates/radicle-git-metadata/src/commit.rs index 7d64a1d0..a2875979 100644 --- a/crates/radicle-git-metadata/src/commit.rs +++ b/crates/radicle-git-metadata/src/commit.rs @@ -1,8 +1,11 @@ pub mod headers; pub mod trailers; +mod parse; +pub use parse::ParseError; + use core::fmt; -use std::str; +use std::str::{self, FromStr}; use headers::{Headers, Signature}; use trailers::{OwnedTrailer, Trailer}; @@ -157,6 +160,44 @@ impl CommitData { } } +impl CommitData +where + Tree: str::FromStr, + Parent: str::FromStr, + Tree::Err: std::error::Error + Send + Sync + 'static, + Parent::Err: std::error::Error + Send + Sync + 'static, +{ + /// Parse a [`CommitData`] from its raw git object bytes. + /// + /// This is the inverse of the [`fmt::Display`] implementation. The bytes + /// are expected to be valid UTF-8 and in the standard git commit object + /// format produced by `git cat-file -p `. + /// + /// Trailers are detected by scanning the last paragraph of the message + /// body (the section after the final blank line). If every non-empty line + /// in that paragraph is a valid `Token: value` pair, those lines are + /// parsed as trailers and stored separately; otherwise the whole body is + /// kept as the message with no trailers. + pub fn from_bytes(bytes: &[u8]) -> Result { + let s = str::from_utf8(bytes).map_err(ParseError::Utf8)?; + parse::parse(s) + } +} + +impl FromStr for CommitData +where + Tree: str::FromStr, + Parent: str::FromStr, + Tree::Err: std::error::Error + Send + Sync + 'static, + Parent::Err: std::error::Error + Send + Sync + 'static, +{ + type Err = ParseError; + + fn from_str(s: &str) -> Result { + parse::parse(s) + } +} + impl fmt::Display for CommitData { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { writeln!(f, "tree {}", self.tree)?; diff --git a/crates/radicle-git-metadata/src/commit/headers/parse.rs b/crates/radicle-git-metadata/src/commit/headers/parse.rs new file mode 100644 index 00000000..7d0a236b --- /dev/null +++ b/crates/radicle-git-metadata/src/commit/headers/parse.rs @@ -0,0 +1,98 @@ +use super::Headers; + +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + #[error("invalid utf-8")] + Utf8(#[source] std::str::Utf8Error), + #[error("missing tree")] + MissingTree, + #[error("invalid tree")] + InvalidTree, + #[error("invalid format")] + InvalidFormat, + #[error("invalid parent")] + InvalidParent, + #[error("invalid header")] + InvalidHeader, + #[error("invalid author")] + InvalidAuthor, + #[error("missing author")] + MissingAuthor, + #[error("invalid committer")] + InvalidCommitter, + #[error("missing committer")] + MissingCommitter, +} + +pub fn parse_commit_header< + Tree: std::str::FromStr, + Parent: std::str::FromStr, + Signature: std::str::FromStr, +>( + header: &str, +) -> Result<(Tree, Vec, Signature, Signature, Headers), ParseError> { + let mut lines = header.lines(); + + let tree = match lines.next() { + Some(tree) => tree + .strip_prefix("tree ") + .map(Tree::from_str) + .transpose() + .map_err(|_| ParseError::InvalidTree)? + .ok_or(ParseError::MissingTree)?, + None => return Err(ParseError::MissingTree), + }; + + let mut parents = Vec::new(); + let mut author: Option = None; + let mut committer: Option = None; + let mut headers = Headers::new(); + + for line in lines { + // Check if a signature is still being parsed + if let Some(rest) = line.strip_prefix(' ') { + let value: &mut String = headers + .0 + .last_mut() + .map(|(_, v)| v) + .ok_or(ParseError::InvalidFormat)?; + value.push('\n'); + value.push_str(rest); + continue; + } + + if let Some((name, value)) = line.split_once(' ') { + match name { + "parent" => parents.push( + value + .parse::() + .map_err(|_| ParseError::InvalidParent)?, + ), + "author" => { + author = Some( + value + .parse::() + .map_err(|_| ParseError::InvalidAuthor)?, + ) + } + "committer" => { + committer = Some( + value + .parse::() + .map_err(|_| ParseError::InvalidCommitter)?, + ) + } + _ => headers.push(name, value), + } + continue; + } + } + + Ok(( + tree, + parents, + author.ok_or(ParseError::MissingAuthor)?, + committer.ok_or(ParseError::MissingCommitter)?, + headers, + )) +} diff --git a/crates/radicle-git-metadata/src/commit/parse.rs b/crates/radicle-git-metadata/src/commit/parse.rs new file mode 100644 index 00000000..e555898e --- /dev/null +++ b/crates/radicle-git-metadata/src/commit/parse.rs @@ -0,0 +1,189 @@ +#[cfg(test)] +mod test; + +use std::borrow::Cow; + +use crate::author::Author; + +use super::{ + headers::Headers, + trailers::{OwnedTrailer, Token, Trailer}, + CommitData, +}; + +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + #[error("the provided commit data contained invalid UTF-8")] + Utf8(#[source] std::str::Utf8Error), + #[error("the commit header is missing the 'tree' entry")] + MissingTree, + #[error("failed to parse 'tree' value: {0}")] + InvalidTree(#[source] Box), + #[error("invalid format: {reason}")] + InvalidFormat { reason: &'static str }, + #[error("failed to parse 'parent' value: {0}")] + InvalidParent(#[source] Box), + #[error("invalid header")] + InvalidHeader, + #[error("failed to parse 'author' value: {0}")] + InvalidAuthor(#[source] Box), + #[error("the commit header is missing the 'author' entry")] + MissingAuthor, + #[error("failed to parse 'committer' value: {0}")] + InvalidCommitter(#[source] Box), + #[error("the commit header is missing the 'committer' entry")] + MissingCommitter, +} + +pub(super) fn parse( + commit: &str, +) -> Result, ParseError> +where + Tree::Err: std::error::Error + Send + Sync + 'static, + Parent::Err: std::error::Error + Send + Sync + 'static, +{ + // The header and body are separated by the first blank line. + let (header, body) = commit.split_once("\n\n").ok_or(ParseError::InvalidFormat { + reason: "commit headers and body must be separated by a blank line", + })?; + + let (tree, parents, author, committer, headers) = + parse_headers::(header)?; + + let (message, trailers) = parse_body(body); + + Ok(CommitData { + tree, + parents, + author, + committer, + headers, + message, + trailers, + }) +} + +fn parse_headers( + header: &str, +) -> Result<(Tree, Vec, Signature, Signature, Headers), ParseError> +where + Tree::Err: std::error::Error + Send + Sync + 'static, + Parent::Err: std::error::Error + Send + Sync + 'static, + Signature::Err: std::error::Error + Send + Sync + 'static, +{ + let mut lines = header.lines(); + + let tree = lines + .next() + .ok_or(ParseError::MissingTree)? + .strip_prefix("tree ") + .map(Tree::from_str) + .transpose() + .map_err(|err| ParseError::InvalidTree(Box::new(err)))? + .ok_or(ParseError::MissingTree)?; + + let mut parents = Vec::new(); + let mut author: Option = None; + let mut committer: Option = None; + let mut headers = Headers::new(); + + for line in lines { + // Check if a signature is still being parsed + if let Some(rest) = line.strip_prefix(' ') { + let value: &mut String = + headers + .0 + .last_mut() + .map(|(_, v)| v) + .ok_or(ParseError::InvalidFormat { + reason: "failed to parse extra header", + })?; + value.push('\n'); + value.push_str(rest); + continue; + } + + if let Some((name, value)) = line.split_once(' ') { + match name { + "parent" => parents.push( + value + .parse::() + .map_err(|err| ParseError::InvalidParent(Box::new(err)))?, + ), + "author" => { + author = Some( + value + .parse::() + .map_err(|err| ParseError::InvalidAuthor(Box::new(err)))?, + ) + } + "committer" => { + committer = Some( + value + .parse::() + .map_err(|err| ParseError::InvalidCommitter(Box::new(err)))?, + ) + } + _ => headers.push(name, value), + } + continue; + } + } + + Ok(( + tree, + parents, + author.ok_or(ParseError::MissingAuthor)?, + committer.ok_or(ParseError::MissingCommitter)?, + headers, + )) +} + +/// Split the commit body (the portion after the first `\n\n` in the object) +/// into a message string and a list of trailers. +/// +/// Trailers are only separated out when the last paragraph of the body +/// consists entirely of valid `Token: value` lines. If parsing the last +/// paragraph as trailers fails for any line, the whole body is returned as +/// the message with an empty trailer list. +fn parse_body(body: &str) -> (String, Vec) { + // Strip the single trailing newline that Display always writes after the + // message, so that rfind("\n\n") reliably finds the trailer separator + // rather than a spurious match at the very end. + let body = body.trim_end_matches('\n'); + + if let Some(split) = body.rfind("\n\n") { + let candidate = &body[split + 2..]; + // Only treat non-empty paragraphs as trailers. + if !candidate.trim().is_empty() { + if let Some(trailers) = try_parse_trailers(candidate) { + return (body[..split].to_string(), trailers); + } + } + } + + (body.to_string(), Vec::new()) +} + +/// Attempt to parse every non-empty line in `s` as a `Token: value` trailer. +/// +/// Returns `None` if any line is not a valid trailer, so that the caller can +/// fall back to treating the whole paragraph as part of the message. +fn try_parse_trailers(s: &str) -> Option> { + s.lines() + .filter(|l| !l.is_empty()) + .map(|line| { + let (token_str, value) = line.split_once(": ")?; + let token = Token::try_from(token_str).ok()?; + // Round-trip through Trailer so that OwnedToken construction + // stays inside the trailers module where the private field lives. + Some( + Trailer { + token, + value: Cow::Borrowed(value), + } + .to_owned(), + ) + }) + .collect() +} diff --git a/crates/radicle-git-metadata/src/commit/parse/test.rs b/crates/radicle-git-metadata/src/commit/parse/test.rs new file mode 100644 index 00000000..a0f10c73 --- /dev/null +++ b/crates/radicle-git-metadata/src/commit/parse/test.rs @@ -0,0 +1,3 @@ +mod error; +mod success; +mod unit; diff --git a/crates/radicle-git-metadata/src/commit/parse/test/error.rs b/crates/radicle-git-metadata/src/commit/parse/test/error.rs new file mode 100644 index 00000000..49523060 --- /dev/null +++ b/crates/radicle-git-metadata/src/commit/parse/test/error.rs @@ -0,0 +1,154 @@ +use crate::commit::parse::{parse, ParseError}; + +/// Helper type whose FromStr always fails. +/// +/// Used to exercise ParseError::InvalidTree and ParseError::InvalidParent +/// without relying on a specific OID type; String::from_str is infallible so +/// it cannot trigger those variants on its own. +#[derive(Debug)] +struct AlwaysFails; + +#[derive(Debug)] +struct AlwaysFailsErr; + +impl std::fmt::Display for AlwaysFailsErr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "always fails to parse") + } +} + +impl std::error::Error for AlwaysFailsErr {} + +impl std::str::FromStr for AlwaysFails { + type Err = AlwaysFailsErr; + + fn from_str(_s: &str) -> Result { + Err(AlwaysFailsErr) + } +} + +#[test] +fn missing_header_body_separator() { + // No blank line separating the headers from the body at all. + let raw = "tree abc123\nauthor Alice Liddell 1700000000 +0000\ncommitter Alice Liddell 1700000000 +0000\nno blank line follows"; + + let err = parse::(raw).unwrap_err(); + assert!( + matches!(err, ParseError::InvalidFormat { .. }), + "unexpected error: {err}" + ); +} + +#[test] +fn missing_tree_empty_header() { + // Header section is empty (just "\n\n"). + let raw = "\n\nMessage"; + + let err = parse::(raw).unwrap_err(); + assert!( + matches!(err, ParseError::MissingTree), + "unexpected error: {err}" + ); +} + +#[test] +fn missing_tree_wrong_first_line() { + // Header section exists but does not open with "tree ". + let raw = "author Alice Liddell 1700000000 +0000\ncommitter Alice Liddell 1700000000 +0000\n\nMessage"; + + let err = parse::(raw).unwrap_err(); + assert!( + matches!(err, ParseError::MissingTree), + "unexpected error: {err}" + ); +} + +#[test] +fn invalid_tree() { + // Tree value present but cannot be parsed into the target Tree type. + let raw = "tree abc123\nauthor Alice Liddell 1700000000 +0000\ncommitter Alice Liddell 1700000000 +0000\n\nMessage"; + + let err = parse::(raw).unwrap_err(); + assert!( + matches!(err, ParseError::InvalidTree(_)), + "unexpected error: {err}" + ); +} + +#[test] +fn invalid_parent() { + // Parent value present but cannot be parsed into the target Parent type. + let raw = "tree abc123\nparent bad-oid\nauthor Alice Liddell 1700000000 +0000\ncommitter Alice Liddell 1700000000 +0000\n\nMessage"; + + let err = parse::(raw).unwrap_err(); + assert!( + matches!(err, ParseError::InvalidParent(_)), + "unexpected error: {err}" + ); +} + +#[test] +fn invalid_format_continuation_without_preceding_header() { + // A continuation line (leading space) appearing before any extra header + // has been pushed to the headers vec triggers InvalidFormat, because + // there is no preceding header value to fold it into. + // + // Note: author and committer do not push to the headers vec, so a + // continuation line immediately after them (with an otherwise empty + // headers vec) exercises this branch. + let raw = "tree abc123\nauthor Alice Liddell 1700000000 +0000\ncommitter Alice Liddell 1700000000 +0000\n spurious continuation\n\nMessage"; + + let err = parse::(raw).unwrap_err(); + assert!( + matches!(err, ParseError::InvalidFormat { .. }), + "unexpected error: {err}" + ); +} + +#[test] +fn missing_author() { + let raw = + "tree abc123\ncommitter Alice Liddell 1700000000 +0000\n\nMessage"; + + let err = parse::(raw).unwrap_err(); + assert!( + matches!(err, ParseError::MissingAuthor), + "unexpected error: {err}" + ); +} + +#[test] +fn invalid_author() { + // Author line is present but its value is not a valid Author string + // (no email bracket, no timestamp, no timezone offset). + let raw = "tree abc123\nauthor not-a-valid-author\ncommitter Alice Liddell 1700000000 +0000\n\nMessage"; + + let err = parse::(raw).unwrap_err(); + assert!( + matches!(err, ParseError::InvalidAuthor(_)), + "unexpected error: {err}" + ); +} + +#[test] +fn missing_committer() { + let raw = "tree abc123\nauthor Alice Liddell 1700000000 +0000\n\nMessage"; + + let err = parse::(raw).unwrap_err(); + assert!( + matches!(err, ParseError::MissingCommitter), + "unexpected error: {err}" + ); +} + +#[test] +fn invalid_committer() { + // Committer line is present but its value is not a valid Author string. + let raw = "tree abc123\nauthor Alice Liddell 1700000000 +0000\ncommitter not-a-valid-committer\n\nMessage"; + + let err = parse::(raw).unwrap_err(); + assert!( + matches!(err, ParseError::InvalidCommitter(_)), + "unexpected error: {err}" + ); +} diff --git a/crates/radicle-git-metadata/src/commit/parse/test/success.rs b/crates/radicle-git-metadata/src/commit/parse/test/success.rs new file mode 100644 index 00000000..9346bd62 --- /dev/null +++ b/crates/radicle-git-metadata/src/commit/parse/test/success.rs @@ -0,0 +1,198 @@ +use crate::commit::parse::parse; + +#[test] +fn root_commit() { + let raw = r#"tree abc123 +author Alice Liddell 1700000000 +0000 +committer Alice Liddell 1700000000 +0000 + +Initial commit"#; + + let commit = parse::(raw).unwrap(); + + assert_eq!(commit.tree(), "abc123"); + assert_eq!(commit.parents().count(), 0); + assert_eq!(commit.author().name, "Alice Liddell"); + assert_eq!(commit.author().email, "alice@example.com"); + assert_eq!(commit.author().time.seconds(), 1700000000); + assert_eq!(commit.author().time.offset(), 0); + assert_eq!(commit.committer().name, "Alice Liddell"); + assert_eq!(commit.message(), "Initial commit"); + assert_eq!(commit.trailers().count(), 0); + assert_eq!(commit.headers().count(), 0); +} + +#[test] +fn commit_with_single_parent() { + let raw = r#"tree def456 +parent abc123 +author Alice Liddell 1700000000 +0000 +committer Alice Liddell 1700000000 +0000 + +Second commit"#; + + let commit = parse::(raw).unwrap(); + + assert_eq!(commit.tree(), "def456"); + assert_eq!(commit.parents().collect::>(), ["abc123"]); + assert_eq!(commit.message(), "Second commit"); +} + +#[test] +fn merge_commit() { + let raw = r#"tree ghi789 +parent abc123 +parent def456 +author Alice Liddell 1700000000 +0000 +committer Alice Liddell 1700000000 +0000 + +Merge branch 'feature'"#; + + let commit = parse::(raw).unwrap(); + + assert_eq!(commit.parents().collect::>(), ["abc123", "def456"]); +} + +#[test] +fn commit_with_multiline_gpgsig() { + // gpgsig continuation lines are indented by one space in the raw object. + // The parser folds them back into the header value with embedded newlines. + let raw = r#"tree abc123 +author Alice Liddell 1700000000 +0000 +committer Alice Liddell 1700000000 +0000 +gpgsig -----BEGIN SSH SIGNATURE----- + AAAAB3NzaC1yc2EAAAADAQAB + AAAA== + -----END SSH SIGNATURE----- + +Signed commit"#; + + let commit = parse::(raw).unwrap(); + + assert_eq!(commit.signatures().count(), 1); + // gpgsig is stored in headers; it is the only extra header here. + assert_eq!(commit.headers().count(), 1); + assert_eq!(commit.message(), "Signed commit"); +} + +#[test] +fn commit_gpgsig_is_preserved_and_strip_removes_it() { + // Parsing preserves gpgsig so callers can extract it before stripping. + let raw = r#"tree abc123 +author Alice Liddell 1700000000 +0000 +committer Alice Liddell 1700000000 +0000 +gpgsig -----BEGIN SSH SIGNATURE----- + AAAA== + -----END SSH SIGNATURE----- + +Signed commit"#; + + let commit = parse::(raw).unwrap(); + assert_eq!(commit.signatures().count(), 1); + + let stripped = commit.strip_signatures(); + assert_eq!(stripped.signatures().count(), 0); + assert_eq!(stripped.headers().count(), 0); + assert_eq!(stripped.message(), "Signed commit"); +} + +#[test] +fn commit_with_trailers() { + // The last paragraph contains only valid Token: value lines, so they + // are split out into the trailers vec and excluded from the message. + let raw = r#"tree abc123 +author Alice Liddell 1700000000 +0000 +committer Bob Bobson 1700000001 +0100 + +Add a new feature + +This commit adds a new feature to the library. + +Signed-off-by: Alice Liddell +Co-authored-by: Bob Bobson "#; + + let commit = parse::(raw).unwrap(); + + assert_eq!( + commit.message(), + "Add a new feature\n\nThis commit adds a new feature to the library." + ); + let trailers: Vec<_> = commit.trailers().collect(); + assert_eq!(trailers.len(), 2); + assert_eq!(&*trailers[0].token, "Signed-off-by"); + assert_eq!(trailers[0].value, "Alice Liddell "); + assert_eq!(&*trailers[1].token, "Co-authored-by"); + assert_eq!(trailers[1].value, "Bob Bobson "); +} + +#[test] +fn commit_last_paragraph_kept_in_message_when_not_all_trailers() { + // If any line in the last paragraph is not a valid Token: value pair, + // the entire paragraph stays in the message and no trailers are extracted. + let raw = r#"tree abc123 +author Alice Liddell 1700000000 +0000 +committer Alice Liddell 1700000000 +0000 + +Add feature + +Signed-off-by: Alice Liddell +This line is not a valid trailer."#; + + let commit = parse::(raw).unwrap(); + + assert_eq!(commit.trailers().count(), 0); + assert!(commit.message().contains("Signed-off-by")); + assert!(commit + .message() + .contains("This line is not a valid trailer.")); +} + +#[test] +fn commit_with_extra_headers() { + let raw = r#"tree abc123 +author Alice Liddell 1700000000 +0000 +committer Alice Liddell 1700000000 +0000 +encoding UTF-8 +mergetag some-value + +Commit with extra headers"#; + + let commit = parse::(raw).unwrap(); + + let headers: Vec<_> = commit.headers().collect(); + assert_eq!(headers.len(), 2); + assert_eq!(headers[0], ("encoding", "UTF-8")); + assert_eq!(headers[1], ("mergetag", "some-value")); +} + +#[test] +fn roundtrip() { + // Parsing and then re-displaying a commit must produce output that parses + // back to a CommitData equal in every field, exercising the Display / + // parse_body symmetry in particular. + let raw = r#"tree abc123 +parent def456 +author Alice Liddell 1700000000 +0000 +committer Bob Bobson 1700000001 +0100 + +Add something useful + +Signed-off-by: Alice Liddell "#; + + let commit = parse::(raw).unwrap(); + let displayed = commit.to_string(); + let reparsed = parse::(&displayed).unwrap(); + + assert_eq!(commit.tree(), reparsed.tree()); + assert_eq!( + commit.parents().collect::>(), + reparsed.parents().collect::>() + ); + assert_eq!(commit.author(), reparsed.author()); + assert_eq!(commit.committer(), reparsed.committer()); + assert_eq!(commit.message(), reparsed.message()); + assert_eq!( + commit.trailers().collect::>(), + reparsed.trailers().collect::>() + ); +} diff --git a/crates/radicle-git-metadata/src/commit/parse/test/unit.rs b/crates/radicle-git-metadata/src/commit/parse/test/unit.rs new file mode 100644 index 00000000..62b3ffcc --- /dev/null +++ b/crates/radicle-git-metadata/src/commit/parse/test/unit.rs @@ -0,0 +1,38 @@ +use crate::commit::parse::{parse_body, try_parse_trailers}; + +#[test] +fn body_no_paragraph_separator_means_no_trailers() { + // A body with no blank line cannot have a trailing trailer paragraph. + let (message, trailers) = parse_body("Just a message with no blank line"); + assert_eq!(message, "Just a message with no blank line"); + assert!(trailers.is_empty()); +} + +#[test] +fn body_last_paragraph_not_trailers_stays_in_message() { + let body = "Short description\n\nThis paragraph has no Token: value lines."; + let (message, trailers) = parse_body(body); + assert_eq!(message, body); + assert!(trailers.is_empty()); +} + +#[test] +fn trailers_rejects_line_without_separator() { + // A line that contains no ": " cannot be a trailer. + assert!(try_parse_trailers("NotATrailerLine").is_none()); +} + +#[test] +fn trailers_rejects_invalid_token_chars() { + // Token characters must be alphanumeric or '-'; spaces are not allowed. + assert!(try_parse_trailers("Invalid Token: value").is_none()); +} + +#[test] +fn trailers_accepts_empty_input() { + // An empty paragraph produces an empty trailer list rather than None. + // (parse_body guards against this with the is_empty() check, but the + // helper itself is defined to return Some([]) for an empty iterator.) + let result = try_parse_trailers(""); + assert_eq!(result, Some(vec![])); +}