Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
heartwood crates radicle src cob thread.rs
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use std::str::FromStr;
use std::sync::LazyLock;

use serde::{Deserialize, Serialize, ser::SerializeStruct};
use thiserror::Error;

use crate::cob;
use crate::cob::common::{Reaction, Timestamp, Uri};
use crate::cob::store::Cob;
use crate::cob::{ActorId, Embed, EntryId, Op, op};
use crate::git;
use crate::prelude::ReadRepository;

/// Type name of a thread, as well as the domain for all thread operations.
/// Note that threads are not usually used standalone. They are embedded into other COBs.
pub static TYPENAME: LazyLock<cob::TypeName> =
    LazyLock::new(|| FromStr::from_str("xyz.radicle.thread").expect("type name is valid"));

/// Error applying an operation onto a state.
#[derive(Error, Debug)]
pub enum Error {
    /// Causal dependency missing.
    ///
    /// This error indicates that the operations are not being applied
    /// in causal order, which is a requirement for this CRDT.
    ///
    /// For example, this can occur if an operation references anothern operation
    /// that hasn't happened yet.
    #[error("causal dependency {0:?} missing")]
    Missing(EntryId),
    /// The identity doc is missing.
    #[error("identity document missing")]
    MissingIdentity,
    /// Error with comment operation.
    #[error("comment {0} is invalid")]
    Comment(EntryId),
    /// Error with edit operation.
    #[error("edit {0} is invalid")]
    Edit(EntryId),
    /// Object initialization failed.
    #[error("initialization failed: {0}")]
    Init(&'static str),
    #[error("op decoding failed: {0}")]
    Op(#[from] op::OpEncodingError),
}

/// Identifies a comment.
pub type CommentId = EntryId;

/// Reactions to a comment or other action.
pub type Reactions = BTreeSet<(ActorId, Reaction)>;

/// A comment edit is just some text and an edit time.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Edit {
    /// Edit author.
    pub author: ActorId,
    /// When the edit was made.
    pub timestamp: Timestamp,
    /// Edit contents. Replaces previous edits.
    pub body: String,
    /// Edit embed list.
    pub embeds: Vec<Embed<Uri>>,
}

impl Edit {
    /// Create a new edit.
    pub fn new(
        author: ActorId,
        body: String,
        timestamp: Timestamp,
        embeds: Vec<Embed<Uri>>,
    ) -> Self {
        Self {
            author,
            timestamp,
            body,
            embeds,
        }
    }
}

/// The `Infallible` type does not have a `Serialize`/`Deserialize`
/// implementation. The `Never` type imitates `Infallible` and
/// provides the derived implementations.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Never {}

/// A comment on a discussion thread.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Comment<T = Never> {
    /// Comment author.
    pub(in crate::cob) author: ActorId,
    /// The comment body.
    pub(in crate::cob) edits: Vec<Edit>,
    /// Reactions to this comment.
    pub(in crate::cob) reactions: Reactions,
    /// Comment this is a reply to.
    /// Should always be set, except for the root comment.
    pub(in crate::cob) reply_to: Option<CommentId>,
    /// Location of comment, if this is an inline comment.
    pub(in crate::cob) location: Option<T>,
    /// Whether the comment has been resolved.
    pub(in crate::cob) resolved: bool,
}

impl<T: Serialize> Serialize for Comment<T> {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::ser::Serializer,
    {
        let mut state = serializer.serialize_struct("Comment", 8)?;
        state.serialize_field("author", &self.author())?;
        if let Some(to) = self.reply_to {
            state.serialize_field("replyTo", &to)?;
        }
        state.serialize_field("reactions", &self.reactions)?;
        state.serialize_field("resolved", &self.resolved)?;
        state.serialize_field("body", self.body())?;
        state.serialize_field("edits", &self.edits)?;
        if let Some(location) = self.location() {
            state.serialize_field("location", &location)?;
        }

        let embeds = self.embeds();
        if !embeds.is_empty() {
            state.serialize_field("embeds", self.embeds())?;
        }
        state.end()
    }
}

impl<L> Comment<L> {
    /// Create a new comment.
    pub fn new(
        author: ActorId,
        body: String,
        reply_to: Option<CommentId>,
        location: Option<L>,
        embeds: Vec<Embed<Uri>>,
        timestamp: Timestamp,
    ) -> Self {
        let edit = Edit::new(author, body, timestamp, embeds);

        Self {
            author,
            reactions: BTreeSet::default(),
            edits: vec![edit],
            reply_to,
            location,
            resolved: false,
        }
    }

    /// Get the comment body. If there are multiple edits, gets the value at the latest edit.
    pub fn body(&self) -> &str {
        // SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`]
        // constructor.
        #[allow(clippy::unwrap_used)]
        self.edits.last().unwrap().body.as_str()
    }

    /// Get the comment timestamp, which is the time of the *original* edit. To get the timestamp
    /// of the latest edit, use the [`Comment::edits`] function.
    pub fn timestamp(&self) -> Timestamp {
        // SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`]
        // constructor.
        #[allow(clippy::unwrap_used)]
        self.edits.first().unwrap().timestamp
    }

    /// Return the comment author.
    pub fn author(&self) -> ActorId {
        self.author
    }

    /// Return the comment this is a reply to. Returns nothing if this is the root comment.
    pub fn reply_to(&self) -> Option<CommentId> {
        self.reply_to
    }

    /// Return the ordered list of edits for this comment, including the original version.
    pub fn edits(&self) -> impl Iterator<Item = &Edit> {
        self.edits.iter()
    }

    /// Add an edit.
    pub fn edit(
        &mut self,
        author: ActorId,
        body: String,
        embeds: Vec<Embed<Uri>>,
        timestamp: Timestamp,
    ) {
        self.edits.push(Edit::new(author, body, timestamp, embeds));
    }

    /// Comment reactions.
    pub fn reactions(&self) -> BTreeMap<&Reaction, Vec<&ActorId>> {
        self.reactions
            .iter()
            .fold(BTreeMap::new(), |mut acc, (author, reaction)| {
                acc.entry(reaction).or_default().push(author);
                acc
            })
    }

    /// Get comment location, if any.
    pub fn location(&self) -> Option<&L> {
        self.location.as_ref()
    }

    /// Get comment resolution status.
    pub fn is_resolved(&self) -> bool {
        self.resolved
    }

    /// Return the embedded media.
    pub fn embeds(&self) -> &[Embed<Uri>] {
        // SAFETY: There is always at least one edit. This is guaranteed by the [`Comment`]
        // constructor.
        #[allow(clippy::unwrap_used)]
        &self.edits.last().unwrap().embeds
    }

    pub fn resolve(&mut self) {
        self.resolved = true;
    }

    pub fn unresolve(&mut self) {
        self.resolved = false;
    }
}

impl<T: PartialOrd> PartialOrd for Comment<T> {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        if self == other {
            Some(Ordering::Equal)
        } else {
            None
        }
    }
}

/// An action that can be carried out in a change.
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Action {
    /// Comment on a thread.
    #[serde(rename_all = "camelCase")]
    Comment {
        /// Comment body.
        body: String,
        /// Comment this is a reply to.
        /// Should be [`None`] if it's the top-level comment.
        /// Should be the root [`CommentId`] if it's a top-level comment.
        reply_to: Option<CommentId>,
    },
    /// Edit a comment.
    Edit { id: CommentId, body: String },
    /// Redact a change. Not all changes can be redacted.
    Redact { id: CommentId },
    /// React to a comment.
    React {
        to: CommentId,
        reaction: Reaction,
        active: bool,
    },
}

impl cob::store::CobAction for Action {
    fn produces_identifier(&self) -> bool {
        matches!(self, Self::Comment { .. })
    }
}

impl From<Action> for nonempty::NonEmpty<Action> {
    fn from(action: Action) -> Self {
        Self::new(action)
    }
}

/// A discussion thread.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Thread<T = Comment> {
    /// The comments under the thread.
    pub(crate) comments: BTreeMap<CommentId, Option<T>>,
    /// Comment timeline.
    pub(crate) timeline: Vec<CommentId>,
}

impl<T> Default for Thread<T> {
    fn default() -> Self {
        Self {
            comments: BTreeMap::default(),
            timeline: Vec::default(),
        }
    }
}

impl<T> cob::store::CobWithType for Thread<T> {
    fn type_name() -> &'static radicle_cob::TypeName {
        &TYPENAME
    }
}

impl<T> Thread<T> {
    pub fn new(id: CommentId, comment: T) -> Self {
        Self {
            comments: BTreeMap::from_iter([(id, Some(comment))]),
            timeline: vec![id],
        }
    }

    pub fn is_initialized(&self) -> bool {
        !self.comments.is_empty()
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }

    pub fn len(&self) -> usize {
        self.comments.len()
    }

    pub fn comment(&self, id: &CommentId) -> Option<&T> {
        self.comments.get(id).and_then(|o| o.as_ref())
    }

    pub fn root(&self) -> Option<(&CommentId, &T)> {
        self.first()
    }

    pub fn first(&self) -> Option<(&CommentId, &T)> {
        self.comments().next()
    }

    pub fn last(&self) -> Option<(&CommentId, &T)> {
        self.comments().next_back()
    }

    pub fn comments(&self) -> impl DoubleEndedIterator<Item = (&CommentId, &T)> + '_ {
        self.timeline.iter().filter_map(|id| {
            self.comments
                .get(id)
                .and_then(|o| o.as_ref())
                .map(|comment| (id, comment))
        })
    }

    pub fn timeline(&self) -> impl DoubleEndedIterator<Item = &EntryId> + '_ {
        self.timeline.iter()
    }
}

impl Thread {
    /// Apply a single action to the thread.
    fn action<R: ReadRepository>(
        &mut self,
        action: Action,
        entry: EntryId,
        author: ActorId,
        timestamp: Timestamp,
        _concurrent: &[&cob::Entry],
        _identity: git::Oid,
        _repo: &R,
    ) -> Result<(), Error> {
        match action {
            Action::Comment { body, reply_to } => {
                comment(self, entry, author, timestamp, body, reply_to, None, vec![])?;
            }
            Action::Edit { id, body } => {
                edit(self, entry, author, id, timestamp, body, vec![])?;
            }
            Action::Redact { id } => {
                redact(self, entry, id)?;
            }
            Action::React {
                to,
                reaction,
                active,
            } => {
                react(self, entry, author, to, reaction, active)?;
            }
        }
        Ok(())
    }
}

impl<L> Thread<Comment<L>> {
    pub fn replies<'a>(
        &'a self,
        to: &'a CommentId,
    ) -> impl Iterator<Item = (&'a CommentId, &'a Comment<L>)> {
        self.comments().filter_map(move |(id, c)| {
            if let Some(reply_to) = c.reply_to {
                if &reply_to == to {
                    return Some((id, c));
                }
            }
            None
        })
    }
}

impl cob::store::Cob for Thread {
    type Action = Action;
    type Error = Error;

    fn from_root<R: ReadRepository>(op: Op<Action>, repo: &R) -> Result<Self, Self::Error> {
        let author = op.author;
        let entry = op.id;
        let timestamp = op.timestamp;
        let identity = op.identity.ok_or(Error::MissingIdentity)?;
        let mut actions = op.actions.into_iter();
        let Some(Action::Comment {
            body,
            reply_to: None,
        }) = actions.next()
        else {
            return Err(Error::Init("missing initial comment"));
        };

        let mut thread = Thread::default();
        comment(
            &mut thread,
            entry,
            author,
            timestamp,
            body,
            None,
            None,
            vec![],
        )?;

        for action in actions {
            thread.action(action, entry, author, timestamp, &[], identity, repo)?;
        }
        Ok(thread)
    }

    fn op<'a, R: ReadRepository, I: IntoIterator<Item = &'a cob::Entry>>(
        &mut self,
        op: Op<Action>,
        concurrent: I,
        repo: &R,
    ) -> Result<(), Error> {
        let identity = op.identity.ok_or(Error::MissingIdentity)?;
        let concurrent = concurrent.into_iter().collect::<Vec<_>>();
        for action in op.actions {
            self.action(
                action,
                op.id,
                op.author,
                op.timestamp,
                &concurrent,
                identity,
                repo,
            )?;
        }
        Ok(())
    }
}

impl<R: ReadRepository> cob::Evaluate<R> for Thread {
    type Error = Error;

    fn init(entry: &cob::Entry, repo: &R) -> Result<Self, Self::Error> {
        let op = Op::try_from(entry)?;
        let object = <Thread as Cob>::from_root(op, repo)?;

        Ok(object)
    }

    fn apply<'a, I: Iterator<Item = (&'a EntryId, &'a cob::Entry)>>(
        &mut self,
        entry: &cob::Entry,
        concurrent: I,
        repo: &R,
    ) -> Result<(), Self::Error> {
        let op = Op::try_from(entry)?;

        self.op(op, concurrent.map(|(_, e)| e), repo)
    }
}

pub fn comment<L>(
    thread: &mut Thread<Comment<L>>,
    id: EntryId,
    author: ActorId,
    timestamp: Timestamp,
    body: String,
    reply_to: Option<CommentId>,
    location: Option<L>,
    embeds: Vec<Embed<Uri>>,
) -> Result<(), Error> {
    if body.is_empty() {
        return Err(Error::Comment(id));
    }
    if let Some(id) = reply_to {
        if !thread.comments.contains_key(&id) {
            return Err(Error::Missing(id));
        }
    }
    debug_assert!(!thread.timeline.contains(&id));
    thread.timeline.push(id);

    // Nb. If a comment is already present, it must be redacted, because the
    // underlying store guarantees exactly-once delivery of ops.
    thread.comments.insert(
        id,
        Some(Comment::new(
            author, body, reply_to, location, embeds, timestamp,
        )),
    );

    Ok(())
}

pub fn edit<L>(
    thread: &mut Thread<Comment<L>>,
    id: EntryId,
    author: ActorId,
    comment: EntryId,
    timestamp: Timestamp,
    body: String,
    embeds: Vec<Embed<Uri>>,
) -> Result<(), Error> {
    debug_assert!(!thread.timeline.contains(&id));
    thread.timeline.push(id);

    // It's possible for a comment to be redacted before we're able to edit it, in
    // case of a concurrent update.
    //
    // However, it's *not* possible for the comment to be absent. Therefore we treat
    // that as an error.
    if let Some(comment) = thread.comments.get_mut(&comment) {
        if let Some(comment) = comment {
            comment.edit(author, body, embeds, timestamp);
        }
    } else {
        return Err(Error::Missing(comment));
    }
    Ok(())
}

pub fn redact<T>(thread: &mut Thread<T>, id: EntryId, comment: EntryId) -> Result<(), Error> {
    if let Some(comment) = thread.comments.get_mut(&comment) {
        debug_assert!(!thread.timeline.contains(&id));
        thread.timeline.push(id);

        *comment = None;
    } else {
        return Err(Error::Missing(id));
    }
    Ok(())
}

pub fn react<T>(
    thread: &mut Thread<Comment<T>>,
    id: EntryId,
    author: ActorId,
    comment: EntryId,
    reaction: Reaction,
    active: bool,
) -> Result<(), Error> {
    let key = (author, reaction);
    let Some(comment) = thread.comments.get_mut(&comment) else {
        return Err(Error::Missing(comment));
    };
    if let Some(comment) = comment {
        debug_assert!(!thread.timeline.contains(&id));
        thread.timeline.push(id);

        if active {
            comment.reactions.insert(key);
        } else {
            comment.reactions.remove(&key);
        }
    }
    Ok(())
}

pub fn resolve<T>(
    thread: &mut Thread<Comment<T>>,
    id: EntryId,
    comment: EntryId,
) -> Result<(), Error> {
    let Some(comment) = thread.comments.get_mut(&comment) else {
        return Err(Error::Missing(comment));
    };

    if let Some(comment) = comment {
        debug_assert!(!thread.timeline.contains(&id));
        thread.timeline.push(id);
        comment.resolve();
    }
    Ok(())
}

pub fn unresolve<T>(
    thread: &mut Thread<Comment<T>>,
    id: EntryId,
    comment: EntryId,
) -> Result<(), Error> {
    let Some(comment) = thread.comments.get_mut(&comment) else {
        return Err(Error::Missing(comment));
    };

    if let Some(comment) = comment {
        debug_assert!(!thread.timeline.contains(&id));
        thread.timeline.push(id);
        comment.unresolve();
    }
    Ok(())
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use std::ops::{Deref, DerefMut};

    use pretty_assertions::assert_eq;
    use qcheck_macros::quickcheck;

    use super::*;
    use crate as radicle;
    use crate::cob::store::Cob;
    use crate::cob::test;
    use crate::crypto::Signer;
    use crate::crypto::test::signer::MockSigner;
    use crate::node::device::Device;
    use crate::profile::env;
    use crate::test::arbitrary;
    use crate::test::arbitrary::r#gen;
    use crate::test::storage::MockRepository;

    /// An object that can be used to create and sign changes.
    pub struct Actor<G> {
        inner: cob::test::Actor<G>,
    }

    impl<G: Default + Signer> Default for Actor<G> {
        fn default() -> Self {
            Self {
                inner: cob::test::Actor::<G>::default(),
            }
        }
    }

    impl<G: Signer> Actor<G> {
        pub fn new(signer: Device<G>) -> Self {
            Self {
                inner: cob::test::Actor::new(signer),
            }
        }

        /// Create a new comment.
        pub fn comment(&mut self, body: &str, reply_to: Option<CommentId>) -> Op<Action> {
            self.op::<Thread>([Action::Comment {
                body: String::from(body),
                reply_to,
            }])
        }

        /// Create a new redaction.
        pub fn redact(&mut self, id: CommentId) -> Op<Action> {
            self.op::<Thread>([Action::Redact { id }])
        }

        /// Edit a comment.
        pub fn edit(&mut self, id: CommentId, body: &str) -> Op<Action> {
            self.op::<Thread>([Action::Edit {
                id,
                body: body.to_owned(),
            }])
        }
    }

    impl<G> Deref for Actor<G> {
        type Target = cob::test::Actor<G>;

        fn deref(&self) -> &Self::Target {
            &self.inner
        }
    }

    impl<G> DerefMut for Actor<G> {
        fn deref_mut(&mut self) -> &mut Self::Target {
            &mut self.inner
        }
    }

    #[test]
    fn test_redact_comment() {
        let radicle::test::setup::Node { signer, .. } = radicle::test::setup::Node::default();
        let repo = r#gen::<MockRepository>(1);
        let mut alice = Actor::new(signer);

        let a0 = alice.comment("First comment", None);
        let a1 = alice.comment("Second comment", Some(a0.id()));
        let a2 = alice.comment("Third comment", Some(a0.id()));

        let mut thread = Thread::from_ops([a0, a1.clone(), a2], &repo).unwrap();
        assert_eq!(thread.comments().count(), 3);

        // Redact the second comment.
        let a3 = alice.redact(a1.id());
        thread.op(a3, [], &repo).unwrap();

        let (_, comment0) = thread.comments().next().unwrap();
        let (_, comment1) = thread.comments().nth(1).unwrap();

        assert_eq!(thread.comments().count(), 2);
        assert_eq!(comment0.body(), "First comment");
        assert_eq!(comment1.body(), "Third comment"); // Second comment was redacted.
    }

    #[test]
    fn test_edit_comment() {
        let mut alice = Actor::<MockSigner>::default();
        let repo = r#gen::<MockRepository>(1);

        let c0 = alice.comment("Hello world!", None);
        let c1 = alice.edit(c0.id(), "Goodbye world.");
        let c2 = alice.edit(c0.id(), "Goodbye world!");

        let t1 = Thread::from_ops([c0.clone(), c1, c2], &repo).unwrap();

        let comment = t1.comment(&c0.id());
        let edits = comment.unwrap().edits().collect::<Vec<_>>();

        assert_eq!(edits[0].body.as_str(), "Hello world!");
        assert_eq!(edits[1].body.as_str(), "Goodbye world.");
        assert_eq!(edits[2].body.as_str(), "Goodbye world!");
        assert_eq!(t1.comment(&c0.id()).unwrap().body(), "Goodbye world!");
    }

    #[test]
    fn test_timeline() {
        let alice = MockSigner::default();
        let bob = MockSigner::default();
        let eve = MockSigner::default();
        let repo = r#gen::<MockRepository>(1);
        let time = env::local_time();

        let mut a = test::history::<Thread, _>(
            &[Action::Comment {
                body: "Thread root".to_owned(),
                reply_to: None,
            }],
            time.into(),
            &alice,
        );
        a.comment("Alice comment", Some(*a.root().id()), &alice);

        let mut b = a.clone();
        let b1 = b.comment("Bob comment", Some(*a.root().id()), &bob);

        let mut e = a.clone();
        let e1 = e.comment("Eve comment", Some(*a.root().id()), &eve);

        assert_eq!(a.as_ref().len(), 2);
        assert_eq!(b.as_ref().len(), 3);
        assert_eq!(e.as_ref().len(), 3);

        a.merge(b.clone());
        a.merge(e.clone());

        assert_eq!(a.as_ref().len(), 4);

        b.merge(a.clone());
        b.merge(e.clone());

        e.merge(a.clone());
        e.merge(b.clone());

        assert_eq!(a, b);
        assert_eq!(b, e);

        let t1 = Thread::from_history(&a, &repo).unwrap();
        let t2 = Thread::from_history(&b, &repo).unwrap();
        let t3 = Thread::from_history(&e, &repo).unwrap();

        assert_eq!(t1, t2);
        assert_eq!(t2, t3);

        let timeline1 = t1.comments().collect::<Vec<_>>();
        let timeline2 = t2.comments().collect::<Vec<_>>();
        let timeline3 = t3.comments().collect::<Vec<_>>();

        assert_eq!(timeline1, timeline2);
        assert_eq!(timeline2, timeline3);
        assert_eq!(timeline1.len(), 4);
        assert_eq!(
            timeline1.iter().map(|(_, c)| c.body()).collect::<Vec<_>>(),
            // Since the operations are concurrent, the ordering depends on the ordering between
            // the operation ids.
            if e1 > b1 {
                vec!["Thread root", "Alice comment", "Bob comment", "Eve comment"]
            } else {
                vec!["Thread root", "Alice comment", "Eve comment", "Bob comment"]
            }
        );
    }

    #[test]
    fn test_duplicate_comments() {
        let repo = r#gen::<MockRepository>(1);
        let alice = MockSigner::default();
        let bob = MockSigner::default();
        let time = env::local_time();

        let mut a = test::history::<Thread, _>(
            &[Action::Comment {
                body: "Thread root".to_owned(),
                reply_to: None,
            }],
            time.into(),
            &alice,
        );
        let mut b = a.clone();

        a.comment("Hello World!", None, &alice);
        b.comment("Hello World!", None, &bob);

        a.merge(b);

        let thread = Thread::from_history(&a, &repo).unwrap();

        assert_eq!(thread.comments().count(), 3);

        let (first_id, first) = thread.comments().nth(1).unwrap();
        let (second_id, second) = thread.comments().nth(2).unwrap();

        assert!(first_id != second_id); // The ids are not the same,
        assert_eq!(
            first
                .edits
                .iter()
                .map(|e| (&e.body, e.timestamp))
                .collect::<Vec<_>>(),
            second
                .edits
                .iter()
                .map(|e| (&e.body, e.timestamp))
                .collect::<Vec<_>>(),
        ); // despite the content being the same.
    }

    #[quickcheck]
    fn prop_ordering(timestamp: u64) {
        let repo = r#gen::<MockRepository>(1);
        let alice = MockSigner::default();
        let bob = MockSigner::default();
        let timestamp = Timestamp::from_secs(timestamp);

        let h0 = test::history::<Thread, _>(
            &[Action::Comment {
                body: "Thread root".to_owned(),
                reply_to: None,
            }],
            timestamp,
            &alice,
        );
        let mut h1 = h0.clone();
        let mut h2 = h0.clone();

        let e1 = h1.commit(
            &Action::Edit {
                id: *h0.root().id(),
                body: String::from("Bye World."),
            },
            &alice,
        );
        let e2 = h2.commit(
            &Action::Edit {
                id: *h0.root().id(),
                body: String::from("Hi World."),
            },
            &bob,
        );

        h1.merge(h2);

        let thread = Thread::from_history(&h1, &repo).unwrap();
        let (_, comment) = thread.comments().next().unwrap();

        // E1 and E2 are concurrent, so the final edit will depend on which is the greater hash.
        if e2 > e1 {
            assert_eq!(comment.body(), "Hi World.");
        } else {
            assert_eq!(comment.body(), "Bye World.");
        }

        let _e3 = h1.commit(
            &Action::Edit {
                id: *h0.root().id(),
                body: String::from("Hoho World!"),
            },
            &alice,
        );
        let thread = Thread::from_history(&h1, &repo).unwrap();
        let (_, comment) = thread.comments().next().unwrap();

        // E3 is causally dependent on E1 and E2, so it always wins.
        assert_eq!(comment.body(), "Hoho World!");
    }

    #[test]
    fn test_comment_redact_missing() {
        let repo = r#gen::<MockRepository>(1);
        let mut alice = Actor::<MockSigner>::default();
        let mut t = Thread::default();
        let id = arbitrary::entry_id();

        t.op(alice.redact(id), [], &repo).unwrap_err();
    }

    #[test]
    fn test_comment_edit_missing() {
        let repo = r#gen::<MockRepository>(1);
        let mut alice = Actor::<MockSigner>::default();
        let mut t = Thread::default();
        let id = arbitrary::entry_id();

        t.op(alice.edit(id, "Edited"), [], &repo).unwrap_err();
    }

    #[test]
    fn test_comment_edit_redacted() {
        let repo = r#gen::<MockRepository>(1);
        let mut alice = Actor::<MockSigner>::default();

        let a1 = alice.comment("Hi", None);
        let a2 = alice.redact(a1.id);
        let a3 = alice.edit(a1.id, "Edited");

        let t = Thread::from_ops([a1, a2, a3], &repo).unwrap();
        assert_eq!(t.comments().count(), 0);
    }
}