diff --git a/radicle-surf/Cargo.toml b/radicle-surf/Cargo.toml index 9c48ec7..d686076 100644 --- a/radicle-surf/Cargo.toml +++ b/radicle-surf/Cargo.toml @@ -37,11 +37,14 @@ default-features = false features = [] [dev-dependencies] +criterion = "0.3" pretty_assertions = "1.3.0" proptest = "0.9" -criterion = "0.3" serde_json = "1" +[dev-dependencies.test-helpers] +path = "../test/test-helpers" + [build-dependencies] anyhow = "1.0" flate2 = "1" diff --git a/radicle-surf/src/vcs/git/branch.rs b/radicle-surf/src/vcs/git/branch.rs index d6dcabc..423a06f 100644 --- a/radicle-surf/src/vcs/git/branch.rs +++ b/radicle-surf/src/vcs/git/branch.rs @@ -18,7 +18,11 @@ use crate::vcs::git::{self, error::Error, ext, reference::Ref}; use std::{cmp::Ordering, convert::TryFrom, fmt, str}; +#[cfg(feature = "serialize")] +use serde::{Deserialize, Serialize}; + /// The branch type we want to filter on. +#[cfg_attr(feature = "serialize", derive(Serialize, Deserialize))] #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] pub enum BranchType { /// Local branches that are under `refs/heads/*` @@ -51,6 +55,7 @@ impl From for BranchType { /// A newtype wrapper over `String` to separate out the fact that a caller wants /// to fetch a branch. +#[cfg_attr(feature = "serialize", derive(Deserialize, Serialize))] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct BranchName(pub(crate) String); @@ -89,6 +94,7 @@ impl BranchName { /// /// **Note**: The `PartialOrd` and `Ord` implementations compare on `BranchName` /// only. +#[cfg_attr(feature = "serialize", derive(Deserialize, Serialize))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct Branch { /// Name identifier of the `Branch`. @@ -191,3 +197,34 @@ impl<'repo> TryFrom> for Branch { } } } + +#[cfg(feature = "serialize")] +#[cfg(test)] +pub mod tests { + use proptest::prelude::*; + use test_helpers::roundtrip; + + use super::{Branch, BranchName, BranchType}; + + proptest! { + #[test] + fn prop_test_branch(branch in branch_strategy()) { + roundtrip::json(branch) + } + } + + fn branch_strategy() -> impl Strategy { + prop_oneof![ + any::().prop_map(|name| Branch { + name: BranchName::new(&name), + locality: BranchType::Local + }), + (any::(), any::()).prop_map(|(name, remote_name)| Branch { + name: BranchName::new(&name), + locality: BranchType::Remote { + name: Some(remote_name), + }, + }) + ] + } +} diff --git a/radicle-surf/src/vcs/git/commit.rs b/radicle-surf/src/vcs/git/commit.rs index 15e53d5..01e8b4f 100644 --- a/radicle-surf/src/vcs/git/commit.rs +++ b/radicle-surf/src/vcs/git/commit.rs @@ -19,7 +19,11 @@ use crate::vcs::git::error::Error; use git2::Oid; use std::{convert::TryFrom, str}; +#[cfg(feature = "serialize")] +use serde::{de, ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer}; + /// `Author` is the static information of a [`git2::Signature`]. +#[cfg_attr(feature = "serialize", derive(Deserialize, Serialize))] #[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Author { /// Name of the author. @@ -27,9 +31,30 @@ pub struct Author { /// Email of the author. pub email: String, /// Time the action was taken, e.g. time of commit. + #[serde( + serialize_with = "serialize_time", + deserialize_with = "deserialize_time" + )] pub time: git2::Time, } +#[cfg(feature = "serialize")] +fn deserialize_time<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let seconds: i64 = Deserialize::deserialize(deserializer)?; + Ok(git2::Time::new(seconds, 0)) +} + +#[cfg(feature = "serialize")] +fn serialize_time(t: &git2::Time, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_i64(t.seconds()) +} + impl std::fmt::Debug for Author { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { use std::cmp::Ordering; @@ -61,9 +86,12 @@ impl<'repo> TryFrom> for Author { /// `Commit` is the static information of a [`git2::Commit`]. To get back the /// original `Commit` in the repository we can use the [`Oid`] to retrieve /// it. +#[cfg_attr(feature = "serialize", derive(Deserialize))] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Commit { + // TODO: Replace git2::Oid with git_ext::Oid (https://github.com/radicle-dev/radicle-git/issues/5) /// Object ID of the Commit, i.e. the SHA1 digest. + #[serde(deserialize_with = "deserialize_oid")] pub id: Oid, /// The author of the commit. pub author: Author, @@ -74,9 +102,73 @@ pub struct Commit { /// The summary message of the commit. pub summary: String, /// The parents of this commit. + #[serde(deserialize_with = "deserialize_vec_oid")] pub parents: Vec, } +impl Commit { + /// Returns the commit description text. This is the text after the one-line + /// summary. + #[must_use] + pub fn description(&self) -> &str { + self.message + .strip_prefix(&self.summary) + .unwrap_or(&self.message) + .trim() + } +} + +#[cfg(feature = "serialize")] +impl Serialize for Commit { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut state = serializer.serialize_struct("Commit", 7)?; + state.serialize_field("id", &self.id.to_string())?; + state.serialize_field("author", &self.author)?; + state.serialize_field("committer", &self.committer)?; + state.serialize_field("summary", &self.summary)?; + state.serialize_field("message", &self.message)?; + state.serialize_field("description", &self.description())?; + state.serialize_field( + "parents", + &self + .parents + .iter() + .map(|oid| oid.to_string()) + .collect::>(), + )?; + state.end() + } +} + +#[cfg(feature = "serialize")] +fn deserialize_oid<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let oid: &str = Deserialize::deserialize(deserializer)?; + Oid::from_str(oid).map_err(|_| { + serde::de::Error::invalid_type( + serde::de::Unexpected::Str(oid), + &"a SHA1 hash not longer than 40 hex characters", + ) + }) +} + +#[cfg(feature = "serialize")] +fn deserialize_vec_oid<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let oids: Vec<&str> = Deserialize::deserialize(deserializer)?; + oids.iter() + .map(|key| Oid::from_str(key)) + .collect::, _>>() + .map_err(de::Error::custom) +} + impl<'repo> TryFrom> for Commit { type Error = Error; @@ -100,3 +192,39 @@ impl<'repo> TryFrom> for Commit { }) } } + +#[cfg(test)] +pub mod tests { + use git2::Oid; + use proptest::prelude::*; + use test_helpers::roundtrip; + + use super::{Author, Commit}; + + #[cfg(feature = "serialize")] + proptest! { + #[test] + fn prop_test_commits(commit in commits_strategy()) { + roundtrip::json(commit) + } + } + + fn commits_strategy() -> impl Strategy { + ("[a-fA-F0-9]{40}", any::(), any::()).prop_map(|(id, text, time)| Commit { + id: Oid::from_str(&id).unwrap(), + author: Author { + name: text.clone(), + email: text.clone(), + time: git2::Time::new(time, 0), + }, + committer: Author { + name: text.clone(), + email: text.clone(), + time: git2::Time::new(time, 0), + }, + message: text.clone(), + summary: text, + parents: vec![Oid::from_str(&id).unwrap(), Oid::from_str(&id).unwrap()], + }) + } +}