heddle_object_model/object/
discussion.rs1use serde::{Deserialize, Serialize};
15
16use crate::object::{
17 hash::StateId, state_attribution::Principal, state_review::SymbolAnchor,
18 visibility_tier::VisibilityTier,
19};
20
21#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
22pub struct DiscussionsBlob {
23 pub format_version: u8,
24 pub discussions: Vec<Discussion>,
25}
26
27versioned_msgpack_blob! {
28 blob: DiscussionsBlob,
29 item: Discussion,
30 field: discussions,
31 error: DiscussionError,
32 codec_err: Encoding,
33 version: 1,
34}
35
36pub type DiscussionId = String;
40
41pub fn generate_discussion_id() -> DiscussionId {
42 uuid::Uuid::now_v7().to_string()
43}
44
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub struct Discussion {
47 pub id: DiscussionId,
48 pub anchor: SymbolAnchor,
49 pub opened_against_state: StateId,
50 pub opened_at: i64,
52 #[serde(default)]
53 pub thread_ref: Option<String>,
54 pub turns: Vec<DiscussionTurn>,
55 pub resolution: DiscussionResolution,
56 #[serde(default)]
60 pub body_changed_since_open: bool,
61 #[serde(default)]
65 pub anchor_ambiguous: bool,
66 #[serde(default)]
70 pub orphaned: bool,
71 #[serde(default)]
73 pub visibility: VisibilityTier,
74 #[serde(default)]
78 pub resolved_annotation_id: Option<String>,
79}
80
81impl Discussion {
82 pub fn validate(&self) -> Result<(), DiscussionError> {
83 if self.id.is_empty() {
84 return Err(DiscussionError::EmptyId);
85 }
86 if self.anchor.file.is_empty() {
87 return Err(DiscussionError::EmptyAnchorFile);
88 }
89 if self.anchor.symbol.is_empty() {
90 return Err(DiscussionError::EmptyAnchorSymbol);
91 }
92 for turn in &self.turns {
93 turn.validate()?;
94 }
95 if let DiscussionResolution::Dismissed { reason } = &self.resolution
96 && reason.trim().is_empty()
97 {
98 return Err(DiscussionError::EmptyDismissReason);
99 }
100 if matches!(
101 self.resolution,
102 DiscussionResolution::ResolvedIntoAnnotation { .. }
103 ) && self.resolved_annotation_id.is_none()
104 {
105 return Err(DiscussionError::MissingAnnotationLink);
106 }
107 Ok(())
108 }
109
110 pub fn is_open(&self) -> bool {
111 matches!(self.resolution, DiscussionResolution::Open)
112 }
113}
114
115#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
116pub struct DiscussionTurn {
117 pub author: Principal,
118 pub body: String,
119 pub posted_at: i64,
121 #[serde(default)]
123 pub references: Vec<DiscussionReference>,
124}
125
126#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
128pub struct DiscussionReference {
129 pub kind: DiscussionReferenceKind,
131 pub id: String,
133 pub at: Option<StateId>,
135 pub start: u32,
137 pub end: u32,
139}
140
141#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
143pub enum DiscussionReferenceKind {
144 User,
146 Agent,
148 Spool,
150 Thread,
152 State,
154 File,
156 Line,
158 Symbol,
160}
161
162impl DiscussionTurn {
163 pub fn validate(&self) -> Result<(), DiscussionError> {
164 if self.body.trim().is_empty() {
165 return Err(DiscussionError::EmptyTurnBody);
166 }
167 Ok(())
168 }
169}
170
171#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
172pub enum DiscussionResolution {
173 #[default]
174 Open,
175 ResolvedIntoAnnotation { annotation_id: String },
180 ResolvedByEdit { state_id: StateId },
183 Dismissed { reason: String },
186}
187
188#[derive(Debug, thiserror::Error)]
189pub enum DiscussionError {
190 #[error("unsupported discussions blob version {0}")]
191 UnsupportedVersion(u8),
192 #[error("discussion id must not be empty")]
193 EmptyId,
194 #[error("discussion anchor must reference a non-empty file")]
195 EmptyAnchorFile,
196 #[error("discussion anchor must reference a non-empty symbol")]
197 EmptyAnchorSymbol,
198 #[error("discussion turn body must not be empty")]
199 EmptyTurnBody,
200 #[error("dismissed discussion must include a non-empty reason")]
201 EmptyDismissReason,
202 #[error("resolved-into-annotation discussion must set resolved_annotation_id")]
203 MissingAnnotationLink,
204 #[error("discussions blob encoding error: {0}")]
205 Encoding(String),
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 fn sample_principal() -> Principal {
213 Principal::new("Alice", "alice@example.com")
214 }
215
216 fn sample_discussion() -> Discussion {
217 Discussion {
218 id: "disc-1".into(),
219 anchor: SymbolAnchor::new("src/lib.rs", "foo"),
220 opened_against_state: StateId::from_bytes([7; 32]),
221 opened_at: 1_700_000_000,
222 thread_ref: None,
223 turns: vec![DiscussionTurn {
224 author: sample_principal(),
225 body: "why does this branch exist?".into(),
226 posted_at: 1_700_000_000,
227 references: Vec::new(),
228 }],
229 resolution: DiscussionResolution::Open,
230 body_changed_since_open: false,
231 anchor_ambiguous: false,
232 orphaned: false,
233 visibility: VisibilityTier::default(),
234 resolved_annotation_id: None,
235 }
236 }
237
238 #[test]
239 fn open_discussion_validates() {
240 sample_discussion().validate().unwrap();
241 }
242
243 #[test]
244 fn dismissed_with_empty_reason_rejected() {
245 let mut d = sample_discussion();
246 d.resolution = DiscussionResolution::Dismissed {
247 reason: " ".into(),
248 };
249 assert!(matches!(
250 d.validate(),
251 Err(DiscussionError::EmptyDismissReason)
252 ));
253 }
254
255 #[test]
256 fn resolved_into_annotation_requires_link() {
257 let mut d = sample_discussion();
258 d.resolution = DiscussionResolution::ResolvedIntoAnnotation {
259 annotation_id: "ann-7".into(),
260 };
261 d.resolved_annotation_id = None;
262 assert!(matches!(
263 d.validate(),
264 Err(DiscussionError::MissingAnnotationLink)
265 ));
266 d.resolved_annotation_id = Some("ann-7".into());
267 d.validate().unwrap();
268 }
269
270 #[test]
271 fn empty_turn_body_rejected() {
272 let mut d = sample_discussion();
273 d.turns[0].body = " ".into();
274 assert!(matches!(d.validate(), Err(DiscussionError::EmptyTurnBody)));
275 }
276
277 #[test]
278 fn blob_roundtrip() {
279 let blob = DiscussionsBlob::new(vec![sample_discussion()]);
280 let bytes = blob.encode().unwrap();
281 let decoded = DiscussionsBlob::decode(&bytes).unwrap();
282 assert_eq!(blob, decoded);
283 }
284
285 #[test]
286 fn body_changed_marker_round_trips() {
287 let mut d = sample_discussion();
288 d.body_changed_since_open = true;
289 let blob = DiscussionsBlob::new(vec![d]);
290 let bytes = blob.encode().unwrap();
291 let decoded = DiscussionsBlob::decode(&bytes).unwrap();
292 assert!(decoded.discussions[0].body_changed_since_open);
293 }
294}