crates/forge/ents-forge/src/comment/entity.rs
entity.rshistorycomment on this file
| 1 | //! The Comment entity: a body about something — an anchor, a context |
| 2 | //! entity, a parent comment, or any combination. |
| 3 | //! |
| 4 | //! Spec coverage: `model.comment`, `model.comment-state`, |
| 5 | //! `model.comment-context`, `model.comment-thread`. |
| 6 | |
| 7 | use ents_attrs as ents; |
| 8 | use facet::Facet; |
| 9 | use facet_git_tree::RawTree; |
| 10 | use gix_hash::ObjectId; |
| 11 | use gix_object::Find; |
| 12 | |
| 13 | /// A body of text about something: an anchor into content, a context |
| 14 | /// entity, a parent comment, or any combination (`model.comment`). |
| 15 | /// |
| 16 | /// `model.comment` requires a body and that the comment identify what it |
| 17 | /// is about; a comment about nothing is refused at creation by the writing |
| 18 | /// tool ([`Comment::is_about_nothing`], enforced in [`super::add`]), never |
| 19 | /// by the gate, which stays content-agnostic. Author and timestamp come |
| 20 | /// from the mutation commit chain rather than a stored field |
| 21 | /// (`meta-ref.identity-binding`) — `Comment` therefore has no author or timestamp |
| 22 | /// field, and no reviewer/resolver field either: who changed [`Comment::state`], |
| 23 | /// and when, is the mutation chain's answer too (`model.comment-state`). |
| 24 | /// |
| 25 | /// The anchor, when present, is stored as an opaque [`RawTree`]: |
| 26 | /// `anchor.adoc` (`anchor.definition`, `anchor.retention`, |
| 27 | /// `anchor.projection`, `anchor.working-tree`) defines what it identifies |
| 28 | /// and how it survives force-push and gc, and it is owned by |
| 29 | /// [`ents_anchor`], this crate's own dependency for anchoring a comment to |
| 30 | /// code (`super::command`). |
| 31 | /// |
| 32 | /// # Examples |
| 33 | /// |
| 34 | /// ``` |
| 35 | /// use ents_forge::comment::Comment; |
| 36 | /// use facet_git_tree::{ObjectStore, RawTree}; |
| 37 | /// use gix_object::{Kind, Write as _}; |
| 38 | /// |
| 39 | /// // Stand in for what `ents-anchor` actually writes: any pre-existing |
| 40 | /// // tree, embedded unchanged. |
| 41 | /// let store = ObjectStore::default(); |
| 42 | /// let anchor_tree = gix_object::Tree { entries: vec![] }; |
| 43 | /// let anchor_oid = store.write(&anchor_tree).expect("tree"); |
| 44 | /// |
| 45 | /// let comment = Comment { |
| 46 | /// body: "this line looks off by one".to_owned(), |
| 47 | /// state: "open".to_owned(), |
| 48 | /// anchor: Some(RawTree::new(anchor_oid)), |
| 49 | /// context: Some("issues/42".to_owned()), |
| 50 | /// parent: None, |
| 51 | /// }; |
| 52 | /// let root = facet_git_tree::serialize_into(&comment, &store).expect("serialize"); |
| 53 | /// let back: Comment = facet_git_tree::deserialize(&root, &store).expect("deserialize"); |
| 54 | /// assert_eq!(back, comment); |
| 55 | /// ``` |
| 56 | // @relation(model.comment, model.comment-state, model.comment-context, model.comment-thread, meta-ref.typed-tree, model.extensibility, scope=file) |
| 57 | #[derive(Debug, Clone, PartialEq, Eq, Facet)] |
| 58 | pub struct Comment { |
| 59 | /// The comment's text. |
| 60 | #[facet(ents::col, ents::body)] |
| 61 | pub body: String, |
| 62 | /// The comment's state (`model.comment-state`): `open` for a new |
| 63 | /// comment, `resolved` once resolved — not a fixed enum, because |
| 64 | /// custom states are schema, not platform features, exactly as for |
| 65 | /// issues (`model.issue`). |
| 66 | #[facet(ents::head)] |
| 67 | pub state: String, |
| 68 | /// The anchor identifying the exact content the comment was written |
| 69 | /// against (`anchor.definition`), opaque to this crate; `None` for a |
| 70 | /// comment about a context entity or a parent comment only. Never |
| 71 | /// rendered generically (`ents::skip`): its projection is a bespoke, |
| 72 | /// domain-specific line on every surface. |
| 73 | #[facet(ents::skip)] |
| 74 | pub anchor: Option<RawTree>, |
| 75 | /// The canonical ref path below `refs/meta/` of the entity this |
| 76 | /// comment belongs to, such as `issues/<id>` or `reviews/<target>/<member>` |
| 77 | /// (`model.comment-context`) — an entity's thread is an aggregation |
| 78 | /// query over comments naming it, never a list the entity stores. |
| 79 | #[facet(ents::skip_empty)] |
| 80 | pub context: Option<String>, |
| 81 | /// The id of the comment this one replies to (`model.comment-thread`); |
| 82 | /// a reply inherits its aboutness from its thread root rather than |
| 83 | /// repeating an anchor or context. |
| 84 | #[facet(ents::skip_empty, ents::id)] |
| 85 | pub parent: Option<String>, |
| 86 | } |
| 87 | |
| 88 | impl Comment { |
| 89 | /// Whether this comment identifies nothing at all — no anchor, no |
| 90 | /// context, no parent. `model.comment` requires the writing tool to |
| 91 | /// refuse such a comment at creation ([`super::add`] does), though |
| 92 | /// never the gate. |
| 93 | #[must_use] |
| 94 | pub fn is_about_nothing(&self) -> bool { |
| 95 | self.anchor.is_none() && self.context.is_none() && self.parent.is_none() |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | /// Read the [`Comment`] stored at `tree`. |
| 100 | /// |
| 101 | /// # Errors |
| 102 | /// |
| 103 | /// A [`facet_git_tree::Error`] when `tree` is not a well-formed comment. |
| 104 | pub(crate) fn read_comment(tree: &ObjectId, objects: &impl Find) -> crate::Result<Comment> { |
| 105 | Ok(facet_git_tree::deserialize::<Comment>(tree, objects)?) |
| 106 | } |
| 107 | |
| 108 | #[cfg(test)] |
| 109 | mod tests { |
| 110 | #![allow(clippy::expect_used, clippy::unwrap_used, reason = "unit test")] |
| 111 | |
| 112 | use facet_git_tree::{ObjectStore, deserialize, serialize_into}; |
| 113 | use gix_object::Write as _; |
| 114 | use rstest::rstest; |
| 115 | |
| 116 | use super::*; |
| 117 | |
| 118 | fn anchor_tree(store: &ObjectStore) -> RawTree { |
| 119 | let tree = gix_object::Tree { entries: vec![] }; |
| 120 | RawTree::new(store.write(&tree).expect("tree")) |
| 121 | } |
| 122 | |
| 123 | #[rstest] |
| 124 | #[case::anchored_only(true, None, None)] |
| 125 | #[case::context_only(false, Some("issues/42"), None)] |
| 126 | #[case::reply_only(false, None, Some("abc123"))] |
| 127 | #[case::every_kind_of_aboutness(true, Some("reviews/7"), Some("abc123"))] |
| 128 | // @relation(model.comment, model.comment-state, model.comment-context, model.comment-thread, meta-ref.typed-tree, scope=function, role=Verifies) |
| 129 | fn comment_round_trips_through_a_tree( |
| 130 | #[case] anchored: bool, |
| 131 | #[case] context: Option<&str>, |
| 132 | #[case] parent: Option<&str>, |
| 133 | ) { |
| 134 | let store = ObjectStore::default(); |
| 135 | let comment = Comment { |
| 136 | body: "looks off by one".to_owned(), |
| 137 | state: "open".to_owned(), |
| 138 | anchor: anchored.then(|| anchor_tree(&store)), |
| 139 | context: context.map(str::to_owned), |
| 140 | parent: parent.map(str::to_owned), |
| 141 | }; |
| 142 | let root = serialize_into(&comment, &store).expect("serialize"); |
| 143 | let back: Comment = deserialize(&root, &store).expect("deserialize"); |
| 144 | assert_eq!(back, comment); |
| 145 | } |
| 146 | |
| 147 | /// A tree that is not a well-formed comment fails to read. |
| 148 | // @relation(model.comment, scope=function, role=Verifies) |
| 149 | #[rstest] |
| 150 | fn a_foreign_tree_fails_to_read() { |
| 151 | let store = ObjectStore::default(); |
| 152 | let root = store |
| 153 | .write(&gix_object::Tree { entries: vec![] }) |
| 154 | .expect("tree"); |
| 155 | let _error = read_comment(&root, &store).unwrap_err(); |
| 156 | } |
| 157 | |
| 158 | #[rstest] |
| 159 | #[case::anchored(true, None, None, false)] |
| 160 | #[case::contextual(false, Some("issues/42"), None, false)] |
| 161 | #[case::reply(false, None, Some("abc"), false)] |
| 162 | #[case::about_nothing(false, None, None, true)] |
| 163 | // @relation(model.comment, scope=function, role=Verifies) |
| 164 | fn is_about_nothing_requires_all_three_absent( |
| 165 | #[case] anchored: bool, |
| 166 | #[case] context: Option<&str>, |
| 167 | #[case] parent: Option<&str>, |
| 168 | #[case] expected: bool, |
| 169 | ) { |
| 170 | let store = ObjectStore::default(); |
| 171 | let comment = Comment { |
| 172 | body: "b".to_owned(), |
| 173 | state: "open".to_owned(), |
| 174 | anchor: anchored.then(|| anchor_tree(&store)), |
| 175 | context: context.map(str::to_owned), |
| 176 | parent: parent.map(str::to_owned), |
| 177 | }; |
| 178 | assert_eq!(comment.is_about_nothing(), expected); |
| 179 | } |
| 180 | } |