git-ents.gitmain
⌘K
foforge
entity.rs180 lines · 7.2 KB · rusthistorycomment 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
7use ents_attrs as ents;
8use facet::Facet;
9use facet_git_tree::RawTree;
10use gix_hash::ObjectId;
11use 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)]
58pub 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
88impl 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.
104pub(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)]
109mod 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}