Add `Patch`, `Issue`, `Label` COBs

Signed-off-by: Alexis Sellier <alexis@radicle.xyz>
This commit is contained in:
Alexis Sellier 2022-11-13 17:43:37 +01:00
parent 08811b4b87
commit 12988da089
No known key found for this signature in database
13 changed files with 3133 additions and 1 deletions

89
Cargo.lock generated
View File

@ -62,6 +62,26 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "automerge"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e97999332403b0685c203d4937730f72777829c8b6ed92d6f1ccba017f38e61"
dependencies = [
"flate2",
"fxhash",
"hex",
"itertools",
"leb128",
"serde",
"sha2 0.10.6",
"smol_str",
"thiserror",
"tinyvec",
"tracing",
"uuid 0.8.2",
]
[[package]]
name = "axum"
version = "0.5.17"
@ -550,6 +570,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "either"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797"
[[package]]
name = "elliptic-curve"
version = "0.12.3"
@ -664,6 +690,15 @@ dependencies = [
"pin-utils",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "generic-array"
version = "0.14.6"
@ -807,6 +842,12 @@ dependencies = [
"libc",
]
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
@ -954,6 +995,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.4"
@ -998,6 +1048,12 @@ dependencies = [
"spin",
]
[[package]]
name = "leb128"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
[[package]]
name = "lexopt"
version = "0.2.1"
@ -1475,6 +1531,7 @@ dependencies = [
name = "radicle"
version = "0.2.0"
dependencies = [
"automerge",
"base64",
"byteorder",
"crossbeam-channel",
@ -1501,6 +1558,7 @@ dependencies = [
"sqlite",
"tempfile",
"thiserror",
"uuid 1.2.1",
"zeroize",
]
@ -1681,6 +1739,7 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
@ -1949,6 +2008,15 @@ version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
[[package]]
name = "smol_str"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7475118a28b7e3a2e157ce0131ba8c5526ea96e90ee601d9f6bb2e286a35ab44"
dependencies = [
"serde",
]
[[package]]
name = "socket2"
version = "0.4.7"
@ -2411,6 +2479,27 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "uuid"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom 0.2.8",
"serde",
]
[[package]]
name = "uuid"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "feb41e78f93363bb2df8b0e86a2ca30eed7806ea16ea0c790d757cf93f79be83"
dependencies = [
"getrandom 0.2.8",
"rand 0.8.5",
"serde",
]
[[package]]
name = "valuable"
version = "0.1.0"

View File

@ -11,6 +11,7 @@ test = ["quickcheck"]
sql = ["sqlite"]
[dependencies]
automerge = { version = "0.1" }
base64 = { version= "0.13" }
byteorder = { version = "1.4" }
crossbeam-channel = { version = "0.5.6" }
@ -30,6 +31,7 @@ sqlite = { version = "0.28.1", optional = true }
nonempty = { version = "0.8.0", features = ["serialize"] }
tempfile = { version = "3.3.0" }
thiserror = { version = "1" }
uuid = { version = "1.1.2", features = ["v4", "fast-rng", "serde"] }
zeroize = { version = "1.5.7" }
[dependencies.git2]

View File

@ -1,3 +1,12 @@
pub mod doc;
pub mod issue;
pub mod label;
pub mod patch;
pub mod shared;
pub mod store;
pub mod transaction;
pub mod value;
pub use cob::{
identity, object::collaboration::error, CollaborativeObject, Create, Entry, History, ObjectId,
TypeName, Update,

243
radicle/src/cob/doc.rs Normal file
View File

@ -0,0 +1,243 @@
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::hash::Hash;
use std::ops::Deref;
use std::str::FromStr;
use automerge::{Automerge, AutomergeError, ObjType};
use crate::cob::value::{FromValue, ValueError};
/// Error decoding a document.
#[derive(thiserror::Error, Debug)]
pub enum DocumentError {
#[error(transparent)]
Automerge(#[from] AutomergeError),
#[error("property '{0}' not found in object")]
PropertyNotFound(automerge::Prop),
#[error("error decoding property `{0}`")]
Property(String),
#[error("property '{0}' is not an object")]
NotAnObject(automerge::Prop),
#[error("Unexpected object type: expected `{expected}`, got `{actual}`")]
ObjectType {
expected: automerge::ObjType,
actual: automerge::ObjType,
},
#[error("error decoding value: {0}")]
Value(#[from] ValueError),
#[error("list under `{0}` cannot be empty")]
EmptyList(&'static str),
}
/// Automerge document decoder.
///
/// Wraps a document, providing convenience functions. Derefs to the underlying doc.
#[derive(Copy, Clone)]
pub struct Document<'a> {
doc: &'a Automerge,
}
impl<'a> Document<'a> {
/// Create a new document from an automerge document.
pub fn new(doc: &'a Automerge) -> Self {
Self { doc }
}
/// Get the value of a property of an object.
pub fn get<O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>, T: FromValue<'a>>(
&self,
id: O,
prop: P,
) -> Result<T, DocumentError> {
let prop = prop.into();
let (val, _) = Document::get_raw(self, id, prop)?;
T::from_value(val).map_err(DocumentError::from)
}
/// Get an object's raw property.
pub fn get_raw<O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>>(
&self,
id: O,
prop: P,
) -> Result<(automerge::Value<'a>, automerge::ObjId), DocumentError> {
let prop = prop.into();
self.doc
.get(id.as_ref(), prop.clone())?
.ok_or(DocumentError::PropertyNotFound(prop))
}
/// Get the id of an object's property.
pub fn get_id<O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>>(
&self,
id: O,
prop: P,
) -> Result<automerge::ObjId, DocumentError> {
self.get_raw(id, prop).map(|(_, id)| id)
}
/// Get a property using a lookup function.
pub fn lookup<V, O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>>(
&self,
id: O,
prop: P,
lookup: fn(Document, &automerge::ObjId) -> Result<V, DocumentError>,
) -> Result<V, DocumentError> {
let obj_id = self.get_id(&id, prop)?;
lookup(*self, &obj_id)
}
/// Get a list-like value from an object.
pub fn list<V, O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>>(
&self,
id: O,
prop: P,
item: fn(Document, &automerge::ObjId) -> Result<V, DocumentError>,
) -> Result<Vec<V>, DocumentError> {
let prop = prop.into();
let id = id.as_ref();
let Some((list, list_id)) = self.doc.get(id, prop.clone())? else {
return Err(DocumentError::PropertyNotFound(prop));
};
let Some(objtype) = list.to_objtype() else {
return Err(DocumentError::NotAnObject(prop));
};
if objtype != ObjType::List {
return Err(DocumentError::ObjectType {
expected: ObjType::List,
actual: objtype,
});
}
let mut objs: Vec<V> = Vec::new();
for i in 0..self.length(&list_id) {
let Some((_, item_id)) = self.doc.get(&list_id, i as usize)? else {
return Err(DocumentError::PropertyNotFound(prop));
};
let item = item(*self, &item_id)?;
objs.push(item);
}
Ok(objs)
}
/// Get a map-like value from an object.
pub fn map<
V: Default,
K: Hash + Eq + FromStr,
O: AsRef<automerge::ObjId>,
P: Into<automerge::Prop>,
>(
&self,
id: O,
prop: P,
mut value: impl FnMut(&mut V),
) -> Result<HashMap<K, V>, DocumentError> {
let prop = prop.into();
let id = id.as_ref();
let Some((obj, obj_id)) = self.doc.get(id, prop.clone())? else {
return Err(DocumentError::PropertyNotFound(prop))?;
};
let Some(objtype) = obj.to_objtype() else {
return Err(DocumentError::NotAnObject(prop));
};
if objtype != ObjType::Map {
return Err(DocumentError::ObjectType {
expected: ObjType::Map,
actual: objtype,
});
}
let mut map = HashMap::new();
for key in self.doc.keys(&obj_id) {
let key = K::from_str(&key).map_err(|_| DocumentError::Property(key))?;
let val = map.entry(key).or_default();
value(val);
}
Ok(map)
}
/// Get a folded value out of an object.
pub fn fold<
T: Default,
V: FromValue<'a> + fmt::Debug,
O: AsRef<automerge::ObjId>,
P: Into<automerge::Prop>,
>(
&self,
id: O,
prop: P,
mut f: impl FnMut(&mut T, V),
) -> Result<T, DocumentError> {
let prop = prop.into();
let id = id.as_ref();
let Some((obj, obj_id)) = self.doc.get(id, prop.clone())? else {
return Err(DocumentError::PropertyNotFound(prop));
};
let Some(objtype) = obj.to_objtype() else {
return Err(DocumentError::NotAnObject(prop));
};
if objtype != ObjType::List {
return Err(DocumentError::ObjectType {
expected: ObjType::List,
actual: objtype,
});
}
let mut acc = T::default();
for i in 0..self.doc.length(&obj_id) {
let Some((item, _)) = self.doc.get(&obj_id, i as usize)? else {
return Err(DocumentError::PropertyNotFound(prop));
};
let val = V::from_value(item)?;
f(&mut acc, val);
}
Ok(acc)
}
/// Get the keys of a map-like property.
pub fn keys<K: Hash + Eq + FromStr, O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>>(
&self,
id: O,
prop: P,
) -> Result<HashSet<K>, DocumentError> {
let prop = prop.into();
let id = id.as_ref();
let Some((obj, obj_id)) = self.doc.get(id, prop.clone())? else {
return Err(DocumentError::PropertyNotFound(prop));
};
let Some(objtype) = obj.to_objtype() else {
return Err(DocumentError::NotAnObject(prop));
};
if objtype != ObjType::Map {
return Err(DocumentError::ObjectType {
expected: ObjType::Map,
actual: objtype,
});
}
let mut keys = HashSet::new();
for key in self.doc.keys(&obj_id) {
let key = K::from_str(&key).map_err(|_| DocumentError::Property(key))?;
keys.insert(key);
}
Ok(keys)
}
}
impl<'a> Deref for Document<'a> {
type Target = Automerge;
fn deref(&self) -> &Self::Target {
self.doc
}
}

766
radicle/src/cob/issue.rs Normal file
View File

@ -0,0 +1,766 @@
#![allow(clippy::large_enum_variant)]
use std::collections::{HashMap, HashSet};
use std::convert::TryFrom;
use std::ops::ControlFlow;
use std::str::FromStr;
use automerge::{Automerge, ObjType, ScalarValue, Value};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use crate::cob::doc::{Document, DocumentError};
use crate::cob::shared;
use crate::cob::shared::*;
use crate::cob::store::{Error, FromHistory, Store};
use crate::cob::transaction::{Transaction, TransactionError};
use crate::cob::value::{FromValue, ValueError};
use crate::cob::{Contents, History, ObjectId, TypeName};
use crate::prelude::*;
/// Type name of an issue.
pub static TYPENAME: Lazy<TypeName> =
Lazy::new(|| FromStr::from_str("xyz.radicle.issue").expect("type name is valid"));
/// Identifier for an issue.
pub type IssueId = ObjectId;
/// Reason why an issue was closed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CloseReason {
Solved,
Other,
}
/// Issue state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase", tag = "status")]
pub enum State {
/// The issue is open.
Open,
/// The issue is closed.
Closed { reason: CloseReason },
}
impl State {
fn lifecycle_message(self) -> String {
match self {
State::Open => "Open issue".to_owned(),
State::Closed { .. } => "Close issue".to_owned(),
}
}
}
impl From<State> for ScalarValue {
fn from(state: State) -> Self {
match state {
State::Open => ScalarValue::from("open"),
State::Closed {
reason: CloseReason::Solved,
} => ScalarValue::from("solved"),
State::Closed {
reason: CloseReason::Other,
} => ScalarValue::from("closed"),
}
}
}
impl<'a> FromValue<'a> for State {
fn from_value(value: Value) -> Result<Self, ValueError> {
let state = value.to_str().ok_or(ValueError::InvalidType)?;
match state {
"open" => Ok(Self::Open),
"closed" => Ok(Self::Closed {
reason: CloseReason::Other,
}),
"solved" => Ok(Self::Closed {
reason: CloseReason::Solved,
}),
_ => Err(ValueError::InvalidValue(value.to_string())),
}
}
}
/// An issue or "ticket".
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
pub author: Author,
pub title: String,
pub state: State,
pub comment: Comment,
pub discussion: Discussion,
pub labels: HashSet<Label>,
pub timestamp: Timestamp,
}
impl Issue {
pub fn author(&self) -> &Author {
&self.author
}
pub fn title(&self) -> &str {
&self.title
}
pub fn state(&self) -> State {
self.state
}
pub fn description(&self) -> &str {
&self.comment.body
}
pub fn reactions(&self) -> &HashMap<Reaction, usize> {
&self.comment.reactions
}
pub fn comments(&self) -> &[Comment<Replies>] {
&self.discussion
}
pub fn labels(&self) -> &HashSet<Label> {
&self.labels
}
pub fn timestamp(&self) -> Timestamp {
self.timestamp
}
}
impl FromHistory for Issue {
fn type_name() -> &'static TypeName {
&TYPENAME
}
fn from_history(history: &History) -> Result<Self, Error> {
let doc = history.traverse(Automerge::new(), |mut doc, entry| {
match entry.contents() {
Contents::Automerge(bytes) => {
match automerge::Change::from_bytes(bytes.clone()) {
Ok(change) => {
doc.apply_changes([change]).ok();
}
Err(_err) => {
// Ignore
}
}
}
}
ControlFlow::Continue(doc)
});
let issue = Issue::try_from(doc)?;
Ok(issue)
}
}
impl TryFrom<&History> for Issue {
type Error = Error;
fn try_from(history: &History) -> Result<Self, Self::Error> {
Issue::from_history(history)
}
}
impl TryFrom<Automerge> for Issue {
type Error = DocumentError;
fn try_from(doc: Automerge) -> Result<Self, Self::Error> {
let doc = Document::new(&doc);
let obj_id = doc.get_id(automerge::ObjId::Root, "issue")?;
let title = doc.get(&obj_id, "title")?;
let comment_id = doc.get_id(&obj_id, "comment")?;
let author = doc.get(&obj_id, "author").map(Author::new)?;
let state = doc.get(&obj_id, "state")?;
let timestamp = doc.get(&obj_id, "timestamp")?;
let comment = shared::lookup::comment(doc, &comment_id)?;
let discussion: Discussion = doc.list(&obj_id, "discussion", shared::lookup::thread)?;
let labels: HashSet<Label> = doc.keys(&obj_id, "labels")?;
Ok(Self {
title,
state,
author,
comment,
discussion,
labels,
timestamp,
})
}
}
pub struct IssueStore<'a> {
store: Store<'a, Issue>,
}
impl<'a> IssueStore<'a> {
/// Create a new issue store.
pub fn new(store: Store<'a, Issue>) -> Self {
Self { store }
}
/// Get an issue by id.
pub fn get(&self, id: &ObjectId) -> Result<Option<Issue>, Error> {
self.store.get(id)
}
/// Create an issue.
pub fn create<G: Signer>(
&self,
title: &str,
description: &str,
labels: &[Label],
signer: &G,
) -> Result<IssueId, Error> {
let author = self.store.author();
let timestamp = Timestamp::now();
let contents = events::create(&author, title, description, timestamp, labels)?;
let cob = self.store.create("Create issue", contents, signer)?;
Ok(*cob.id())
}
/// Remove an issue.
pub fn remove<G: Signer>(&self, _issue_id: &IssueId) -> Result<(), Error> {
todo!()
}
/// Comment on an issue.
pub fn comment<G: Signer>(
&self,
issue_id: &IssueId,
body: &str,
signer: &G,
) -> Result<(), Error> {
let author = self.store.author();
let mut issue = self.store.get_raw(issue_id)?;
let timestamp = Timestamp::now();
let changes = events::comment(&mut issue, &author, body, timestamp)?;
self.store
.update(*issue_id, "Add comment", changes, signer)?;
Ok(())
}
/// Life-cycle an issue, eg. open or close it.
pub fn lifecycle<G: Signer>(
&self,
issue_id: &IssueId,
state: State,
signer: &G,
) -> Result<(), Error> {
let author = self.store.author();
let mut issue = self.store.get_raw(issue_id)?;
let changes = events::lifecycle(&mut issue, &author, state)?;
self.store.update(*issue_id, "Lifecycle", changes, signer)?;
Ok(())
}
/// Label an issue.
pub fn label<G: Signer>(
&self,
issue_id: &IssueId,
labels: &[Label],
signer: &G,
) -> Result<(), Error> {
let author = self.store.author();
let mut issue = self.store.get_raw(issue_id)?;
let changes = events::label(&mut issue, &author, labels)?;
self.store.update(*issue_id, "Add label", changes, signer)?;
Ok(())
}
/// React to an issue comment.
pub fn react<G: Signer>(
&self,
issue_id: &IssueId,
comment_id: CommentId,
reaction: Reaction,
signer: &G,
) -> Result<(), Error> {
let author = self.store.author();
let mut issue = self.store.get_raw(issue_id)?;
let changes = events::react(&mut issue, comment_id, &author, &[reaction])?;
self.store.update(*issue_id, "React", changes, signer)?;
Ok(())
}
/// Reply to an issue comment.
pub fn reply<G: Signer>(
&self,
issue_id: &IssueId,
comment_id: CommentId,
reply: &str,
signer: &G,
) -> Result<(), Error> {
let author = self.store.author();
let mut issue = self.store.get_raw(issue_id)?;
let changes = events::reply(&mut issue, comment_id, &author, reply, Timestamp::now())?;
self.store.update(*issue_id, "Reply", changes, signer)?;
Ok(())
}
/// Get all issues, sorted by time.
pub fn all(&self) -> Result<Vec<(IssueId, Issue)>, Error> {
let mut issues = self.store.list()?;
issues.sort_by_key(|(_, i)| i.timestamp);
Ok(issues)
}
/// Get the issue count.
pub fn count(&self) -> Result<usize, Error> {
let issues = self.store.list()?;
Ok(issues.len())
}
}
/// Issue events.
mod events {
use super::*;
use automerge::{
transaction::{CommitOptions, Transactable},
ObjId,
};
pub fn create(
author: &Author,
title: &str,
description: &str,
timestamp: Timestamp,
labels: &[Label],
) -> Result<Contents, TransactionError> {
let title = title.trim();
if title.is_empty() {
return Err(TransactionError::InvalidValue("title"));
}
let mut doc = Automerge::new();
let _issue = doc
.transact_with::<_, _, TransactionError, _, ()>(
|_| CommitOptions::default().with_message("Create issue".to_owned()),
|tx| {
let issue = tx.put_object(ObjId::Root, "issue", ObjType::Map)?;
tx.put(&issue, "title", title)?;
tx.put(&issue, "author", author)?;
tx.put(&issue, "state", State::Open)?;
tx.put(&issue, "timestamp", timestamp)?;
tx.put_object(&issue, "discussion", ObjType::List)?;
let labels_id = tx.put_object(&issue, "labels", ObjType::Map)?;
for label in labels {
tx.put(&labels_id, label.name().trim(), true)?;
}
// Nb. The top-level comment doesn't have a `replies` field.
let comment_id = tx.put_object(&issue, "comment", ObjType::Map)?;
tx.put(&comment_id, "body", description.trim())?;
tx.put(&comment_id, "author", author)?;
tx.put(&comment_id, "timestamp", timestamp)?;
tx.put_object(&comment_id, "reactions", ObjType::Map)?;
Ok(issue)
},
)
.map_err(|failure| failure.error)?
.result;
Ok(Contents::Automerge(doc.save_incremental()))
}
pub fn comment(
issue: &mut Automerge,
author: &Author,
body: &str,
timestamp: Timestamp,
) -> Result<Contents, TransactionError> {
let _comment = issue
.transact_with::<_, _, TransactionError, _, ()>(
|_| CommitOptions::default().with_message("Add comment".to_owned()),
|tx| {
let mut tx = Transaction::new(tx);
let (_obj, obj_id) = tx.get(ObjId::Root, "issue")?;
let (_, discussion_id) = tx.get(&obj_id, "discussion")?;
let length = tx.length(&discussion_id);
let comment = tx.insert_object(&discussion_id, length, ObjType::Map)?;
tx.put(&comment, "author", author)?;
tx.put(&comment, "body", body.trim())?;
tx.put(&comment, "timestamp", timestamp)?;
tx.put_object(&comment, "replies", ObjType::List)?;
tx.put_object(&comment, "reactions", ObjType::Map)?;
Ok(comment)
},
)
.map_err(|failure| failure.error)?
.result;
#[allow(clippy::unwrap_used)]
let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
Ok(Contents::Automerge(change))
}
pub fn lifecycle(
issue: &mut Automerge,
author: &Author,
state: State,
) -> Result<Contents, TransactionError> {
issue
.transact_with::<_, _, TransactionError, _, ()>(
|_| CommitOptions::default().with_message(state.lifecycle_message()),
|tx| {
let mut tx = Transaction::new(tx);
let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
tx.put(&obj_id, "state", state)?;
tx.put(&obj_id, "author", author)?;
Ok(())
},
)
.map_err(|failure| failure.error)?;
#[allow(clippy::unwrap_used)]
let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
Ok(Contents::Automerge(change))
}
pub fn label(
issue: &mut Automerge,
_author: &Author,
labels: &[Label],
) -> Result<Contents, TransactionError> {
issue
.transact_with::<_, _, TransactionError, _, ()>(
|_| CommitOptions::default().with_message("Label issue".to_owned()),
|tx| {
let mut tx = Transaction::new(tx);
let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
let (_, labels_id) = tx.get(&obj_id, "labels")?;
for label in labels {
tx.put(&labels_id, label.name().trim(), true)?;
}
Ok(())
},
)
.map_err(|failure| failure.error)?;
#[allow(clippy::unwrap_used)]
let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
Ok(Contents::Automerge(change))
}
pub fn reply(
issue: &mut Automerge,
comment_id: CommentId,
author: &Author,
body: &str,
timestamp: Timestamp,
) -> Result<Contents, TransactionError> {
issue
.transact_with::<_, _, TransactionError, _, ()>(
|_| CommitOptions::default().with_message("Reply".to_owned()),
|tx| {
let mut tx = Transaction::new(tx);
let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
let (_, discussion_id) = tx.get(&obj_id, "discussion")?;
let (_, comment_id) = tx.get(&discussion_id, usize::from(comment_id))?;
let (_, replies_id) = tx.get(&comment_id, "replies")?;
let length = tx.length(&replies_id);
let reply = tx.insert_object(&replies_id, length, ObjType::Map)?;
// Nb. Replies don't themselves have replies.
tx.put(&reply, "author", author)?;
tx.put(&reply, "body", body.trim())?;
tx.put(&reply, "timestamp", timestamp)?;
tx.put_object(&reply, "reactions", ObjType::Map)?;
Ok(())
},
)
.map_err(|failure| failure.error)?;
#[allow(clippy::unwrap_used)]
let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
Ok(Contents::Automerge(change))
}
pub fn react(
issue: &mut Automerge,
comment_id: CommentId,
author: &Author,
reactions: &[Reaction],
) -> Result<Contents, TransactionError> {
issue
.transact_with::<_, _, TransactionError, _, ()>(
|_| CommitOptions::default().with_message("React".to_owned()),
|tx| {
let mut tx = Transaction::new(tx);
let (_, obj_id) = tx.get(ObjId::Root, "issue")?;
let (_, discussion_id) = tx.get(&obj_id, "discussion")?;
let (_, comment_id) = if comment_id == CommentId::root() {
tx.get(&obj_id, "comment")?
} else {
tx.get(&discussion_id, usize::from(comment_id) - 1)?
};
let (_, reactions_id) = tx.get(&comment_id, "reactions")?;
for reaction in reactions {
let key = reaction.emoji.to_string();
let reaction_id = if let Some((_, reaction_id)) =
tx.try_get(&reactions_id, key)?
{
reaction_id
} else {
tx.put_object(&reactions_id, reaction.emoji.to_string(), ObjType::Map)?
};
tx.put(&reaction_id, author.id.to_human(), true)?;
}
Ok(())
},
)
.map_err(|failure| failure.error)?;
#[allow(clippy::unwrap_used)]
let change = issue.get_last_local_change().unwrap().raw_bytes().to_vec();
Ok(Contents::Automerge(change))
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::test;
#[test]
fn test_issue_create_and_get() {
let tmp = tempfile::tempdir().unwrap();
let (_, signer, project) = test::setup::context(&tmp);
let store = Store::open(*signer.public_key(), &project).unwrap();
let issues = store.issues();
let timestamp = Timestamp::now();
let issue_id = issues
.create("My first issue", "Blah blah blah.", &[], &signer)
.unwrap();
let issue = issues.get(&issue_id).unwrap().unwrap();
assert_eq!(issue.title(), "My first issue");
assert_eq!(issue.author(), &store.author());
assert_eq!(issue.description(), "Blah blah blah.");
assert_eq!(issue.comments().len(), 0);
assert_eq!(issue.state(), State::Open);
assert!(issue.timestamp() >= timestamp);
}
#[test]
fn test_issue_create_and_change_state() {
let tmp = tempfile::tempdir().unwrap();
let (_, signer, project) = test::setup::context(&tmp);
let store = Store::open(*signer.public_key(), &project).unwrap();
let issues = store.issues();
let issue_id = issues
.create("My first issue", "Blah blah blah.", &[], &signer)
.unwrap();
issues
.lifecycle(
&issue_id,
State::Closed {
reason: CloseReason::Other,
},
&signer,
)
.unwrap();
let issue = issues.get(&issue_id).unwrap().unwrap();
assert_eq!(
issue.state(),
State::Closed {
reason: CloseReason::Other
}
);
issues.lifecycle(&issue_id, State::Open, &signer).unwrap();
let issue = issues.get(&issue_id).unwrap().unwrap();
assert_eq!(issue.state(), State::Open);
}
#[test]
fn test_issue_react() {
let tmp = tempfile::tempdir().unwrap();
let (_, signer, project) = test::setup::context(&tmp);
let store = Store::open(*signer.public_key(), &project).unwrap();
let issues = store.issues();
let issue_id = issues
.create("My first issue", "Blah blah blah.", &[], &signer)
.unwrap();
let reaction = Reaction::new('🥳').unwrap();
issues
.react(&issue_id, CommentId::root(), reaction, &signer)
.unwrap();
let issue = issues.get(&issue_id).unwrap().unwrap();
let count = issue.reactions()[&reaction];
// TODO: Test multiple reactions from same author and different authors
assert_eq!(count, 1);
}
#[test]
fn test_issue_reply() {
let tmp = tempfile::tempdir().unwrap();
let (_, signer, project) = test::setup::context(&tmp);
let store = Store::open(*signer.public_key(), &project).unwrap();
let issues = store.issues();
let issue_id = issues
.create("My first issue", "Blah blah blah.", &[], &signer)
.unwrap();
issues.comment(&issue_id, "Ho ho ho.", &signer).unwrap();
issues
.reply(&issue_id, CommentId::root(), "Hi hi hi.", &signer)
.unwrap();
issues
.reply(&issue_id, CommentId::root(), "Ha ha ha.", &signer)
.unwrap();
let issue = issues.get(&issue_id).unwrap().unwrap();
let reply1 = &issue.comments()[0].replies[0];
let reply2 = &issue.comments()[0].replies[1];
assert_eq!(reply1.body, "Hi hi hi.");
assert_eq!(reply2.body, "Ha ha ha.");
}
#[test]
fn test_issue_label() {
let tmp = tempfile::tempdir().unwrap();
let (_, signer, project) = test::setup::context(&tmp);
let store = Store::open(*signer.public_key(), &project).unwrap();
let issues = store.issues();
let issue_id = issues
.create("My first issue", "Blah blah blah.", &[], &signer)
.unwrap();
let bug_label = Label::new("bug").unwrap();
let wontfix_label = Label::new("wontfix").unwrap();
issues
.label(&issue_id, &[bug_label.clone()], &signer)
.unwrap();
issues
.label(&issue_id, &[wontfix_label.clone()], &signer)
.unwrap();
let issue = issues.get(&issue_id).unwrap().unwrap();
let labels = issue.labels();
assert!(labels.contains(&bug_label));
assert!(labels.contains(&wontfix_label));
}
#[test]
fn test_issue_comment() {
let tmp = tempfile::tempdir().unwrap();
let (_, signer, project) = test::setup::context(&tmp);
let store = Store::open(*signer.public_key(), &project).unwrap();
let issues = store.issues();
let now = Timestamp::now();
let author = *signer.public_key();
let issue_id = issues
.create("My first issue", "Blah blah blah.", &[], &signer)
.unwrap();
issues.comment(&issue_id, "Ho ho ho.", &signer).unwrap();
issues.comment(&issue_id, "Ha ha ha.", &signer).unwrap();
let issue = issues.get(&issue_id).unwrap().unwrap();
let c1 = &issue.comments()[0];
let c2 = &issue.comments()[1];
assert_eq!(&c1.body, "Ho ho ho.");
assert_eq!(c1.author.id(), &author);
assert_eq!(&c2.body, "Ha ha ha.");
assert_eq!(c2.author.id(), &author);
assert!(c1.timestamp >= now);
}
#[test]
fn test_issue_state_serde() {
assert_eq!(
serde_json::to_value(State::Open).unwrap(),
serde_json::json!({ "status": "open" })
);
assert_eq!(
serde_json::to_value(State::Closed {
reason: CloseReason::Solved
})
.unwrap(),
serde_json::json!({ "status": "closed", "reason": "solved" })
);
}
#[test]
fn test_issue_all() {
let tmp = tempfile::tempdir().unwrap();
let (_, signer, project) = test::setup::context(&tmp);
let store = Store::open(*signer.public_key(), &project).unwrap();
let issues = store.issues();
let author = store.author();
let contents =
events::create(&author, "First", "Blah blah.", Timestamp::new(0), &[]).unwrap();
issues
.store
.create("Create issue", contents, &signer)
.unwrap();
let contents =
events::create(&author, "Second", "Blah blah.", Timestamp::new(1), &[]).unwrap();
issues
.store
.create("Create issue", contents, &signer)
.unwrap();
let contents =
events::create(&author, "Third", "Blah blah.", Timestamp::new(2), &[]).unwrap();
issues
.store
.create("Create issue", contents, &signer)
.unwrap();
let issues = issues.all().unwrap();
assert_eq!(issues.len(), 3);
// Issues are sorted by timestamp.
assert_eq!(issues[0].1.title(), "First");
assert_eq!(issues[1].1.title(), "Second");
assert_eq!(issues[2].1.title(), "Third");
}
}

176
radicle/src/cob/label.rs Normal file
View File

@ -0,0 +1,176 @@
#![allow(clippy::large_enum_variant)]
use std::convert::TryFrom;
use std::ops::ControlFlow;
use std::str::FromStr;
use automerge::{Automerge, ObjType};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use crate::cob::doc::Document;
use crate::cob::shared::*;
use crate::cob::store::{Error, FromHistory, Store};
use crate::cob::transaction::TransactionError;
use crate::cob::{Contents, History, ObjectId, TypeName};
use crate::prelude::*;
pub static TYPENAME: Lazy<TypeName> =
Lazy::new(|| FromStr::from_str("xyz.radicle.label").expect("type name is valid"));
/// Identifier for a label.
pub type LabelId = ObjectId;
/// Describes a label.
#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)]
pub struct Label {
pub name: String,
pub description: String,
pub color: Color,
}
impl FromHistory for Label {
fn type_name() -> &'static TypeName {
&TYPENAME
}
fn from_history(history: &History) -> Result<Self, Error> {
Label::try_from(history)
}
}
impl TryFrom<&History> for Label {
type Error = Error;
fn try_from(history: &History) -> Result<Self, Self::Error> {
let doc = history.traverse(Automerge::new(), |mut doc, entry| {
match entry.contents() {
Contents::Automerge(bytes) => {
match automerge::Change::from_bytes(bytes.clone()) {
Ok(change) => {
doc.apply_changes([change]).ok();
}
Err(_err) => {
// Ignore
}
}
}
}
ControlFlow::Continue(doc)
});
let label = Label::try_from(doc)?;
Ok(label)
}
}
impl TryFrom<Automerge> for Label {
type Error = Error;
fn try_from(doc: Automerge) -> Result<Self, Self::Error> {
let doc = Document::new(&doc);
let obj_id = doc.get_id(automerge::ObjId::Root, "label")?;
let name = doc.get(&obj_id, "name")?;
let description = doc.get(&obj_id, "description")?;
let color = doc.get(&obj_id, "color")?;
Ok(Self {
name,
description,
color,
})
}
}
pub struct LabelStore<'a> {
store: Store<'a, Label>,
}
impl<'a> LabelStore<'a> {
pub fn new(store: Store<'a, Label>) -> Self {
Self { store }
}
pub fn create<G: Signer>(
&self,
name: &str,
description: &str,
color: &Color,
signer: &G,
) -> Result<LabelId, Error> {
let author = self.store.author();
let _timestamp = Timestamp::now();
let contents = events::create(&author, name, description, color)?;
let cob = self.store.create("Create label", contents, signer)?;
Ok(*cob.id())
}
pub fn get(&self, id: &LabelId) -> Result<Option<Label>, Error> {
self.store.get(id)
}
}
mod events {
use super::*;
use automerge::{
transaction::{CommitOptions, Transactable},
ObjId,
};
pub fn create(
_author: &Author,
name: &str,
description: &str,
color: &Color,
) -> Result<Contents, TransactionError> {
let name = name.trim();
if name.is_empty() {
return Err(TransactionError::InvalidValue("name"));
}
let mut doc = Automerge::new();
doc.transact_with::<_, _, TransactionError, _, ()>(
|_| CommitOptions::default().with_message("Create label".to_owned()),
|tx| {
let label = tx.put_object(ObjId::Root, "label", ObjType::Map)?;
tx.put(&label, "name", name)?;
tx.put(&label, "description", description)?;
tx.put(&label, "color", color.to_string())?;
Ok(label)
},
)
.map_err(|failure| failure.error)?;
Ok(Contents::Automerge(doc.save_incremental()))
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::test;
#[test]
fn test_label_create_and_get() {
let tmp = tempfile::tempdir().unwrap();
let (_, signer, project) = test::setup::context(&tmp);
let store = Store::open(*signer.public_key(), &project).unwrap();
let labels = store.labels();
let label_id = labels
.create(
"bug",
"Something that doesn't work",
&Color::from_str("#ff0000").unwrap(),
&signer,
)
.unwrap();
let label = labels.get(&label_id).unwrap().unwrap();
assert_eq!(label.name, "bug");
assert_eq!(label.description, "Something that doesn't work");
assert_eq!(label.color.to_string(), "#ff0000");
}
}

1082
radicle/src/cob/patch.rs Normal file

File diff suppressed because it is too large Load Diff

384
radicle/src/cob/shared.rs Normal file
View File

@ -0,0 +1,384 @@
#![allow(clippy::large_enum_variant)]
use std::borrow::Borrow;
use std::collections::HashMap;
use std::fmt;
use std::hash::Hash;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
use automerge::transaction::Transactable;
use automerge::{AutomergeError, ObjType, ScalarValue};
use serde::{Deserialize, Serialize};
use crate::cob::doc::{Document, DocumentError};
use crate::cob::value::{FromValue, Value, ValueError};
use crate::prelude::*;
/// A discussion thread.
pub type Discussion = Vec<Comment<Replies>>;
#[derive(thiserror::Error, Debug)]
pub enum ReactionError {
#[error("invalid reaction")]
InvalidReaction,
}
#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Reaction {
pub emoji: char,
}
impl Reaction {
pub fn new(emoji: char) -> Result<Self, ReactionError> {
if emoji.is_whitespace() || emoji.is_ascii() || emoji.is_alphanumeric() {
return Err(ReactionError::InvalidReaction);
}
Ok(Self { emoji })
}
}
impl FromStr for Reaction {
type Err = ReactionError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut chars = s.chars();
let first = chars.next().ok_or(ReactionError::InvalidReaction)?;
// Reactions should not consist of more than a single emoji.
if chars.next().is_some() {
return Err(ReactionError::InvalidReaction);
}
Reaction::new(first)
}
}
#[derive(thiserror::Error, Debug)]
pub enum LabelError {
#[error("invalid label name: `{0}`")]
InvalidName(String),
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Label(String);
impl Label {
pub fn new(name: impl Into<String>) -> Result<Self, LabelError> {
let name = name.into();
if name.chars().any(|c| c.is_whitespace()) || name.is_empty() {
return Err(LabelError::InvalidName(name));
}
Ok(Self(name))
}
pub fn name(&self) -> &str {
self.0.as_str()
}
}
impl FromStr for Label {
type Err = LabelError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl From<Label> for String {
fn from(Label(name): Label) -> Self {
name
}
}
/// RGB color.
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub struct Color(u32);
#[derive(thiserror::Error, Debug)]
pub enum ColorConversionError {
#[error("invalid format: expect '#rrggbb'")]
InvalidFormat,
#[error(transparent)]
ParseInt(#[from] std::num::ParseIntError),
}
impl fmt::Display for Color {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "#{:06x}", self.0)
}
}
impl FromStr for Color {
type Err = ColorConversionError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let hex = s.replace('#', "").to_lowercase();
if hex.chars().count() != 6 {
return Err(ColorConversionError::InvalidFormat);
}
match u32::from_str_radix(&hex, 16) {
Ok(n) => Ok(Color(n)),
Err(e) => Err(e.into()),
}
}
}
impl<'a> FromValue<'a> for Color {
fn from_value(val: Value<'a>) -> Result<Self, ValueError> {
let color = String::from_value(val)?;
let color = Self::from_str(&color).map_err(|_| ValueError::InvalidValue(color))?;
Ok(color)
}
}
impl Serialize for Color {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
let s = self.to_string();
serializer.serialize_str(&s)
}
}
impl<'a> Deserialize<'a> for Color {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'a>,
{
let color = String::deserialize(deserializer)?;
Self::from_str(&color).map_err(serde::de::Error::custom)
}
}
/// Author.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Author {
pub id: NodeId,
}
impl Author {
pub fn new(id: NodeId) -> Self {
Self { id }
}
pub fn id(&self) -> &NodeId {
&self.id
}
}
impl From<&Author> for ScalarValue {
fn from(author: &Author) -> Self {
ScalarValue::from(author.id.to_human())
}
}
/// Local id of a comment in an issue.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Clone)]
pub struct CommentId {
/// Represents the index of the comment in the thread,
/// with `0` being the top-level comment.
ix: usize,
}
impl CommentId {
/// Root comment.
pub const fn root() -> Self {
Self { ix: 0 }
}
}
impl From<usize> for CommentId {
fn from(ix: usize) -> Self {
Self { ix }
}
}
impl From<CommentId> for usize {
fn from(id: CommentId) -> Self {
id.ix
}
}
/// Comment replies.
pub type Replies = Vec<Comment>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment<R = ()> {
pub author: Author,
pub body: String,
pub reactions: HashMap<Reaction, usize>,
pub replies: R,
pub timestamp: Timestamp,
}
impl<R: Default> Comment<R> {
pub fn new(author: Author, body: String, timestamp: Timestamp) -> Self {
Self {
author,
body,
reactions: HashMap::default(),
replies: R::default(),
timestamp,
}
}
}
impl Comment<()> {
pub(super) fn put(
&self,
tx: &mut automerge::transaction::Transaction,
id: &automerge::ObjId,
) -> Result<(), AutomergeError> {
let comment_id = tx.put_object(id, "comment", ObjType::Map)?;
assert!(
self.reactions.is_empty(),
"Cannot put comment with non-empty reactions"
);
tx.put(&comment_id, "body", self.body.trim())?;
tx.put(&comment_id, "author", self.author.id().to_string())?;
tx.put(&comment_id, "timestamp", self.timestamp)?;
tx.put_object(&comment_id, "reactions", ObjType::Map)?;
Ok(())
}
}
impl Comment<Replies> {
pub(super) fn put(
&self,
tx: &mut automerge::transaction::Transaction,
id: &automerge::ObjId,
) -> Result<(), AutomergeError> {
let comment_id = tx.put_object(id, "comment", ObjType::Map)?;
assert!(
self.reactions.is_empty(),
"Cannot put comment with non-empty reactions"
);
assert!(
self.replies.is_empty(),
"Cannot put comment with non-empty replies"
);
tx.put(&comment_id, "body", self.body.trim())?;
tx.put(&comment_id, "author", self.author.id().to_string())?;
tx.put(&comment_id, "timestamp", self.timestamp)?;
tx.put_object(&comment_id, "reactions", ObjType::Map)?;
tx.put_object(&comment_id, "replies", ObjType::List)?;
Ok(())
}
}
#[derive(Debug, Copy, Clone, PartialOrd, PartialEq, Ord, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Timestamp {
seconds: u64,
}
impl Timestamp {
pub fn new(seconds: u64) -> Self {
Self { seconds }
}
pub fn now() -> Self {
#[allow(clippy::unwrap_used)] // Safe because Unix was already invented!
let duration = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
Self {
seconds: duration.as_secs(),
}
}
pub fn as_secs(&self) -> u64 {
self.seconds
}
}
impl From<Timestamp> for ScalarValue {
fn from(ts: Timestamp) -> Self {
ScalarValue::Timestamp(ts.seconds as i64)
}
}
impl<'a> FromValue<'a> for Timestamp {
fn from_value(val: Value<'a>) -> Result<Self, ValueError> {
if let Value::Scalar(scalar) = &val {
if let ScalarValue::Timestamp(ts) = scalar.borrow() {
return Ok(Self {
seconds: *ts as u64,
});
}
}
Err(ValueError::InvalidValue(val.to_string()))
}
}
pub mod lookup {
use super::{Author, Comment, HashMap, Reaction, Replies};
use super::{Document, DocumentError};
pub fn comment(doc: Document, obj_id: &automerge::ObjId) -> Result<Comment<()>, DocumentError> {
let author = doc.get(obj_id, "author").map(Author::new)?;
let body = doc.get(obj_id, "body")?;
let timestamp = doc.get(obj_id, "timestamp")?;
let reactions: HashMap<Reaction, usize> = doc.map(obj_id, "reactions", |v| *v += 1)?;
Ok(Comment {
author,
body,
reactions,
replies: (),
timestamp,
})
}
pub fn thread(
doc: Document,
obj_id: &automerge::ObjId,
) -> Result<Comment<Replies>, DocumentError> {
let comment = self::comment(doc, obj_id)?;
let replies = doc.list(obj_id, "replies", self::comment)?;
Ok(Comment {
author: comment.author,
body: comment.body,
reactions: comment.reactions,
replies,
timestamp: comment.timestamp,
})
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_color() {
let c = Color::from_str("#ffccaa").unwrap();
assert_eq!(c.to_string(), "#ffccaa".to_owned());
assert_eq!(serde_json::to_string(&c).unwrap(), "\"#ffccaa\"".to_owned());
assert_eq!(serde_json::from_str::<'_, Color>("\"#ffccaa\"").unwrap(), c);
let c = Color::from_str("#0000aa").unwrap();
assert_eq!(c.to_string(), "#0000aa".to_owned());
let c = Color::from_str("#aa0000").unwrap();
assert_eq!(c.to_string(), "#aa0000".to_owned());
let c = Color::from_str("#00aa00").unwrap();
assert_eq!(c.to_string(), "#00aa00".to_owned());
Color::from_str("#aa00").unwrap_err();
Color::from_str("#abc").unwrap_err();
}
}

204
radicle/src/cob/store.rs Normal file
View File

@ -0,0 +1,204 @@
//! Generic COB storage.
#![allow(clippy::large_enum_variant)]
use std::marker::PhantomData;
use std::ops::ControlFlow;
use automerge::{Automerge, AutomergeError};
use crate::cob;
use crate::cob::doc::DocumentError;
use crate::cob::shared::Author;
use crate::cob::transaction::TransactionError;
use crate::cob::CollaborativeObject;
use crate::cob::{issue, label, patch};
use crate::cob::{Contents, Create, History, ObjectId, TypeName, Update};
use crate::crypto::PublicKey;
use crate::git;
use crate::identity::project;
use crate::prelude::*;
use crate::storage::git as storage;
/// Store error.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("create error: {0}")]
Create(#[from] cob::error::Create),
#[error("update error: {0}")]
Update(#[from] cob::error::Update),
#[error("retrieve error: {0}")]
Retrieve(#[from] cob::error::Retrieve),
#[error(transparent)]
Automerge(#[from] AutomergeError),
#[error(transparent)]
Transaction(#[from] TransactionError),
#[error(transparent)]
Identity(#[from] project::IdentityError),
#[error(transparent)]
Document(#[from] DocumentError),
#[error("object `{1}`of type `{0}` was not found")]
NotFound(TypeName, ObjectId),
}
/// A type that can be materialized from an event history.
/// All collaborative objects implement this trait.
pub trait FromHistory: Sized {
/// The object type name.
fn type_name() -> &'static TypeName;
/// Create an object from a history.
fn from_history(history: &History) -> Result<Self, Error>;
}
/// Storage for collaborative objects of a specific type `T` in a single project.
pub struct Store<'a, T> {
whoami: PublicKey,
project: project::Identity<git::Oid>,
raw: &'a storage::Repository,
witness: PhantomData<T>,
}
impl<'a, T> AsRef<storage::Repository> for Store<'a, T> {
fn as_ref(&self) -> &storage::Repository {
self.raw
}
}
impl<'a> Store<'a, ()> {
/// Open a new generic store.
pub fn open(whoami: PublicKey, store: &'a storage::Repository) -> Result<Self, Error> {
let project = project::Identity::load(&whoami, store)?;
Ok(Self {
project,
whoami,
raw: store,
witness: PhantomData,
})
}
/// Return a patch store from this generic store.
pub fn patches(&self) -> patch::PatchStore<'_> {
patch::PatchStore::new(Store {
whoami: self.whoami,
project: self.project.clone(),
raw: self.raw,
witness: PhantomData,
})
}
/// Return an issues store from this generic store.
pub fn issues(&self) -> issue::IssueStore<'_> {
issue::IssueStore::new(Store {
whoami: self.whoami,
project: self.project.clone(),
raw: self.raw,
witness: PhantomData,
})
}
/// Return a labels store from this generic store.
pub fn labels(&self) -> label::LabelStore<'_> {
label::LabelStore::new(Store {
whoami: self.whoami,
project: self.project.clone(),
raw: self.raw,
witness: PhantomData,
})
}
}
impl<'a, T> Store<'a, T> {
/// Get this store's author.
pub fn author(&self) -> Author {
Author::new(self.whoami)
}
}
impl<'a, T: FromHistory> Store<'a, T> {
/// Update an object.
pub fn update<G: Signer>(
&self,
object_id: ObjectId,
message: &'static str,
changes: Contents,
signer: &G,
) -> Result<CollaborativeObject, cob::error::Update> {
cob::update(
self.raw,
signer,
&self.project,
Update {
author: Some(cob::Author::from(*signer.public_key())),
object_id,
typename: T::type_name().clone(),
message: message.to_owned(),
changes,
},
)
}
/// Create an object.
pub fn create<G: Signer>(
&self,
message: &'static str,
contents: Contents,
signer: &G,
) -> Result<CollaborativeObject, cob::error::Create> {
cob::create(
self.raw,
signer,
&self.project,
Create {
author: Some(cob::Author::from(*signer.public_key())),
typename: T::type_name().clone(),
message: message.to_owned(),
contents,
},
)
}
/// Get an object.
pub fn get(&self, id: &ObjectId) -> Result<Option<T>, Error> {
let cob = cob::get(self.raw, T::type_name(), id)?;
if let Some(cob) = cob {
let history = cob.history();
let obj = T::from_history(history)?;
Ok(Some(obj))
} else {
Ok(None)
}
}
/// Get an object as a raw automerge document.
pub fn get_raw(&self, id: &ObjectId) -> Result<Automerge, Error> {
let Some(cob) = cob::get(self.raw, T::type_name(), id)? else {
return Err(Error::NotFound(T::type_name().clone(), *id));
};
let doc = cob.history().traverse(Vec::new(), |mut doc, entry| {
match entry.contents() {
Contents::Automerge(bytes) => {
doc.extend(bytes);
}
}
ControlFlow::Continue(doc)
});
let doc = Automerge::load(&doc)?;
Ok(doc)
}
/// List objects.
pub fn list(&self) -> Result<Vec<(ObjectId, T)>, Error> {
let raw = cob::list(self.raw, T::type_name())?;
raw.into_iter()
.map(|o| {
let obj = T::from_history(o.history())?;
Ok::<_, Error>((*o.id(), obj))
})
.collect()
}
}

View File

@ -0,0 +1,72 @@
use std::ops::{Deref, DerefMut};
use automerge::transaction::Transactable;
use automerge::AutomergeError;
use crate::cob::value::Value;
/// Wraps an automerge transaction with additional functionality.
#[derive(Debug)]
pub struct Transaction<'a, 'b> {
raw: &'a mut automerge::transaction::Transaction<'b>,
}
impl<'a, 'b> AsMut<automerge::transaction::Transaction<'b>> for Transaction<'a, 'b> {
fn as_mut(&mut self) -> &mut automerge::transaction::Transaction<'b> {
self.raw
}
}
impl<'a, 'b> Deref for Transaction<'a, 'b> {
type Target = automerge::transaction::Transaction<'b>;
fn deref(&self) -> &Self::Target {
self.raw
}
}
impl<'a, 'b> DerefMut for Transaction<'a, 'b> {
fn deref_mut(&mut self) -> &mut Self::Target {
self.raw
}
}
impl<'a, 'b> Transaction<'a, 'b> {
pub fn new(raw: &'a mut automerge::transaction::Transaction<'b>) -> Self {
Self { raw }
}
pub fn try_get<O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>>(
&self,
obj: O,
prop: P,
) -> Result<Option<(Value, automerge::ObjId)>, TransactionError> {
let prop = prop.into();
let result = self.raw.get(obj, prop)?;
Ok(result)
}
pub fn get<O: AsRef<automerge::ObjId>, P: Into<automerge::Prop>>(
&self,
obj: O,
prop: P,
) -> Result<(Value, automerge::ObjId), TransactionError> {
let prop = prop.into();
self.raw
.get(obj, prop.clone())?
.ok_or(TransactionError::PropertyNotFound(prop))
}
}
/// Transaction error.
#[derive(thiserror::Error, Debug)]
pub enum TransactionError {
#[error(transparent)]
Automerge(#[from] AutomergeError),
#[error("property '{0}' was not found in object")]
PropertyNotFound(automerge::Prop),
#[error("invalid property value for '{0}'")]
InvalidValue(&'static str),
}

77
radicle/src/cob/value.rs Normal file
View File

@ -0,0 +1,77 @@
#![allow(clippy::large_enum_variant)]
use std::str::FromStr;
use std::sync::Arc;
pub use automerge::{ScalarValue, Value};
use crate::git;
use crate::prelude::*;
/// Implemented by types that can be converted from a [`Value`].
pub trait FromValue<'a>: Sized {
fn from_value(val: Value<'a>) -> Result<Self, ValueError>;
}
#[derive(thiserror::Error, Debug)]
pub enum ValueError {
#[error("invalid type")]
InvalidType,
#[error("invalid value: `{0}`")]
InvalidValue(String),
#[error("value error: {0}")]
Other(Arc<dyn std::error::Error + Send + Sync>),
}
impl<'a, T> FromValue<'a> for Option<T>
where
T: FromValue<'a>,
{
fn from_value(val: Value<'a>) -> Result<Option<T>, ValueError> {
match val {
Value::Scalar(s) if s.is_null() => Ok(None),
_ => Ok(Some(T::from_value(val)?)),
}
}
}
impl<'a> FromValue<'a> for NodeId {
fn from_value(val: Value<'a>) -> Result<NodeId, ValueError> {
let peer = String::from_value(val)?;
let peer = NodeId::from_str(&peer).map_err(|e| ValueError::Other(Arc::new(e)))?;
Ok(peer)
}
}
impl<'a> FromValue<'a> for uuid::Uuid {
fn from_value(val: Value<'a>) -> Result<uuid::Uuid, ValueError> {
let uuid = String::from_value(val)?;
let uuid = uuid::Uuid::from_str(&uuid).map_err(|e| ValueError::Other(Arc::new(e)))?;
Ok(uuid)
}
}
impl<'a> FromValue<'a> for Id {
fn from_value(val: Value<'a>) -> Result<Id, ValueError> {
let id = String::from_value(val)?;
let id = Id::from_str(&id).map_err(|e| ValueError::Other(Arc::new(e)))?;
Ok(id)
}
}
impl<'a> FromValue<'a> for git::Oid {
fn from_value(val: Value<'a>) -> Result<git::Oid, ValueError> {
let oid = String::from_value(val)?;
let oid = git::Oid::from_str(&oid).map_err(|e| ValueError::Other(Arc::new(e)))?;
Ok(oid)
}
}
impl<'a> FromValue<'a> for String {
fn from_value(val: Value) -> Result<String, ValueError> {
val.into_string().map_err(|_| ValueError::InvalidType)
}
}

View File

@ -148,7 +148,11 @@ pub struct Paths<'a> {
home: &'a Path,
}
impl Paths<'_> {
impl<'a> Paths<'a> {
pub fn new(home: &'a Path) -> Self {
Self { home }
}
pub fn storage(&self) -> PathBuf {
self.home.join("storage")
}

View File

@ -3,3 +3,27 @@ pub mod arbitrary;
pub mod assert;
pub mod fixtures;
pub mod storage;
pub mod setup {
use tempfile::TempDir;
use crate::crypto::test::signer::MockSigner;
use crate::prelude::*;
use crate::{
profile::Paths,
test::{fixtures, storage::git::Repository},
Storage,
};
pub fn context(tmp: &TempDir) -> (Storage, MockSigner, Repository) {
let mut rng = fastrand::Rng::new();
let signer = MockSigner::new(&mut rng);
let home = tmp.path().join("home");
let paths = Paths::new(home.as_path());
let storage = Storage::open(paths.storage()).unwrap();
let (id, _, _, _) = fixtures::project(tmp.path().join("copy"), &storage, &signer).unwrap();
let project = storage.repository(id).unwrap();
(storage, signer, project)
}
}