Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
heartwood crates radicle-git-metadata src commit parse.rs
#[cfg(test)]
mod test;

use std::borrow::Cow;

use crate::author::Author;

use super::{
    CommitData,
    headers::Headers,
    trailers::{OwnedTrailer, Token, Trailer},
};

#[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<dyn std::error::Error + Send + Sync + 'static>),
    #[error("invalid format: {reason}")]
    InvalidFormat { reason: &'static str },
    #[error("failed to parse 'parent' value: {0}")]
    InvalidParent(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
    #[error("invalid header")]
    InvalidHeader,
    #[error("failed to parse 'author' value: {0}")]
    InvalidAuthor(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
    #[error("the commit header is missing the 'author' entry")]
    MissingAuthor,
    #[error("failed to parse 'committer' value: {0}")]
    InvalidCommitter(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
    #[error("the commit header is missing the 'committer' entry")]
    MissingCommitter,
}

pub(super) fn parse<Tree: std::str::FromStr, Parent: std::str::FromStr>(
    commit: &str,
) -> Result<CommitData<Tree, Parent>, 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::<Tree, Parent, Author>(header)?;

    let (message, trailers) = parse_body(body);

    Ok(CommitData {
        tree,
        parents,
        author,
        committer,
        headers,
        message,
        trailers,
    })
}

fn parse_headers<Tree: std::str::FromStr, Parent: std::str::FromStr, Signature: std::str::FromStr>(
    header: &str,
) -> Result<(Tree, Vec<Parent>, 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<Signature> = None;
    let mut committer: Option<Signature> = 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::<Parent>()
                        .map_err(|err| ParseError::InvalidParent(Box::new(err)))?,
                ),
                "author" => {
                    author = Some(
                        value
                            .parse::<Signature>()
                            .map_err(|err| ParseError::InvalidAuthor(Box::new(err)))?,
                    )
                }
                "committer" => {
                    committer = Some(
                        value
                            .parse::<Signature>()
                            .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<OwnedTrailer>) {
    // 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<Vec<OwnedTrailer>> {
    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()
}