diff --git a/Cargo.lock b/Cargo.lock index f3be1efd..237f85e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2914,6 +2914,13 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "radicle-git-metadata" +version = "0.1.0" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "radicle-git-ref-format" version = "0.1.0" diff --git a/crates/radicle-git-metadata/Cargo.toml b/crates/radicle-git-metadata/Cargo.toml new file mode 100644 index 00000000..ab58b3f0 --- /dev/null +++ b/crates/radicle-git-metadata/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "radicle-git-metadata" +description = "Radicle structs that carry Git commit metadata" +homepage.workspace = true +repository.workspace = true +version = "0.1.0" +edition.workspace = true +license.workspace = true +keywords = ["radicle", "git", "metadata"] +rust-version.workspace = true + +[dependencies] +thiserror = { workspace = true } \ No newline at end of file diff --git a/crates/radicle-git-metadata/src/author.rs b/crates/radicle-git-metadata/src/author.rs new file mode 100644 index 00000000..e0c13365 --- /dev/null +++ b/crates/radicle-git-metadata/src/author.rs @@ -0,0 +1,125 @@ +use std::{ + fmt, + num::ParseIntError, + str::{self, FromStr}, +}; + +use thiserror::Error; + +/// The data for indicating authorship of an action within +/// [`crate::commit::CommitData`]. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Author { + /// Name corresponding to `user.name` in the git config. + /// + /// Note: this must not contain `<` or `>`. + pub name: String, + /// Email corresponding to `user.email` in the git config. + /// + /// Note: this must not contain `<` or `>`. + pub email: String, + /// The time of this author's action. + pub time: Time, +} + +/// The time of a [`Author`]'s action. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Time { + seconds: i64, + offset: i32, +} + +impl Time { + pub fn new(seconds: i64, offset: i32) -> Self { + Self { seconds, offset } + } + + /// Return the time, in seconds, since the epoch. + pub fn seconds(&self) -> i64 { + self.seconds + } + + /// Return the timezone offset, in minutes. + pub fn offset(&self) -> i32 { + self.offset + } + + fn from_components<'a>(cs: &mut impl Iterator) -> Result { + let offset = match cs.next() { + None => Err(ParseError::Missing("offset")), + Some(offset) => Self::parse_offset(offset).map_err(ParseError::Offset), + }?; + let time = match cs.next() { + None => return Err(ParseError::Missing("time")), + Some(time) => time.parse::().map_err(ParseError::Time)?, + }; + Ok(Self::new(time, offset)) + } + + fn parse_offset(offset: &str) -> Result { + // The offset is in the form of timezone offset, + // e.g. +0200, -0100. This needs to be converted into + // minutes. The first two digits in the offset are the + // number of hours in the offset, while the latter two + // digits are the number of minutes in the offset. + let tz_offset = offset.parse::()?; + let hours = tz_offset / 100; + let minutes = tz_offset % 100; + Ok(hours * 60 + minutes) + } +} + +impl FromStr for Time { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + Self::from_components(&mut s.split(' ').rev()) + } +} + +impl fmt::Display for Time { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let sign = if self.offset.is_negative() { '-' } else { '+' }; + let hours = self.offset.abs() / 60; + let minutes = self.offset.abs() % 60; + write!(f, "{} {}{:0>2}{:0>2}", self.seconds, sign, hours, minutes) + } +} + +impl fmt::Display for Author { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} <{}> {}", self.name, self.email, self.time,) + } +} + +#[derive(Debug, Error)] +pub enum ParseError { + #[error("missing '{0}' while parsing person signature")] + Missing(&'static str), + #[error("offset was incorrect format while parsing person signature")] + Offset(#[source] ParseIntError), + #[error("time was incorrect format while parsing person signature")] + Time(#[source] ParseIntError), +} + +impl FromStr for Author { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + // Splitting the string in 4 subcomponents is expected to give back the + // following iterator entries: timezone offset, time, email, and name + let mut components = s.rsplitn(4, ' '); + let time = Time::from_components(&mut components)?; + let email = components + .next() + .ok_or(ParseError::Missing("email"))? + .trim_matches(|c| c == '<' || c == '>') + .to_owned(); + let name = components.next().ok_or(ParseError::Missing("name"))?; + Ok(Self { + name: name.to_owned(), + email: email.to_owned(), + time, + }) + } +} diff --git a/crates/radicle-git-metadata/src/commit.rs b/crates/radicle-git-metadata/src/commit.rs new file mode 100644 index 00000000..62872960 --- /dev/null +++ b/crates/radicle-git-metadata/src/commit.rs @@ -0,0 +1,179 @@ +pub mod headers; +pub mod trailers; + +use core::fmt; +use std::str; + +use headers::{Headers, Signature}; +use trailers::{OwnedTrailer, Trailer}; + +use crate::author::Author; + +/// A git commit in its object description form, i.e. the output of +/// `git cat-file` for a commit object. +#[derive(Debug)] +pub struct CommitData { + tree: Tree, + parents: Vec, + author: Author, + committer: Author, + headers: Headers, + message: String, + trailers: Vec, +} + +impl CommitData { + pub fn new( + tree: Tree, + parents: P, + author: Author, + committer: Author, + headers: Headers, + message: String, + trailers: I, + ) -> Self + where + P: IntoIterator, + I: IntoIterator, + OwnedTrailer: From, + { + let trailers = trailers.into_iter().map(OwnedTrailer::from).collect(); + let parents = parents.into_iter().collect(); + Self { + tree, + parents, + author, + committer, + headers, + message, + trailers, + } + } + + /// The tree this commit points to. + pub fn tree(&self) -> &Tree { + &self.tree + } + + /// The parents of this commit. + pub fn parents(&self) -> impl Iterator + '_ + where + Parent: Clone, + { + self.parents.iter().cloned() + } + + /// The author of this commit, i.e. the header corresponding to `author`. + pub fn author(&self) -> &Author { + &self.author + } + + /// The committer of this commit, i.e. the header corresponding to + /// `committer`. + pub fn committer(&self) -> &Author { + &self.committer + } + + /// The message body of this commit. + pub fn message(&self) -> &str { + &self.message + } + + /// The [`Signature`]s found in this commit, i.e. the headers corresponding + /// to `gpgsig`. + pub fn signatures(&self) -> impl Iterator + '_ { + self.headers.signatures() + } + + /// The [`Headers`] found in this commit. + /// + /// Note: these do not include `tree`, `parent`, `author`, and `committer`. + pub fn headers(&self) -> impl Iterator { + self.headers.iter() + } + + /// Iterate over the [`Headers`] values that match the provided `name`. + pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator + 'a { + self.headers.values(name) + } + + /// Push a header to the end of the headers section. + pub fn push_header(&mut self, name: &str, value: &str) { + self.headers.push(name, value.trim()); + } + + pub fn trailers(&self) -> impl Iterator { + self.trailers.iter() + } + + /// Convert the `CommitData::tree` into a value of type `U`. The + /// conversion function `f` can be fallible. + /// + /// For example, `map_tree` can be used to turn raw tree data into + /// an `Oid` by writing it to a repository. + pub fn map_tree(self, f: F) -> Result, E> + where + F: FnOnce(Tree) -> Result, + { + Ok(CommitData { + tree: f(self.tree)?, + parents: self.parents, + author: self.author, + committer: self.committer, + headers: self.headers, + message: self.message, + trailers: self.trailers, + }) + } + + /// Convert the [`CommitData::parents`] into a vector containing + /// values of type `U`. The conversion function `f` can be + /// fallible. + /// + /// For example, this can be used to resolve the object identifiers + /// to their respective full commits. + pub fn map_parents(self, f: F) -> Result, E> + where + F: FnMut(Parent) -> Result, + { + Ok(CommitData { + tree: self.tree, + parents: self + .parents + .into_iter() + .map(f) + .collect::, _>>()?, + author: self.author, + committer: self.committer, + headers: self.headers, + message: self.message, + trailers: self.trailers, + }) + } +} + +impl fmt::Display for CommitData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "tree {}", self.tree)?; + for parent in self.parents.iter() { + writeln!(f, "parent {parent}")?; + } + writeln!(f, "author {}", self.author)?; + writeln!(f, "committer {}", self.committer)?; + + for (name, value) in self.headers.iter() { + writeln!(f, "{name} {}", value.replace('\n', "\n "))?; + } + writeln!(f)?; + write!(f, "{}", self.message.trim())?; + writeln!(f)?; + + if !self.trailers.is_empty() { + writeln!(f)?; + } + for trailer in self.trailers.iter() { + writeln!(f, "{}", Trailer::from(trailer).display(": "))?; + } + Ok(()) + } +} diff --git a/crates/radicle-git-metadata/src/commit/headers.rs b/crates/radicle-git-metadata/src/commit/headers.rs new file mode 100644 index 00000000..172a884d --- /dev/null +++ b/crates/radicle-git-metadata/src/commit/headers.rs @@ -0,0 +1,168 @@ +use core::fmt; +use std::borrow::Cow; + +const BEGIN_SSH: &str = "-----BEGIN SSH SIGNATURE-----\n"; +const BEGIN_PGP: &str = "-----BEGIN PGP SIGNATURE-----\n"; + +/// A collection of headers stored in [`super::CommitData`]. +/// +/// Note: these do not include `tree`, `parent`, `author`, and `committer`. +#[derive(Clone, Debug, Default)] +pub struct Headers(pub(super) Vec<(String, String)>); + +/// A `gpgsig` signature stored in [`super::CommitData`]. +#[derive(Debug)] +pub enum Signature<'a> { + /// A PGP signature, i.e. starts with `-----BEGIN PGP SIGNATURE-----`. + Pgp(Cow<'a, str>), + /// A SSH signature, i.e. starts with `-----BEGIN SSH SIGNATURE-----`. + Ssh(Cow<'a, str>), +} + +impl<'a> Signature<'a> { + fn from_str(s: &'a str) -> Result { + if s.starts_with(BEGIN_SSH) { + Ok(Signature::Ssh(Cow::Borrowed(s))) + } else if s.starts_with(BEGIN_PGP) { + Ok(Signature::Pgp(Cow::Borrowed(s))) + } else { + Err(UnknownScheme) + } + } +} + +impl fmt::Display for Signature<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Signature::Pgp(pgp) => f.write_str(pgp.as_ref()), + Signature::Ssh(ssh) => f.write_str(ssh.as_ref()), + } + } +} + +pub struct UnknownScheme; + +impl Headers { + pub fn new() -> Self { + Headers(Vec::new()) + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter().map(|(k, v)| (k.as_str(), v.as_str())) + } + + pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator + 'a { + self.iter() + .filter_map(move |(k, v)| (k == name).then_some(v)) + } + + pub fn signatures(&self) -> impl Iterator + '_ { + self.0.iter().filter_map(|(k, v)| { + if k == "gpgsig" { + Signature::from_str(v).ok() + } else { + None + } + }) + } + + /// Push a header to the end of the headers section. + pub fn push(&mut self, name: &str, value: &str) { + self.0.push((name.to_owned(), value.trim().to_owned())); + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + #[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/trailers.rs b/crates/radicle-git-metadata/src/commit/trailers.rs new file mode 100644 index 00000000..797d56dd --- /dev/null +++ b/crates/radicle-git-metadata/src/commit/trailers.rs @@ -0,0 +1,127 @@ +use std::{borrow::Cow, fmt, ops::Deref}; + +pub trait Separator<'a> { + fn sep_for(&self, token: &Token) -> &'a str; +} + +impl<'a> Separator<'a> for &'a str { + fn sep_for(&self, _: &Token) -> &'a str { + self + } +} + +impl<'a, F> Separator<'a> for F +where + F: Fn(&Token) -> &'a str, +{ + fn sep_for(&self, token: &Token) -> &'a str { + self(token) + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Token<'a>(&'a str); + +impl Deref for Token<'_> { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.0 + } +} + +impl<'a> TryFrom<&'a str> for Token<'a> { + type Error = &'static str; + + fn try_from(s: &'a str) -> Result { + let is_token = s.chars().all(|c| c.is_alphanumeric() || c == '-'); + if is_token { + Ok(Token(s)) + } else { + Err("token contains invalid characters") + } + } +} + +pub struct Display<'a> { + trailer: &'a Trailer<'a>, + separator: &'a str, +} + +impl fmt::Display for Display<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{}{}{}", + self.trailer.token.deref(), + self.separator, + self.trailer.value, + ) + } +} + +/// A trailer is a key/value pair found in the last paragraph of a Git +/// commit message, not including any patches or conflicts that may be +/// present. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Trailer<'a> { + pub token: Token<'a>, + pub value: Cow<'a, str>, +} + +impl<'a> Trailer<'a> { + pub fn display(&'a self, separator: &'a str) -> Display<'a> { + Display { + trailer: self, + separator, + } + } + + pub fn to_owned(&self) -> OwnedTrailer { + OwnedTrailer::from(self) + } +} + +/// A version of the [`Trailer`] which owns its token and +/// value. Useful for when you need to carry trailers around in a long +/// lived data structure. +#[derive(Debug)] +pub struct OwnedTrailer { + pub token: OwnedToken, + pub value: String, +} + +#[derive(Debug)] +pub struct OwnedToken(String); + +impl Deref for OwnedToken { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl<'a> From<&Trailer<'a>> for OwnedTrailer { + fn from(t: &Trailer<'a>) -> Self { + OwnedTrailer { + token: OwnedToken(t.token.0.to_string()), + value: t.value.to_string(), + } + } +} + +impl<'a> From> for OwnedTrailer { + fn from(t: Trailer<'a>) -> Self { + (&t).into() + } +} + +impl<'a> From<&'a OwnedTrailer> for Trailer<'a> { + fn from(t: &'a OwnedTrailer) -> Self { + Trailer { + token: Token(t.token.0.as_str()), + value: Cow::from(&t.value), + } + } +} diff --git a/crates/radicle-git-metadata/src/lib.rs b/crates/radicle-git-metadata/src/lib.rs new file mode 100644 index 00000000..3e183770 --- /dev/null +++ b/crates/radicle-git-metadata/src/lib.rs @@ -0,0 +1,2 @@ +pub mod author; +pub mod commit;