Implement patches with radicle CRDTs
Signed-off-by: Alexis Sellier <alexis@radicle.xyz>
This commit is contained in:
parent
b82e1864b5
commit
0dc34dc15d
|
|
@ -118,26 +118,6 @@ version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
|
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]]
|
[[package]]
|
||||||
name = "axum"
|
name = "axum"
|
||||||
version = "0.5.17"
|
version = "0.5.17"
|
||||||
|
|
@ -347,9 +327,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.22"
|
version = "0.4.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1"
|
checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|
@ -852,9 +832,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ethereum-types"
|
name = "ethereum-types"
|
||||||
version = "0.14.0"
|
version = "0.14.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "81224dc661606574f5a0f28c9947d0ee1d93ff11c5f1c4e7272f52e8c0b5483c"
|
checksum = "02d215cbf040552efcbe99a38372fe80ab9d00268e20012b79fcd0f073edd8ee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ethbloom",
|
"ethbloom",
|
||||||
"fixed-hash",
|
"fixed-hash",
|
||||||
|
|
@ -1017,15 +997,6 @@ dependencies = [
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fxhash"
|
|
||||||
version = "0.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
|
||||||
dependencies = [
|
|
||||||
"byteorder",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.6"
|
version = "0.14.6"
|
||||||
|
|
@ -1558,12 +1529,6 @@ dependencies = [
|
||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "leb128"
|
|
||||||
version = "0.2.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lexical-core"
|
name = "lexical-core"
|
||||||
version = "0.7.6"
|
version = "0.7.6"
|
||||||
|
|
@ -2200,7 +2165,6 @@ dependencies = [
|
||||||
name = "radicle"
|
name = "radicle"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"automerge",
|
|
||||||
"base64",
|
"base64",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
|
|
@ -2228,7 +2192,6 @@ dependencies = [
|
||||||
"sqlite",
|
"sqlite",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"uuid 1.2.2",
|
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -2908,15 +2871,6 @@ version = "1.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
|
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]]
|
[[package]]
|
||||||
name = "snapbox"
|
name = "snapbox"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
|
|
@ -3268,9 +3222,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-macros"
|
name = "tokio-macros"
|
||||||
version = "1.8.0"
|
version = "1.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484"
|
checksum = "d266c00fde287f55d3f1c3e96c500c362a2b8c695076ec180f27918820bc6df8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
@ -3475,9 +3429,9 @@ checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uint"
|
name = "uint"
|
||||||
version = "0.9.4"
|
version = "0.9.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a45526d29728d135c2900b0d30573fe3ee79fceb12ef534c7bb30e810a91b601"
|
checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"crunchy",
|
"crunchy",
|
||||||
|
|
@ -3529,27 +3483,6 @@ dependencies = [
|
||||||
"percent-encoding",
|
"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.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "422ee0de9031b5b948b97a8fc04e3aa35230001a722ddd27943e0be31564ce4c"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.2.8",
|
|
||||||
"rand 0.8.5",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
|
||||||
|
|
@ -157,8 +157,10 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
let blob = Doc::blob_at(oid, &repo)?;
|
let blob = Doc::blob_at(oid, &repo)?;
|
||||||
let content: serde_json::Value = serde_json::from_slice(blob.content())?;
|
let content: serde_json::Value = serde_json::from_slice(blob.content())?;
|
||||||
let timezone = if tip.time().sign() == '+' {
|
let timezone = if tip.time().sign() == '+' {
|
||||||
|
#[allow(deprecated)]
|
||||||
FixedOffset::east(tip.time().offset_minutes() * 60)
|
FixedOffset::east(tip.time().offset_minutes() * 60)
|
||||||
} else {
|
} else {
|
||||||
|
#[allow(deprecated)]
|
||||||
FixedOffset::west(tip.time().offset_minutes() * 60)
|
FixedOffset::west(tip.time().offset_minutes() * 60)
|
||||||
};
|
};
|
||||||
let time = DateTime::<Utc>::from(
|
let time = DateTime::<Utc>::from(
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,8 @@ use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
use crate::terminal as term;
|
use crate::terminal as term;
|
||||||
use crate::terminal::args::{Args, Error, Help};
|
use crate::terminal::args::{Args, Error, Help};
|
||||||
use radicle::cob::automerge;
|
|
||||||
use radicle::cob::patch::RevisionIx;
|
use radicle::cob::patch::RevisionIx;
|
||||||
use radicle::cob::patch::{Patch, PatchId};
|
use radicle::cob::patch::{Patch, PatchId, Patches};
|
||||||
use radicle::git;
|
use radicle::git;
|
||||||
use radicle::prelude::*;
|
use radicle::prelude::*;
|
||||||
use radicle::rad;
|
use radicle::rad;
|
||||||
|
|
@ -141,8 +140,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
.project_of(profile.id())
|
.project_of(profile.id())
|
||||||
.context(format!("couldn't load project {} from local state", id))?;
|
.context(format!("couldn't load project {} from local state", id))?;
|
||||||
let repository = profile.storage.repository(id)?;
|
let repository = profile.storage.repository(id)?;
|
||||||
let cobs = automerge::Store::open(*profile.id(), &repository)?;
|
let mut patches = Patches::open(*profile.id(), &repository)?;
|
||||||
let patches = cobs.patches();
|
|
||||||
|
|
||||||
if repo.head_detached()? {
|
if repo.head_detached()? {
|
||||||
anyhow::bail!("HEAD is in a detached state; can't merge");
|
anyhow::bail!("HEAD is in a detached state; can't merge");
|
||||||
|
|
@ -152,9 +150,9 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
// Get patch information
|
// Get patch information
|
||||||
//
|
//
|
||||||
let patch_id = options.id;
|
let patch_id = options.id;
|
||||||
let patch = patches
|
let mut patch = patches
|
||||||
.get(&patch_id)?
|
.get_mut(&patch_id)
|
||||||
.ok_or_else(|| anyhow!("couldn't find patch {} locally", &options.id))?;
|
.map_err(|e| anyhow!("couldn't find patch {} locally: {e}", &options.id))?;
|
||||||
|
|
||||||
let head = repo.head()?;
|
let head = repo.head()?;
|
||||||
let branch = head
|
let branch = head
|
||||||
|
|
@ -163,11 +161,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
let head_oid = head
|
let head_oid = head
|
||||||
.target()
|
.target()
|
||||||
.ok_or_else(|| anyhow!("cannot merge into detatched head; aborting"))?;
|
.ok_or_else(|| anyhow!("cannot merge into detatched head; aborting"))?;
|
||||||
let revision_id = options.revision.unwrap_or_else(|| patch.version());
|
let revision_ix = options.revision.unwrap_or_else(|| patch.version());
|
||||||
let revision = patch
|
let (revision_id, revision) = patch
|
||||||
.revisions
|
.revisions()
|
||||||
.get(revision_id)
|
.nth(revision_ix)
|
||||||
.ok_or_else(|| anyhow!("revision R{} does not exist", revision_id))?;
|
.ok_or_else(|| anyhow!("revision R{} does not exist", revision_ix))?;
|
||||||
|
|
||||||
//
|
//
|
||||||
// Analyze merge
|
// Analyze merge
|
||||||
|
|
@ -234,9 +232,9 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
"{} {} {} ({}) by {} into {} ({}) via {}...",
|
"{} {} {} ({}) by {} into {} ({}) via {}...",
|
||||||
term::format::bold("Merging"),
|
term::format::bold("Merging"),
|
||||||
term::format::tertiary(term::format::cob(&patch_id)),
|
term::format::tertiary(term::format::cob(&patch_id)),
|
||||||
term::format::dim(format!("R{}", revision_id)),
|
term::format::dim(format!("R{}", revision_ix)),
|
||||||
term::format::secondary(term::format::oid(revision.oid)),
|
term::format::secondary(term::format::oid(revision.oid)),
|
||||||
term::format::tertiary(patch.author.id),
|
term::format::tertiary(patch.author().id),
|
||||||
term::format::highlight(branch),
|
term::format::highlight(branch),
|
||||||
term::format::secondary(term::format::oid(head_oid)),
|
term::format::secondary(term::format::oid(head_oid)),
|
||||||
merge_style_pretty
|
merge_style_pretty
|
||||||
|
|
@ -251,7 +249,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
//
|
//
|
||||||
match merge_style {
|
match merge_style {
|
||||||
MergeStyle::Commit => {
|
MergeStyle::Commit => {
|
||||||
merge_commit(&repo, patch_id, &patch_commit, &patch, cobs.public_key())?;
|
merge_commit(&repo, patch_id, &patch_commit, &patch, signer.public_key())?;
|
||||||
}
|
}
|
||||||
MergeStyle::FastForward => {
|
MergeStyle::FastForward => {
|
||||||
fast_forward(&repo, &revision.oid)?;
|
fast_forward(&repo, &revision.oid)?;
|
||||||
|
|
@ -270,7 +268,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
// Update patch COB
|
// Update patch COB
|
||||||
//
|
//
|
||||||
// TODO: Don't allow merging the same revision twice?
|
// TODO: Don't allow merging the same revision twice?
|
||||||
patches.merge(&patch_id, revision_id, head_oid.into(), &signer)?;
|
patch.merge(*revision_id, head_oid.into(), &signer)?;
|
||||||
|
|
||||||
term::success!(
|
term::success!(
|
||||||
"Patch state updated, use {} to publish",
|
"Patch state updated, use {} to publish",
|
||||||
|
|
@ -294,21 +292,21 @@ fn merge_commit(
|
||||||
patch: &Patch,
|
patch: &Patch,
|
||||||
whoami: &PublicKey,
|
whoami: &PublicKey,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let description = patch.description().trim();
|
let description = patch.description().unwrap_or_default().trim();
|
||||||
let mut merge_opts = git::raw::MergeOptions::new();
|
let mut merge_opts = git::raw::MergeOptions::new();
|
||||||
let mut merge_msg = format!(
|
let mut merge_msg = format!(
|
||||||
"Merge patch '{}' from {}",
|
"Merge patch '{}' from {}",
|
||||||
term::format::cob(&patch_id),
|
term::format::cob(&patch_id),
|
||||||
patch.author.id()
|
patch.author().id()
|
||||||
);
|
);
|
||||||
write!(&mut merge_msg, "\n\n")?;
|
write!(&mut merge_msg, "\n\n")?;
|
||||||
|
|
||||||
if !description.is_empty() {
|
if !description.is_empty() {
|
||||||
write!(&mut merge_msg, "{}", patch.description().trim())?;
|
write!(&mut merge_msg, "{}", description)?;
|
||||||
write!(&mut merge_msg, "\n\n")?;
|
write!(&mut merge_msg, "\n\n")?;
|
||||||
}
|
}
|
||||||
writeln!(&mut merge_msg, "Rad-Patch: {}", patch_id)?;
|
writeln!(&mut merge_msg, "Rad-Patch: {}", patch_id)?;
|
||||||
writeln!(&mut merge_msg, "Rad-Author: {}", patch.author.id())?;
|
writeln!(&mut merge_msg, "Rad-Author: {}", patch.author().id())?;
|
||||||
writeln!(&mut merge_msg, "Rad-Committer: {}", whoami)?;
|
writeln!(&mut merge_msg, "Rad-Committer: {}", whoami)?;
|
||||||
writeln!(&mut merge_msg)?;
|
writeln!(&mut merge_msg)?;
|
||||||
writeln!(&mut merge_msg, "{}", MERGE_HELP_MSG.trim())?;
|
writeln!(&mut merge_msg, "{}", MERGE_HELP_MSG.trim())?;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use radicle::cob::automerge::patch::{MergeTarget, Patch, PatchId, PatchStore};
|
use radicle::cob::patch::{Clock, MergeTarget, Patch, PatchId, Patches};
|
||||||
use radicle::git;
|
use radicle::git;
|
||||||
use radicle::git::raw::Oid;
|
use radicle::git::raw::Oid;
|
||||||
use radicle::prelude::*;
|
use radicle::prelude::*;
|
||||||
|
|
@ -111,15 +111,15 @@ pub fn find_unmerged_with_base(
|
||||||
patch_head: Oid,
|
patch_head: Oid,
|
||||||
target_head: Oid,
|
target_head: Oid,
|
||||||
merge_base: Oid,
|
merge_base: Oid,
|
||||||
patches: &PatchStore,
|
patches: &Patches,
|
||||||
workdir: &git::raw::Repository,
|
workdir: &git::raw::Repository,
|
||||||
) -> anyhow::Result<Vec<(PatchId, Patch)>> {
|
) -> anyhow::Result<Vec<(PatchId, Patch, Clock)>> {
|
||||||
// My patches.
|
// My patches.
|
||||||
let proposed: Vec<_> = patches.proposed_by(patches.public_key())?.collect();
|
let proposed: Vec<_> = patches.proposed_by(patches.public_key())?.collect();
|
||||||
let mut matches = Vec::new();
|
let mut matches = Vec::new();
|
||||||
|
|
||||||
for (id, patch) in proposed {
|
for (id, patch, clock) in proposed {
|
||||||
let (_, rev) = patch.latest();
|
let (_, rev) = patch.latest().unwrap();
|
||||||
|
|
||||||
if !rev.merges.is_empty() {
|
if !rev.merges.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -129,7 +129,7 @@ pub fn find_unmerged_with_base(
|
||||||
}
|
}
|
||||||
// Merge-base between the two patches.
|
// Merge-base between the two patches.
|
||||||
if workdir.merge_base(**patch.head(), target_head)? == merge_base {
|
if workdir.merge_base(**patch.head(), target_head)? == merge_base {
|
||||||
matches.push((id, patch));
|
matches.push((id, patch, clock));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(matches)
|
Ok(matches)
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
use radicle::cob::automerge;
|
use radicle::cob::patch::{MergeTarget, PatchId, PatchMut, Patches};
|
||||||
use radicle::cob::automerge::patch::{MergeTarget, Patch, PatchId, PatchStore};
|
|
||||||
use radicle::git;
|
use radicle::git;
|
||||||
use radicle::git::raw::Oid;
|
use radicle::git::raw::Oid;
|
||||||
use radicle::prelude::*;
|
use radicle::prelude::*;
|
||||||
|
|
@ -52,8 +51,7 @@ pub fn run(
|
||||||
));
|
));
|
||||||
|
|
||||||
let signer = term::signer(profile)?;
|
let signer = term::signer(profile)?;
|
||||||
let cobs = automerge::Store::open(profile.public_key, storage)?;
|
let mut patches = Patches::open(profile.public_key, storage)?;
|
||||||
let patches = cobs.patches();
|
|
||||||
|
|
||||||
// `HEAD`; This is what we are proposing as a patch.
|
// `HEAD`; This is what we are proposing as a patch.
|
||||||
let head = workdir.head()?;
|
let head = workdir.head()?;
|
||||||
|
|
@ -132,17 +130,17 @@ pub fn run(
|
||||||
workdir,
|
workdir,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
if let Some((id, patch)) = result.pop() {
|
if let Some((id, patch, clock)) = result.pop() {
|
||||||
if result.is_empty() {
|
if result.is_empty() {
|
||||||
spinner.message(format!(
|
spinner.message(format!(
|
||||||
"Found existing patch {} {}",
|
"Found existing patch {} {}",
|
||||||
term::format::tertiary(term::format::cob(&id)),
|
term::format::tertiary(term::format::cob(&id)),
|
||||||
term::format::italic(&patch.title)
|
term::format::italic(patch.title())
|
||||||
));
|
));
|
||||||
spinner.finish();
|
spinner.finish();
|
||||||
term::blank();
|
term::blank();
|
||||||
|
|
||||||
Some((id, patch))
|
Some((id, PatchMut::new(id, patch, clock, &mut patches)))
|
||||||
} else {
|
} else {
|
||||||
spinner.failed();
|
spinner.failed();
|
||||||
term::blank();
|
term::blank();
|
||||||
|
|
@ -155,7 +153,7 @@ pub fn run(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Update::Patch(id) => {
|
Update::Patch(id) => {
|
||||||
if let Some(patch) = patches.get(id)? {
|
if let Ok(patch) = patches.get_mut(id) {
|
||||||
Some((*id, patch))
|
Some((*id, patch))
|
||||||
} else {
|
} else {
|
||||||
anyhow::bail!("Patch `{}` not found", id);
|
anyhow::bail!("Patch `{}` not found", id);
|
||||||
|
|
@ -167,9 +165,7 @@ pub fn run(
|
||||||
if term::confirm("Update?") {
|
if term::confirm("Update?") {
|
||||||
term::blank();
|
term::blank();
|
||||||
|
|
||||||
return update(
|
return update(patch, id, &base_oid, &head_oid, workdir, options, &signer);
|
||||||
patch, id, &base_oid, &head_oid, &patches, workdir, options, &signer,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
anyhow::bail!("Patch update aborted by user");
|
anyhow::bail!("Patch update aborted by user");
|
||||||
}
|
}
|
||||||
|
|
@ -183,7 +179,7 @@ pub fn run(
|
||||||
term::format::dim(target_peer.id),
|
term::format::dim(target_peer.id),
|
||||||
term::format::highlight(&project.default_branch.to_string()),
|
term::format::highlight(&project.default_branch.to_string()),
|
||||||
term::format::secondary(&term::format::oid(*target_oid)),
|
term::format::secondary(&term::format::oid(*target_oid)),
|
||||||
term::format::dim(term::format::node(cobs.public_key())),
|
term::format::dim(term::format::node(patches.public_key())),
|
||||||
term::format::highlight(&head_branch.to_string()),
|
term::format::highlight(&head_branch.to_string()),
|
||||||
term::format::secondary(&term::format::oid(head_oid)),
|
term::format::secondary(&term::format::oid(head_oid)),
|
||||||
);
|
);
|
||||||
|
|
@ -237,7 +233,7 @@ pub fn run(
|
||||||
anyhow::bail!("patch proposal aborted by user");
|
anyhow::bail!("patch proposal aborted by user");
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = patches.create(
|
let patch = patches.create(
|
||||||
title,
|
title,
|
||||||
&description,
|
&description,
|
||||||
MergeTarget::default(),
|
MergeTarget::default(),
|
||||||
|
|
@ -248,7 +244,7 @@ pub fn run(
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
term::blank();
|
term::blank();
|
||||||
term::success!("Patch {} created 🌱", term::format::highlight(id));
|
term::success!("Patch {} created 🌱", term::format::highlight(patch.id));
|
||||||
|
|
||||||
if options.sync {
|
if options.sync {
|
||||||
// TODO
|
// TODO
|
||||||
|
|
@ -259,16 +255,17 @@ pub fn run(
|
||||||
|
|
||||||
/// Update an existing patch with a new revision.
|
/// Update an existing patch with a new revision.
|
||||||
fn update<G: Signer>(
|
fn update<G: Signer>(
|
||||||
patch: Patch,
|
mut patch: PatchMut,
|
||||||
patch_id: PatchId,
|
patch_id: PatchId,
|
||||||
base: &Oid,
|
base: &Oid,
|
||||||
head: &Oid,
|
head: &Oid,
|
||||||
patches: &PatchStore,
|
|
||||||
workdir: &git::raw::Repository,
|
workdir: &git::raw::Repository,
|
||||||
options: Options,
|
options: Options,
|
||||||
signer: &G,
|
signer: &G,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let (current, current_revision) = patch.latest();
|
// TODO(cloudhead): Handle error.
|
||||||
|
let (_, current_revision) = patch.latest().unwrap();
|
||||||
|
let current_version = patch.version();
|
||||||
|
|
||||||
if *current_revision.oid == *head {
|
if *current_revision.oid == *head {
|
||||||
term::info!("Nothing to do, patch is already up to date.");
|
term::info!("Nothing to do, patch is already up to date.");
|
||||||
|
|
@ -278,9 +275,9 @@ fn update<G: Signer>(
|
||||||
term::info!(
|
term::info!(
|
||||||
"{} {} ({}) -> {} ({})",
|
"{} {} ({}) -> {} ({})",
|
||||||
term::format::tertiary(term::format::cob(&patch_id)),
|
term::format::tertiary(term::format::cob(&patch_id)),
|
||||||
term::format::dim(format!("R{}", current)),
|
term::format::dim(format!("R{}", current_version)),
|
||||||
term::format::secondary(term::format::oid(current_revision.oid)),
|
term::format::secondary(term::format::oid(current_revision.oid)),
|
||||||
term::format::dim(format!("R{}", current + 1)),
|
term::format::dim(format!("R{}", current_version + 1)),
|
||||||
term::format::secondary(term::format::oid(*head)),
|
term::format::secondary(term::format::oid(*head)),
|
||||||
);
|
);
|
||||||
let message = options.message.get(REVISION_MSG);
|
let message = options.message.get(REVISION_MSG);
|
||||||
|
|
@ -292,9 +289,7 @@ fn update<G: Signer>(
|
||||||
if !term::confirm("Continue?") {
|
if !term::confirm("Continue?") {
|
||||||
anyhow::bail!("patch update aborted by user");
|
anyhow::bail!("patch update aborted by user");
|
||||||
}
|
}
|
||||||
|
patch.update(message, *base, *head, signer)?;
|
||||||
let new = patches.update(&patch_id, message, *base, *head, signer)?;
|
|
||||||
assert_eq!(new, current + 1);
|
|
||||||
|
|
||||||
term::blank();
|
term::blank();
|
||||||
term::success!("Patch {} updated 🌱", term::format::highlight(patch_id));
|
term::success!("Patch {} updated 🌱", term::format::highlight(patch_id));
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use radicle::cob::automerge;
|
use anyhow::anyhow;
|
||||||
use radicle::cob::patch::{Patch, PatchId, Verdict};
|
|
||||||
|
use radicle::cob::patch::{Patch, PatchId, Patches, Verdict};
|
||||||
use radicle::git;
|
use radicle::git;
|
||||||
use radicle::prelude::*;
|
use radicle::prelude::*;
|
||||||
use radicle::profile::Profile;
|
use radicle::profile::Profile;
|
||||||
|
|
@ -22,8 +23,7 @@ pub fn run(
|
||||||
}
|
}
|
||||||
|
|
||||||
let me = *profile.id();
|
let me = *profile.id();
|
||||||
let cobs = automerge::Store::open(*profile.id(), storage)?;
|
let patches = Patches::open(*profile.id(), storage)?;
|
||||||
let patches = cobs.patches();
|
|
||||||
let proposed = patches.proposed()?;
|
let proposed = patches.proposed()?;
|
||||||
|
|
||||||
// Patches the user authored.
|
// Patches the user authored.
|
||||||
|
|
@ -31,8 +31,8 @@ pub fn run(
|
||||||
// Patches other users authored.
|
// Patches other users authored.
|
||||||
let mut other = Vec::new();
|
let mut other = Vec::new();
|
||||||
|
|
||||||
for (id, patch) in proposed {
|
for (id, patch, _) in proposed {
|
||||||
if *patch.author.id() == me {
|
if *patch.author().id() == me {
|
||||||
own.push((id, patch));
|
own.push((id, patch));
|
||||||
} else {
|
} else {
|
||||||
other.push((id, patch));
|
other.push((id, patch));
|
||||||
|
|
@ -61,7 +61,7 @@ pub fn run(
|
||||||
for (id, patch) in &mut other {
|
for (id, patch) in &mut other {
|
||||||
term::blank();
|
term::blank();
|
||||||
|
|
||||||
print(cobs.public_key(), id, patch, &workdir, storage)?;
|
print(patches.public_key(), id, patch, &workdir, storage)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
term::blank();
|
term::blank();
|
||||||
|
|
@ -77,25 +77,29 @@ fn print(
|
||||||
workdir: &Option<git::raw::Repository>,
|
workdir: &Option<git::raw::Repository>,
|
||||||
storage: &Repository,
|
storage: &Repository,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let target_head = common::patch_merge_target_oid(patch.target, storage)?;
|
let target_head = common::patch_merge_target_oid(patch.target(), storage)?;
|
||||||
|
|
||||||
let you = patch.author.id() == whoami;
|
let you = patch.author().id() == whoami;
|
||||||
let prefix = "└─ ";
|
let prefix = "└─ ";
|
||||||
let mut author_info = vec![format!(
|
let mut author_info = vec![format!(
|
||||||
"{}* opened by {}",
|
"{}* opened by {}",
|
||||||
prefix,
|
prefix,
|
||||||
term::format::tertiary(patch.author.id()),
|
term::format::tertiary(patch.author().id()),
|
||||||
)];
|
)];
|
||||||
|
|
||||||
if you {
|
if you {
|
||||||
author_info.push(term::format::secondary("(you)"));
|
author_info.push(term::format::secondary("(you)"));
|
||||||
}
|
}
|
||||||
author_info.push(term::format::dim(term::format::timestamp(&patch.timestamp)));
|
author_info.push(term::format::dim(term::format::timestamp(
|
||||||
|
&patch.timestamp(),
|
||||||
|
)));
|
||||||
|
|
||||||
let revision = patch.revisions.last();
|
let (_, revision) = patch
|
||||||
|
.latest()
|
||||||
|
.ok_or_else(|| anyhow!("patch is malformed: no revisions found"))?;
|
||||||
term::info!(
|
term::info!(
|
||||||
"{} {} {} {} {}",
|
"{} {} {} {} {}",
|
||||||
term::format::bold(&patch.title),
|
term::format::bold(patch.title()),
|
||||||
term::format::highlight(term::format::cob(patch_id)),
|
term::format::highlight(term::format::cob(patch_id)),
|
||||||
term::format::dim(format!("R{}", patch.version())),
|
term::format::dim(format!("R{}", patch.version())),
|
||||||
common::pretty_commit_version(&revision.oid, workdir)?,
|
common::pretty_commit_version(&revision.oid, workdir)?,
|
||||||
|
|
@ -104,7 +108,7 @@ fn print(
|
||||||
term::info!("{}", author_info.join(" "));
|
term::info!("{}", author_info.join(" "));
|
||||||
|
|
||||||
let mut timeline = Vec::new();
|
let mut timeline = Vec::new();
|
||||||
for merge in &revision.merges {
|
for merge in revision.merges.iter() {
|
||||||
let peer = storage.remote(&merge.node)?;
|
let peer = storage.remote(&merge.node)?;
|
||||||
let mut badges = Vec::new();
|
let mut badges = Vec::new();
|
||||||
|
|
||||||
|
|
@ -126,13 +130,13 @@ fn print(
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
for review in revision.reviews.values() {
|
for (reviewer, review) in revision.reviews.iter() {
|
||||||
let verdict = match review.verdict {
|
let verdict = match review.verdict() {
|
||||||
Some(Verdict::Accept) => term::format::positive(term::format::dim("✓ accepted")),
|
Some(Verdict::Accept) => term::format::positive(term::format::dim("✓ accepted")),
|
||||||
Some(Verdict::Reject) => term::format::negative(term::format::dim("✗ rejected")),
|
Some(Verdict::Reject) => term::format::negative(term::format::dim("✗ rejected")),
|
||||||
None => term::format::negative(term::format::dim("⋄ reviewed")),
|
None => term::format::negative(term::format::dim("⋄ reviewed")),
|
||||||
};
|
};
|
||||||
let peer = storage.remote(review.author.id())?;
|
let peer = storage.remote(reviewer)?;
|
||||||
let mut badges = Vec::new();
|
let mut badges = Vec::new();
|
||||||
|
|
||||||
if peer.delegate {
|
if peer.delegate {
|
||||||
|
|
@ -143,12 +147,12 @@ fn print(
|
||||||
}
|
}
|
||||||
|
|
||||||
timeline.push((
|
timeline.push((
|
||||||
review.timestamp,
|
review.timestamp(),
|
||||||
format!(
|
format!(
|
||||||
"{}{} by {} {}",
|
"{}{} by {} {}",
|
||||||
" ".repeat(term::text_width(prefix)),
|
" ".repeat(term::text_width(prefix)),
|
||||||
verdict,
|
verdict,
|
||||||
term::format::tertiary(review.author.id()),
|
term::format::tertiary(reviewer),
|
||||||
badges.join(" "),
|
badges.join(" "),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@ use std::str::FromStr;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
use radicle::cob;
|
use radicle::cob::patch::{PatchId, Patches, RevisionIx, Verdict};
|
||||||
use radicle::cob::automerge::patch::{PatchId, RevisionIx, Verdict};
|
|
||||||
use radicle::prelude::*;
|
use radicle::prelude::*;
|
||||||
use radicle::rad;
|
use radicle::rad;
|
||||||
|
|
||||||
|
|
@ -140,18 +139,17 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
let _project = repository
|
let _project = repository
|
||||||
.project_of(profile.id())
|
.project_of(profile.id())
|
||||||
.context(format!("couldn't load project {} from local state", id))?;
|
.context(format!("couldn't load project {} from local state", id))?;
|
||||||
let cobs = cob::automerge::Store::open(*profile.id(), &repository)?;
|
let mut patches = Patches::open(*profile.id(), &repository)?;
|
||||||
let patches = cobs.patches();
|
|
||||||
|
|
||||||
let patch_id = options.id;
|
let patch_id = options.id;
|
||||||
let patch = patches
|
let mut patch = patches
|
||||||
.get(&patch_id)?
|
.get_mut(&patch_id)
|
||||||
.context(format!("couldn't find patch {} locally", patch_id))?;
|
.context(format!("couldn't find patch {} locally", patch_id))?;
|
||||||
let patch_id_pretty = term::format::tertiary(term::format::cob(&patch_id));
|
let patch_id_pretty = term::format::tertiary(term::format::cob(&patch_id));
|
||||||
let revision_ix = options.revision.unwrap_or_else(|| patch.version());
|
let revision_ix = options.revision.unwrap_or_else(|| patch.version());
|
||||||
let _revision = patch
|
let (revision_id, _) = patch
|
||||||
.revisions
|
.revisions()
|
||||||
.get(revision_ix)
|
.nth(revision_ix)
|
||||||
.ok_or_else(|| anyhow!("revision R{} does not exist", revision_ix))?;
|
.ok_or_else(|| anyhow!("revision R{} does not exist", revision_ix))?;
|
||||||
let message = options.message.get(REVIEW_HELP_MSG);
|
let message = options.message.get(REVIEW_HELP_MSG);
|
||||||
|
|
||||||
|
|
@ -165,16 +163,15 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
verdict_pretty,
|
verdict_pretty,
|
||||||
patch_id_pretty,
|
patch_id_pretty,
|
||||||
term::format::dim(format!("R{}", revision_ix)),
|
term::format::dim(format!("R{}", revision_ix)),
|
||||||
term::format::tertiary(patch.author.id())
|
term::format::tertiary(patch.author().id())
|
||||||
)) {
|
)) {
|
||||||
anyhow::bail!("Patch review aborted");
|
anyhow::bail!("Patch review aborted");
|
||||||
}
|
}
|
||||||
|
|
||||||
patches.review(
|
patch.review(
|
||||||
&patch_id,
|
*revision_id,
|
||||||
revision_ix,
|
|
||||||
options.verdict,
|
options.verdict,
|
||||||
message,
|
Some(message),
|
||||||
vec![],
|
vec![],
|
||||||
&signer,
|
&signer,
|
||||||
)?;
|
)?;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ test = ["quickcheck", "radicle-crypto/test"]
|
||||||
sql = ["sqlite"]
|
sql = ["sqlite"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
automerge = { version = "0.1" }
|
|
||||||
base64 = { version= "0.13" }
|
base64 = { version= "0.13" }
|
||||||
byteorder = { version = "1.4" }
|
byteorder = { version = "1.4" }
|
||||||
crossbeam-channel = { version = "0.5.6" }
|
crossbeam-channel = { version = "0.5.6" }
|
||||||
|
|
@ -31,7 +30,6 @@ sqlite = { version = "0.28.1", optional = true }
|
||||||
nonempty = { version = "0.8.0", features = ["serialize"] }
|
nonempty = { version = "0.8.0", features = ["serialize"] }
|
||||||
tempfile = { version = "3.3.0" }
|
tempfile = { version = "3.3.0" }
|
||||||
thiserror = { version = "1" }
|
thiserror = { version = "1" }
|
||||||
uuid = { version = "1.1.2", features = ["v4", "fast-rng", "serde"] }
|
|
||||||
zeroize = { version = "1.5.7" }
|
zeroize = { version = "1.5.7" }
|
||||||
|
|
||||||
[dependencies.git2]
|
[dependencies.git2]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
pub mod automerge;
|
|
||||||
pub mod common;
|
pub mod common;
|
||||||
pub mod issue;
|
pub mod issue;
|
||||||
pub mod patch;
|
pub mod patch;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
pub mod doc;
|
|
||||||
pub mod label;
|
|
||||||
pub mod patch;
|
|
||||||
pub mod shared;
|
|
||||||
pub mod store;
|
|
||||||
pub mod transaction;
|
|
||||||
pub mod value;
|
|
||||||
|
|
||||||
pub use store::Store;
|
|
||||||
|
|
@ -1,243 +0,0 @@
|
||||||
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::automerge::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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,835 +0,0 @@
|
||||||
#![allow(clippy::too_many_arguments)]
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::convert::TryFrom;
|
|
||||||
use std::ops::{ControlFlow, Deref};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use automerge::transaction::Transactable;
|
|
||||||
use automerge::{Automerge, AutomergeError, ObjType, ScalarValue, Value};
|
|
||||||
use nonempty::NonEmpty;
|
|
||||||
|
|
||||||
use crate::cob::automerge::doc::{Document, DocumentError};
|
|
||||||
use crate::cob::automerge::shared;
|
|
||||||
use crate::cob::automerge::shared::*;
|
|
||||||
use crate::cob::automerge::store::{Error, Store};
|
|
||||||
use crate::cob::automerge::transaction::{Transaction, TransactionError};
|
|
||||||
use crate::cob::automerge::value::{FromValue, ValueError};
|
|
||||||
use crate::cob::common::*;
|
|
||||||
use crate::cob::{Contents, History, ObjectId, Timestamp, TypeName};
|
|
||||||
use crate::git;
|
|
||||||
use crate::prelude::*;
|
|
||||||
|
|
||||||
// Re-export generic patch.
|
|
||||||
pub use crate::cob::patch::*;
|
|
||||||
|
|
||||||
impl TryFrom<Document<'_>> for Patch {
|
|
||||||
type Error = DocumentError;
|
|
||||||
|
|
||||||
fn try_from(doc: Document) -> Result<Self, Self::Error> {
|
|
||||||
let obj_id = doc.get_id(automerge::ObjId::Root, "patch")?;
|
|
||||||
let title = doc.get(&obj_id, "title")?;
|
|
||||||
let author = doc.get(&obj_id, "author")?;
|
|
||||||
let state = doc.get(&obj_id, "state")?;
|
|
||||||
let target = doc.get(&obj_id, "target")?;
|
|
||||||
let timestamp = doc.get(&obj_id, "timestamp")?;
|
|
||||||
let revisions = doc.list(&obj_id, "revisions", lookup::revision)?;
|
|
||||||
let labels: HashSet<Tag> = doc.keys(&obj_id, "labels")?;
|
|
||||||
let revisions =
|
|
||||||
NonEmpty::from_vec(revisions).ok_or(DocumentError::EmptyList("revisions"))?;
|
|
||||||
let author: Author = Author::new(author);
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
author,
|
|
||||||
title,
|
|
||||||
state,
|
|
||||||
target,
|
|
||||||
labels,
|
|
||||||
revisions,
|
|
||||||
timestamp,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PatchStore<'a> {
|
|
||||||
store: Store<'a, Patch>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Deref for PatchStore<'a> {
|
|
||||||
type Target = Store<'a, Patch>;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.store
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> PatchStore<'a> {
|
|
||||||
/// Create a new patch store.
|
|
||||||
pub fn new(store: Store<'a, Patch>) -> Self {
|
|
||||||
Self { store }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a patch by id.
|
|
||||||
pub fn get(&self, id: &ObjectId) -> Result<Option<Patch>, Error> {
|
|
||||||
self.store.get(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a patch.
|
|
||||||
pub fn create<G: Signer>(
|
|
||||||
&self,
|
|
||||||
title: &str,
|
|
||||||
description: &str,
|
|
||||||
target: MergeTarget,
|
|
||||||
base: impl Into<git::Oid>,
|
|
||||||
oid: impl Into<git::Oid>,
|
|
||||||
labels: &[Tag],
|
|
||||||
signer: &G,
|
|
||||||
) -> Result<PatchId, Error> {
|
|
||||||
let author = self.author();
|
|
||||||
let timestamp = Timestamp::now();
|
|
||||||
let revision = Revision::new(
|
|
||||||
author.clone(),
|
|
||||||
base.into(),
|
|
||||||
oid.into(),
|
|
||||||
description.to_owned(),
|
|
||||||
timestamp,
|
|
||||||
);
|
|
||||||
let contents = events::create(&author, title, &revision, target, timestamp, labels)?;
|
|
||||||
let cob = self.store.create("Create patch", contents, signer)?;
|
|
||||||
|
|
||||||
Ok(*cob.id())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Comment on a patch.
|
|
||||||
pub fn comment<G: Signer>(
|
|
||||||
&self,
|
|
||||||
patch_id: &PatchId,
|
|
||||||
revision_ix: RevisionIx,
|
|
||||||
body: &str,
|
|
||||||
signer: &G,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let author = self.author();
|
|
||||||
let mut patch = self.store.get_raw(patch_id)?;
|
|
||||||
let timestamp = Timestamp::now();
|
|
||||||
let changes = events::comment(&mut patch, revision_ix, &author, body, timestamp)?;
|
|
||||||
|
|
||||||
self.store
|
|
||||||
.update(*patch_id, "Add comment", changes, signer)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a patch with new code. Creates a new revision.
|
|
||||||
pub fn update<G: Signer>(
|
|
||||||
&self,
|
|
||||||
patch_id: &PatchId,
|
|
||||||
comment: impl ToString,
|
|
||||||
base: impl Into<git::Oid>,
|
|
||||||
oid: impl Into<git::Oid>,
|
|
||||||
signer: &G,
|
|
||||||
) -> Result<RevisionIx, Error> {
|
|
||||||
let author = self.author();
|
|
||||||
let timestamp = Timestamp::now();
|
|
||||||
let revision = Revision::new(
|
|
||||||
author,
|
|
||||||
base.into(),
|
|
||||||
oid.into(),
|
|
||||||
comment.to_string(),
|
|
||||||
timestamp,
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut patch = self.get_raw(patch_id)?;
|
|
||||||
let (revision_ix, changes) = events::update(&mut patch, revision)?;
|
|
||||||
|
|
||||||
self.store
|
|
||||||
.update(*patch_id, "Update patch", changes, signer)?;
|
|
||||||
|
|
||||||
Ok(revision_ix)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reply to a patch comment.
|
|
||||||
pub fn reply<G: Signer>(
|
|
||||||
&self,
|
|
||||||
patch_id: &PatchId,
|
|
||||||
revision_ix: RevisionIx,
|
|
||||||
comment_id: CommentId,
|
|
||||||
reply: &str,
|
|
||||||
signer: &G,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let author = self.author();
|
|
||||||
let mut patch = self.get_raw(patch_id)?;
|
|
||||||
let changes = events::reply(
|
|
||||||
&mut patch,
|
|
||||||
revision_ix,
|
|
||||||
comment_id,
|
|
||||||
&author,
|
|
||||||
reply,
|
|
||||||
Timestamp::now(),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
self.store.update(*patch_id, "Reply", changes, signer)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Review a patch revision.
|
|
||||||
pub fn review<G: Signer>(
|
|
||||||
&self,
|
|
||||||
patch_id: &PatchId,
|
|
||||||
revision_ix: RevisionIx,
|
|
||||||
verdict: Option<Verdict>,
|
|
||||||
comment: impl Into<String>,
|
|
||||||
inline: Vec<CodeComment>,
|
|
||||||
signer: &G,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let timestamp = Timestamp::now();
|
|
||||||
let review = Review::new(self.author(), verdict, comment, inline, timestamp);
|
|
||||||
|
|
||||||
let mut patch = self.get_raw(patch_id)?;
|
|
||||||
let (_, changes) = events::review(&mut patch, revision_ix, review)?;
|
|
||||||
|
|
||||||
self.store
|
|
||||||
.update(*patch_id, "Review patch", changes, signer)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge a patch revision.
|
|
||||||
pub fn merge<G: Signer>(
|
|
||||||
&self,
|
|
||||||
patch_id: &PatchId,
|
|
||||||
revision_ix: RevisionIx,
|
|
||||||
commit: git::Oid,
|
|
||||||
signer: &G,
|
|
||||||
) -> Result<Merge, Error> {
|
|
||||||
let timestamp = Timestamp::now();
|
|
||||||
let merge = Merge {
|
|
||||||
node: *signer.public_key(),
|
|
||||||
commit,
|
|
||||||
timestamp,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut patch = self.get_raw(patch_id)?;
|
|
||||||
let changes = events::merge(&mut patch, revision_ix, &merge)?;
|
|
||||||
|
|
||||||
self.store
|
|
||||||
.update(*patch_id, "Merge revision", changes, signer)?;
|
|
||||||
|
|
||||||
Ok(merge)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the patch count.
|
|
||||||
pub fn count(&self) -> Result<usize, Error> {
|
|
||||||
let cobs = self.store.list()?;
|
|
||||||
|
|
||||||
Ok(cobs.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all patches for this project.
|
|
||||||
pub fn all(&self) -> Result<Vec<(PatchId, Patch)>, Error> {
|
|
||||||
let mut patches = self.store.list()?;
|
|
||||||
patches.sort_by_key(|(_, p)| p.timestamp);
|
|
||||||
|
|
||||||
Ok(patches)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get proposed patches.
|
|
||||||
pub fn proposed(&self) -> Result<impl Iterator<Item = (PatchId, Patch)>, Error> {
|
|
||||||
let all = self.all()?;
|
|
||||||
|
|
||||||
Ok(all.into_iter().filter(|(_, p)| p.is_proposed()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get patches proposed by the given key.
|
|
||||||
pub fn proposed_by<'b>(
|
|
||||||
&'b self,
|
|
||||||
who: &'b PublicKey,
|
|
||||||
) -> Result<impl Iterator<Item = (PatchId, Patch)> + '_, Error> {
|
|
||||||
Ok(self.proposed()?.filter(move |(_, p)| p.author.id() == who))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<State> for ScalarValue {
|
|
||||||
fn from(state: State) -> Self {
|
|
||||||
match state {
|
|
||||||
State::Proposed => ScalarValue::from("proposed"),
|
|
||||||
State::Draft => ScalarValue::from("draft"),
|
|
||||||
State::Archived => ScalarValue::from("archived"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> FromValue<'a> for State {
|
|
||||||
fn from_value(value: Value<'a>) -> Result<Self, ValueError> {
|
|
||||||
let state = value.to_str().ok_or(ValueError::InvalidType)?;
|
|
||||||
|
|
||||||
match state {
|
|
||||||
"proposed" => Ok(Self::Proposed),
|
|
||||||
"draft" => Ok(Self::Draft),
|
|
||||||
"archived" => Ok(Self::Archived),
|
|
||||||
_ => Err(ValueError::InvalidValue(value.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Revision {
|
|
||||||
/// Put this object into an automerge document.
|
|
||||||
fn put<'a>(
|
|
||||||
&self,
|
|
||||||
mut tx: impl AsMut<automerge::transaction::Transaction<'a>>,
|
|
||||||
id: &automerge::ObjId,
|
|
||||||
) -> Result<(), AutomergeError> {
|
|
||||||
assert!(
|
|
||||||
self.merges.is_empty(),
|
|
||||||
"Cannot put revision with non-empty merges"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.reviews.is_empty(),
|
|
||||||
"Cannot put revision with non-empty reviews"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.discussion.is_empty(),
|
|
||||||
"Cannot put revision with non-empty discussion"
|
|
||||||
);
|
|
||||||
let tx = tx.as_mut();
|
|
||||||
|
|
||||||
tx.put(id, "id", self.id.to_string())?;
|
|
||||||
tx.put(id, "oid", self.oid.to_string())?;
|
|
||||||
tx.put(id, "base", self.base.to_string())?;
|
|
||||||
|
|
||||||
self.comment.put(tx, id)?;
|
|
||||||
|
|
||||||
tx.put_object(id, "discussion", ObjType::List)?;
|
|
||||||
tx.put_object(id, "reviews", ObjType::Map)?;
|
|
||||||
tx.put_object(id, "merges", ObjType::List)?;
|
|
||||||
tx.put(id, "timestamp", self.timestamp)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Verdict> for ScalarValue {
|
|
||||||
fn from(verdict: Verdict) -> Self {
|
|
||||||
#[allow(clippy::unwrap_used)]
|
|
||||||
let s = serde_json::to_string(&verdict).unwrap(); // Cannot fail.
|
|
||||||
ScalarValue::from(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> FromValue<'a> for Verdict {
|
|
||||||
fn from_value(value: Value) -> Result<Self, ValueError> {
|
|
||||||
let verdict = value.to_str().ok_or(ValueError::InvalidType)?;
|
|
||||||
serde_json::from_str(verdict).map_err(|e| ValueError::Other(Arc::new(e)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Review {
|
|
||||||
/// Put this object into an automerge document.
|
|
||||||
fn put<'a>(
|
|
||||||
&self,
|
|
||||||
mut tx: impl AsMut<automerge::transaction::Transaction<'a>>,
|
|
||||||
id: &automerge::ObjId,
|
|
||||||
) -> Result<(), AutomergeError> {
|
|
||||||
assert!(
|
|
||||||
self.inline.is_empty(),
|
|
||||||
"Cannot put review with non-empty inline comments"
|
|
||||||
);
|
|
||||||
let tx = tx.as_mut();
|
|
||||||
|
|
||||||
tx.put(id, "author", &self.author)?;
|
|
||||||
tx.put(
|
|
||||||
id,
|
|
||||||
"verdict",
|
|
||||||
if let Some(v) = self.verdict {
|
|
||||||
v.into()
|
|
||||||
} else {
|
|
||||||
ScalarValue::Null
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
self.comment.put(tx, id)?;
|
|
||||||
|
|
||||||
tx.put_object(id, "inline", ObjType::List)?;
|
|
||||||
tx.put(id, "timestamp", self.timestamp)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromHistory for Patch {
|
|
||||||
fn type_name() -> &'static TypeName {
|
|
||||||
&TYPENAME
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a patch from an automerge history.
|
|
||||||
fn from_history(history: &History) -> Result<Self, Error> {
|
|
||||||
let doc = history.traverse(Automerge::new(), |mut doc, entry| {
|
|
||||||
let bytes = entry.contents();
|
|
||||||
match automerge::Change::from_bytes(bytes.clone()) {
|
|
||||||
Ok(change) => {
|
|
||||||
doc.apply_changes([change]).ok();
|
|
||||||
}
|
|
||||||
Err(_err) => {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ControlFlow::Continue(doc)
|
|
||||||
});
|
|
||||||
let patch = Patch::try_from(Document::new(&doc))?;
|
|
||||||
|
|
||||||
Ok(patch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mod lookup {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
pub fn revision(
|
|
||||||
doc: Document,
|
|
||||||
revision_id: &automerge::ObjId,
|
|
||||||
) -> Result<Revision, DocumentError> {
|
|
||||||
let comment_id = doc.get_id(revision_id, "comment")?;
|
|
||||||
let reviews_id = doc.get_id(revision_id, "reviews")?;
|
|
||||||
let id = doc.get(revision_id, "id")?;
|
|
||||||
let base = doc.get(revision_id, "base")?;
|
|
||||||
let oid = doc.get(revision_id, "oid")?;
|
|
||||||
let timestamp = doc.get(revision_id, "timestamp")?;
|
|
||||||
let merges: Vec<Merge> = doc.list(revision_id, "merges", self::merge)?;
|
|
||||||
|
|
||||||
// Discussion.
|
|
||||||
let comment = shared::lookup::comment(doc, &comment_id)?;
|
|
||||||
let discussion: Discussion = doc.list(revision_id, "discussion", shared::lookup::thread)?;
|
|
||||||
|
|
||||||
// Reviews.
|
|
||||||
let mut reviews: HashMap<NodeId, Review> = HashMap::new();
|
|
||||||
for key in (*doc).keys(&reviews_id) {
|
|
||||||
let review_id = doc.get_id(&reviews_id, key)?;
|
|
||||||
let review = self::review(doc, &review_id)?;
|
|
||||||
|
|
||||||
reviews.insert(*review.author.id(), review);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Revision {
|
|
||||||
id,
|
|
||||||
base,
|
|
||||||
oid,
|
|
||||||
comment,
|
|
||||||
discussion,
|
|
||||||
reviews,
|
|
||||||
merges,
|
|
||||||
changeset: (),
|
|
||||||
timestamp,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn merge(doc: Document, obj_id: &automerge::ObjId) -> Result<Merge, DocumentError> {
|
|
||||||
let node = doc.get(obj_id, "peer")?;
|
|
||||||
let commit = doc.get(obj_id, "commit")?;
|
|
||||||
let timestamp = doc.get(obj_id, "timestamp")?;
|
|
||||||
|
|
||||||
Ok(Merge {
|
|
||||||
node,
|
|
||||||
commit,
|
|
||||||
timestamp,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn review(doc: Document, obj_id: &automerge::ObjId) -> Result<Review, DocumentError> {
|
|
||||||
let author = doc.get(obj_id, "author")?;
|
|
||||||
let verdict = doc.get(obj_id, "verdict")?;
|
|
||||||
let timestamp = doc.get(obj_id, "timestamp")?;
|
|
||||||
let comment = doc.lookup(obj_id, "comment", shared::lookup::thread)?;
|
|
||||||
let inline = vec![];
|
|
||||||
|
|
||||||
Ok(Review {
|
|
||||||
author: Author::new(author),
|
|
||||||
comment,
|
|
||||||
verdict,
|
|
||||||
inline,
|
|
||||||
timestamp,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Patch events.
|
|
||||||
mod events {
|
|
||||||
use super::*;
|
|
||||||
use automerge::{
|
|
||||||
transaction::{CommitOptions, Transactable},
|
|
||||||
ObjId,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn create(
|
|
||||||
author: &Author,
|
|
||||||
title: &str,
|
|
||||||
revision: &Revision,
|
|
||||||
target: MergeTarget,
|
|
||||||
timestamp: Timestamp,
|
|
||||||
labels: &[Tag],
|
|
||||||
) -> Result<Contents, TransactionError> {
|
|
||||||
let title = title.trim();
|
|
||||||
if title.is_empty() {
|
|
||||||
return Err(TransactionError::InvalidValue("title"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut doc = Automerge::new();
|
|
||||||
let _patch = doc
|
|
||||||
.transact_with::<_, _, TransactionError, _, ()>(
|
|
||||||
|_| CommitOptions::default().with_message("Create patch".to_owned()),
|
|
||||||
|tx| {
|
|
||||||
let mut tx = Transaction::new(tx);
|
|
||||||
let patch_id = tx.put_object(ObjId::Root, "patch", ObjType::Map)?;
|
|
||||||
|
|
||||||
tx.put(&patch_id, "title", title)?;
|
|
||||||
tx.put(&patch_id, "author", author)?;
|
|
||||||
tx.put(&patch_id, "state", State::Proposed)?;
|
|
||||||
tx.put(&patch_id, "target", target)?;
|
|
||||||
tx.put(&patch_id, "timestamp", timestamp)?;
|
|
||||||
|
|
||||||
let labels_id = tx.put_object(&patch_id, "labels", ObjType::Map)?;
|
|
||||||
for label in labels {
|
|
||||||
tx.put(&labels_id, label.name().trim(), true)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let revisions_id = tx.put_object(&patch_id, "revisions", ObjType::List)?;
|
|
||||||
let revision_id = tx.insert_object(&revisions_id, 0, ObjType::Map)?;
|
|
||||||
|
|
||||||
revision.put(tx, &revision_id)?;
|
|
||||||
|
|
||||||
Ok(patch_id)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|failure| failure.error)?
|
|
||||||
.result;
|
|
||||||
|
|
||||||
Ok(doc.save_incremental())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn comment(
|
|
||||||
patch: &mut Automerge,
|
|
||||||
revision_ix: RevisionIx,
|
|
||||||
author: &Author,
|
|
||||||
body: &str,
|
|
||||||
timestamp: Timestamp,
|
|
||||||
) -> Result<Contents, TransactionError> {
|
|
||||||
let _comment = patch
|
|
||||||
.transact_with::<_, _, TransactionError, _, ()>(
|
|
||||||
|_| CommitOptions::default().with_message("Add comment".to_owned()),
|
|
||||||
|t| {
|
|
||||||
let mut tx = Transaction::new(t);
|
|
||||||
let (_, obj_id) = tx.get(ObjId::Root, "patch")?;
|
|
||||||
let (_, revisions_id) = tx.get(&obj_id, "revisions")?;
|
|
||||||
let (_, revision_id) = tx.get(&revisions_id, revision_ix)?;
|
|
||||||
let (_, discussion_id) = tx.get(&revision_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 = patch.get_last_local_change().unwrap().raw_bytes().to_vec();
|
|
||||||
|
|
||||||
Ok(change)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update(
|
|
||||||
patch: &mut Automerge,
|
|
||||||
revision: Revision,
|
|
||||||
) -> Result<(RevisionIx, Contents), TransactionError> {
|
|
||||||
let revision_ix = patch
|
|
||||||
.transact_with::<_, _, TransactionError, _, ()>(
|
|
||||||
|_| CommitOptions::default().with_message("Merge revision".to_owned()),
|
|
||||||
|tx| {
|
|
||||||
let mut tx = Transaction::new(tx);
|
|
||||||
let (_, obj_id) = tx.get(ObjId::Root, "patch")?;
|
|
||||||
let (_, revisions_id) = tx.get(&obj_id, "revisions")?;
|
|
||||||
|
|
||||||
let ix = tx.length(&revisions_id);
|
|
||||||
let revision_id = tx.insert_object(&revisions_id, ix, ObjType::Map)?;
|
|
||||||
|
|
||||||
revision.put(tx, &revision_id)?;
|
|
||||||
|
|
||||||
Ok(ix)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|failure| failure.error)?
|
|
||||||
.result;
|
|
||||||
|
|
||||||
#[allow(clippy::unwrap_used)]
|
|
||||||
let change = patch.get_last_local_change().unwrap().raw_bytes().to_vec();
|
|
||||||
|
|
||||||
Ok((revision_ix, change))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reply(
|
|
||||||
patch: &mut Automerge,
|
|
||||||
revision_ix: RevisionIx,
|
|
||||||
comment_id: CommentId,
|
|
||||||
author: &Author,
|
|
||||||
body: &str,
|
|
||||||
timestamp: Timestamp,
|
|
||||||
) -> Result<Contents, TransactionError> {
|
|
||||||
patch
|
|
||||||
.transact_with::<_, _, TransactionError, _, ()>(
|
|
||||||
|_| CommitOptions::default().with_message("Reply".to_owned()),
|
|
||||||
|tx| {
|
|
||||||
let mut tx = Transaction::new(tx);
|
|
||||||
let (_, obj_id) = tx.get(ObjId::Root, "patch")?;
|
|
||||||
let (_, revisions_id) = tx.get(&obj_id, "revisions")?;
|
|
||||||
let (_, revision_id) = tx.get(&revisions_id, revision_ix)?;
|
|
||||||
let (_, discussion_id) = tx.get(&revision_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 = patch.get_last_local_change().unwrap().raw_bytes().to_vec();
|
|
||||||
|
|
||||||
Ok(change)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn review(
|
|
||||||
patch: &mut Automerge,
|
|
||||||
revision_ix: RevisionIx,
|
|
||||||
review: Review,
|
|
||||||
) -> Result<((), Contents), TransactionError> {
|
|
||||||
patch
|
|
||||||
.transact_with::<_, _, TransactionError, _, ()>(
|
|
||||||
|_| CommitOptions::default().with_message("Review patch".to_owned()),
|
|
||||||
|tx| {
|
|
||||||
let mut tx = Transaction::new(tx);
|
|
||||||
let (_, obj_id) = tx.get(ObjId::Root, "patch")?;
|
|
||||||
let (_, revisions_id) = tx.get(&obj_id, "revisions")?;
|
|
||||||
let (_, revision_id) = tx.get(&revisions_id, revision_ix)?;
|
|
||||||
let (_, reviews_id) = tx.get(&revision_id, "reviews")?;
|
|
||||||
|
|
||||||
let review_id =
|
|
||||||
tx.put_object(&reviews_id, review.author.id.to_human(), ObjType::Map)?;
|
|
||||||
|
|
||||||
review.put(tx, &review_id)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|failure| failure.error)?;
|
|
||||||
|
|
||||||
#[allow(clippy::unwrap_used)]
|
|
||||||
let change = patch.get_last_local_change().unwrap().raw_bytes().to_vec();
|
|
||||||
|
|
||||||
Ok(((), change))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn merge(
|
|
||||||
patch: &mut Automerge,
|
|
||||||
revision_ix: RevisionIx,
|
|
||||||
merge: &Merge,
|
|
||||||
) -> Result<Contents, TransactionError> {
|
|
||||||
patch
|
|
||||||
.transact_with::<_, _, TransactionError, _, ()>(
|
|
||||||
|_| CommitOptions::default().with_message("Merge revision".to_owned()),
|
|
||||||
|tx| {
|
|
||||||
let mut tx = Transaction::new(tx);
|
|
||||||
let (_, obj_id) = tx.get(ObjId::Root, "patch")?;
|
|
||||||
let (_, revisions_id) = tx.get(&obj_id, "revisions")?;
|
|
||||||
let (_, revision_id) = tx.get(&revisions_id, revision_ix)?;
|
|
||||||
let (_, merges_id) = tx.get(&revision_id, "merges")?;
|
|
||||||
|
|
||||||
let length = tx.length(&merges_id);
|
|
||||||
let merge_id = tx.insert_object(&merges_id, length, ObjType::Map)?;
|
|
||||||
|
|
||||||
tx.put(&merge_id, "peer", merge.node.to_string())?;
|
|
||||||
tx.put(&merge_id, "commit", merge.commit.to_string())?;
|
|
||||||
tx.put(&merge_id, "timestamp", merge.timestamp)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|failure| failure.error)?;
|
|
||||||
|
|
||||||
#[allow(clippy::unwrap_used)]
|
|
||||||
let change = patch.get_last_local_change().unwrap().raw_bytes().to_vec();
|
|
||||||
|
|
||||||
Ok(change)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::test;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_patch_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 patches = store.patches();
|
|
||||||
let author = *signer.public_key();
|
|
||||||
let timestamp = Timestamp::now();
|
|
||||||
let target = MergeTarget::Delegates;
|
|
||||||
let oid = git::Oid::from(git2::Oid::zero());
|
|
||||||
let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
|
|
||||||
let patch_id = patches
|
|
||||||
.create(
|
|
||||||
"My first patch",
|
|
||||||
"Blah blah blah.",
|
|
||||||
target,
|
|
||||||
base,
|
|
||||||
oid,
|
|
||||||
&[],
|
|
||||||
&signer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let patch = patches.get(&patch_id).unwrap().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(&patch.title, "My first patch");
|
|
||||||
assert_eq!(patch.author.id(), &author);
|
|
||||||
assert_eq!(patch.state, State::Proposed);
|
|
||||||
assert!(patch.timestamp >= timestamp);
|
|
||||||
|
|
||||||
let revision = patch.revisions.head;
|
|
||||||
|
|
||||||
assert_eq!(revision.author(), &store.author());
|
|
||||||
assert_eq!(revision.comment.body, "Blah blah blah.");
|
|
||||||
assert_eq!(revision.discussion.len(), 0);
|
|
||||||
assert_eq!(revision.oid, oid);
|
|
||||||
assert_eq!(revision.base, base);
|
|
||||||
assert!(revision.reviews.is_empty());
|
|
||||||
assert!(revision.merges.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_patch_merge() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let (_, signer, project) = test::setup::context(&tmp);
|
|
||||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
|
||||||
let patches = store.patches();
|
|
||||||
let target = MergeTarget::Delegates;
|
|
||||||
let oid = git::Oid::from(git2::Oid::zero());
|
|
||||||
let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
|
|
||||||
let patch_id = patches
|
|
||||||
.create(
|
|
||||||
"My first patch",
|
|
||||||
"Blah blah blah.",
|
|
||||||
target,
|
|
||||||
base,
|
|
||||||
oid,
|
|
||||||
&[],
|
|
||||||
&signer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let _merge = patches.merge(&patch_id, 0, base, &signer).unwrap();
|
|
||||||
let patch = patches.get(&patch_id).unwrap().unwrap();
|
|
||||||
let merges = patch.revisions.head.merges;
|
|
||||||
|
|
||||||
assert_eq!(merges.len(), 1);
|
|
||||||
assert_eq!(merges[0].node, *signer.public_key());
|
|
||||||
assert_eq!(merges[0].commit, base);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_patch_review() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let (_, signer, project) = test::setup::context(&tmp);
|
|
||||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
|
||||||
let patches = store.patches();
|
|
||||||
let whoami = store.author();
|
|
||||||
let target = MergeTarget::Delegates;
|
|
||||||
let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
|
|
||||||
let rev_oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
|
|
||||||
let patch_id = patches
|
|
||||||
.create(
|
|
||||||
"My first patch",
|
|
||||||
"Blah blah blah.",
|
|
||||||
target,
|
|
||||||
base,
|
|
||||||
rev_oid,
|
|
||||||
&[],
|
|
||||||
&signer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
patches
|
|
||||||
.review(&patch_id, 0, Some(Verdict::Accept), "LGTM", vec![], &signer)
|
|
||||||
.unwrap();
|
|
||||||
let patch = patches.get(&patch_id).unwrap().unwrap();
|
|
||||||
let reviews = patch.revisions.head.reviews;
|
|
||||||
assert_eq!(reviews.len(), 1);
|
|
||||||
|
|
||||||
let review = reviews.get(whoami.id()).unwrap();
|
|
||||||
assert_eq!(review.author.id(), whoami.id());
|
|
||||||
assert_eq!(review.verdict, Some(Verdict::Accept));
|
|
||||||
assert_eq!(review.comment.body.as_str(), "LGTM");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_patch_update() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let (_, signer, project) = test::setup::context(&tmp);
|
|
||||||
let store = Store::open(*signer.public_key(), &project).unwrap();
|
|
||||||
let patches = store.patches();
|
|
||||||
let target = MergeTarget::Delegates;
|
|
||||||
let base = git::Oid::from_str("af08e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
|
|
||||||
let rev0_oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
|
|
||||||
let rev1_oid = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
|
|
||||||
let patch_id = patches
|
|
||||||
.create(
|
|
||||||
"My first patch",
|
|
||||||
"Blah blah blah.",
|
|
||||||
target,
|
|
||||||
base,
|
|
||||||
rev0_oid,
|
|
||||||
&[],
|
|
||||||
&signer,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let patch = patches.get(&patch_id).unwrap().unwrap();
|
|
||||||
assert_eq!(patch.description(), "Blah blah blah.");
|
|
||||||
assert_eq!(patch.version(), 0);
|
|
||||||
|
|
||||||
let revision_id = patches
|
|
||||||
.update(&patch_id, "I've made changes.", base, rev1_oid, &signer)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(revision_id, 1);
|
|
||||||
|
|
||||||
let patch = patches.get(&patch_id).unwrap().unwrap();
|
|
||||||
assert_eq!(patch.description(), "I've made changes.");
|
|
||||||
|
|
||||||
assert_eq!(patch.revisions.len(), 2);
|
|
||||||
assert_eq!(patch.version(), 1);
|
|
||||||
|
|
||||||
let (id, revision) = patch.latest();
|
|
||||||
|
|
||||||
assert_eq!(id, 1);
|
|
||||||
assert_eq!(revision.oid, rev1_oid);
|
|
||||||
assert_eq!(revision.description(), "I've made changes.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
#![allow(clippy::large_enum_variant)]
|
|
||||||
use std::borrow::Borrow;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use automerge::transaction::Transactable;
|
|
||||||
use automerge::{AutomergeError, ObjType, ScalarValue};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::cob::automerge::doc::{Document, DocumentError};
|
|
||||||
use crate::cob::automerge::store::Error;
|
|
||||||
use crate::cob::automerge::value::{FromValue, Value, ValueError};
|
|
||||||
use crate::cob::common::*;
|
|
||||||
use crate::cob::{History, TypeName};
|
|
||||||
|
|
||||||
/// 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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&Author> for ScalarValue {
|
|
||||||
fn from(author: &Author) -> Self {
|
|
||||||
ScalarValue::from(author.id.to_human())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Timestamp> for ScalarValue {
|
|
||||||
fn from(ts: Timestamp) -> Self {
|
|
||||||
ScalarValue::Timestamp(ts.as_secs() 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::new(*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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
//! 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::automerge::doc::DocumentError;
|
|
||||||
use crate::cob::automerge::shared::FromHistory;
|
|
||||||
use crate::cob::automerge::transaction::TransactionError;
|
|
||||||
use crate::cob::automerge::{label, patch};
|
|
||||||
use crate::cob::common::Author;
|
|
||||||
use crate::cob::CollaborativeObject;
|
|
||||||
use crate::cob::{Contents, Create, HistoryType, 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),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the public key associated with this store.
|
|
||||||
pub fn public_key(&self) -> &PublicKey {
|
|
||||||
&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,
|
|
||||||
history_type: HistoryType::Automerge,
|
|
||||||
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())),
|
|
||||||
history_type: HistoryType::Automerge,
|
|
||||||
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| {
|
|
||||||
doc.extend(entry.contents());
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
use std::ops::{Deref, DerefMut};
|
|
||||||
|
|
||||||
use automerge::transaction::Transactable;
|
|
||||||
use automerge::AutomergeError;
|
|
||||||
|
|
||||||
use crate::cob::automerge::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),
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
#![allow(clippy::large_enum_variant)]
|
|
||||||
use std::str::FromStr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
pub use automerge::{ScalarValue, Value};
|
|
||||||
|
|
||||||
use crate::cob::patch::*;
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> FromValue<'a> for MergeTarget {
|
|
||||||
fn from_value(value: Value<'a>) -> Result<Self, ValueError> {
|
|
||||||
let state = value.to_str().ok_or(ValueError::InvalidType)?;
|
|
||||||
|
|
||||||
match state {
|
|
||||||
"delegates" => Ok(Self::Delegates),
|
|
||||||
_ => Err(ValueError::InvalidValue(value.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<MergeTarget> for ScalarValue {
|
|
||||||
fn from(target: MergeTarget) -> Self {
|
|
||||||
match target {
|
|
||||||
MergeTarget::Delegates => ScalarValue::from("delegates"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
@ -168,56 +167,47 @@ impl FromStr for Color {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A discussion thread.
|
impl Serialize for Color {
|
||||||
pub type Discussion = Vec<Comment<Replies>>;
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
/// Comment replies.
|
S: serde::ser::Serializer,
|
||||||
pub type Replies = Vec<Comment>;
|
{
|
||||||
|
let s = self.to_string();
|
||||||
/// Local id of a comment in an issue.
|
serializer.serialize_str(&s)
|
||||||
#[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 {
|
impl<'a> Deserialize<'a> for Color {
|
||||||
fn from(ix: usize) -> Self {
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
Self { ix }
|
where
|
||||||
|
D: serde::de::Deserializer<'a>,
|
||||||
|
{
|
||||||
|
let color = String::deserialize(deserializer)?;
|
||||||
|
Self::from_str(&color).map_err(serde::de::Error::custom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<CommentId> for usize {
|
#[cfg(test)]
|
||||||
fn from(id: CommentId) -> Self {
|
mod test {
|
||||||
id.ix
|
use super::*;
|
||||||
}
|
|
||||||
}
|
#[test]
|
||||||
|
fn test_color() {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
let c = Color::from_str("#ffccaa").unwrap();
|
||||||
pub struct Comment<R = ()> {
|
assert_eq!(c.to_string(), "#ffccaa".to_owned());
|
||||||
pub author: Author,
|
assert_eq!(serde_json::to_string(&c).unwrap(), "\"#ffccaa\"".to_owned());
|
||||||
pub body: String,
|
assert_eq!(serde_json::from_str::<'_, Color>("\"#ffccaa\"").unwrap(), c);
|
||||||
pub reactions: HashMap<Reaction, usize>,
|
|
||||||
pub replies: R,
|
let c = Color::from_str("#0000aa").unwrap();
|
||||||
pub timestamp: Timestamp,
|
assert_eq!(c.to_string(), "#0000aa".to_owned());
|
||||||
}
|
|
||||||
|
let c = Color::from_str("#aa0000").unwrap();
|
||||||
impl<R: Default> Comment<R> {
|
assert_eq!(c.to_string(), "#aa0000".to_owned());
|
||||||
pub fn new(author: Author, body: String, timestamp: Timestamp) -> Self {
|
|
||||||
Self {
|
let c = Color::from_str("#00aa00").unwrap();
|
||||||
author,
|
assert_eq!(c.to_string(), "#00aa00".to_owned());
|
||||||
body,
|
|
||||||
reactions: HashMap::default(),
|
Color::from_str("#aa00").unwrap_err();
|
||||||
replies: R::default(),
|
Color::from_str("#abc").unwrap_err();
|
||||||
timestamp,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue