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 orphaned: bool,
66 #[serde(default)]
68 pub visibility: VisibilityTier,
69 #[serde(default)]
73 pub resolved_annotation_id: Option<String>,
74}
75
76impl Discussion {
77 pub fn validate(&self) -> Result<(), DiscussionError> {
78 if self.id.is_empty() {
79 return Err(DiscussionError::EmptyId);
80 }
81 if self.anchor.file.is_empty() {
82 return Err(DiscussionError::EmptyAnchorFile);
83 }
84 if self.anchor.symbol.is_empty() {
85 return Err(DiscussionError::EmptyAnchorSymbol);
86 }
87 for turn in &self.turns {
88 turn.validate()?;
89 }
90 if let DiscussionResolution::Dismissed { reason } = &self.resolution
91 && reason.trim().is_empty()
92 {
93 return Err(DiscussionError::EmptyDismissReason);
94 }
95 if matches!(
96 self.resolution,
97 DiscussionResolution::ResolvedIntoAnnotation { .. }
98 ) && self.resolved_annotation_id.is_none()
99 {
100 return Err(DiscussionError::MissingAnnotationLink);
101 }
102 Ok(())
103 }
104
105 pub fn is_open(&self) -> bool {
106 matches!(self.resolution, DiscussionResolution::Open)
107 }
108}
109
110#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
111pub struct DiscussionTurn {
112 pub author: Principal,
113 pub body: String,
114 pub posted_at: i64,
116 #[serde(default)]
118 pub references: Vec<DiscussionReference>,
119}
120
121#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
123pub struct DiscussionReference {
124 pub kind: DiscussionReferenceKind,
126 pub id: String,
128 pub at: Option<StateId>,
130 pub start: u32,
132 pub end: u32,
134}
135
136#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
138pub enum DiscussionReferenceKind {
139 User,
141 Agent,
143 Spool,
145 Thread,
147 State,
149 File,
151 Line,
153 Symbol,
155}
156
157impl DiscussionTurn {
158 pub fn validate(&self) -> Result<(), DiscussionError> {
159 if self.body.trim().is_empty() {
160 return Err(DiscussionError::EmptyTurnBody);
161 }
162 Ok(())
163 }
164}
165
166#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
167pub enum DiscussionResolution {
168 #[default]
169 Open,
170 ResolvedIntoAnnotation { annotation_id: String },
175 ResolvedByEdit { state_id: StateId },
178 Dismissed { reason: String },
181}
182
183#[derive(Debug, thiserror::Error)]
184pub enum DiscussionError {
185 #[error("unsupported discussions blob version {0}")]
186 UnsupportedVersion(u8),
187 #[error("discussion id must not be empty")]
188 EmptyId,
189 #[error("discussion anchor must reference a non-empty file")]
190 EmptyAnchorFile,
191 #[error("discussion anchor must reference a non-empty symbol")]
192 EmptyAnchorSymbol,
193 #[error("discussion turn body must not be empty")]
194 EmptyTurnBody,
195 #[error("dismissed discussion must include a non-empty reason")]
196 EmptyDismissReason,
197 #[error("resolved-into-annotation discussion must set resolved_annotation_id")]
198 MissingAnnotationLink,
199 #[error("discussions blob encoding error: {0}")]
200 Encoding(String),
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 fn sample_principal() -> Principal {
208 Principal::new("Alice", "alice@example.com")
209 }
210
211 fn sample_discussion() -> Discussion {
212 Discussion {
213 id: "disc-1".into(),
214 anchor: SymbolAnchor::new("src/lib.rs", "foo"),
215 opened_against_state: StateId::from_bytes([7; 32]),
216 opened_at: 1_700_000_000,
217 thread_ref: None,
218 turns: vec![DiscussionTurn {
219 author: sample_principal(),
220 body: "why does this branch exist?".into(),
221 posted_at: 1_700_000_000,
222 references: Vec::new(),
223 }],
224 resolution: DiscussionResolution::Open,
225 body_changed_since_open: false,
226 orphaned: false,
227 visibility: VisibilityTier::default(),
228 resolved_annotation_id: None,
229 }
230 }
231
232 #[test]
233 fn open_discussion_validates() {
234 sample_discussion().validate().unwrap();
235 }
236
237 #[test]
238 fn dismissed_with_empty_reason_rejected() {
239 let mut d = sample_discussion();
240 d.resolution = DiscussionResolution::Dismissed {
241 reason: " ".into(),
242 };
243 assert!(matches!(
244 d.validate(),
245 Err(DiscussionError::EmptyDismissReason)
246 ));
247 }
248
249 #[test]
250 fn resolved_into_annotation_requires_link() {
251 let mut d = sample_discussion();
252 d.resolution = DiscussionResolution::ResolvedIntoAnnotation {
253 annotation_id: "ann-7".into(),
254 };
255 d.resolved_annotation_id = None;
256 assert!(matches!(
257 d.validate(),
258 Err(DiscussionError::MissingAnnotationLink)
259 ));
260 d.resolved_annotation_id = Some("ann-7".into());
261 d.validate().unwrap();
262 }
263
264 #[test]
265 fn empty_turn_body_rejected() {
266 let mut d = sample_discussion();
267 d.turns[0].body = " ".into();
268 assert!(matches!(d.validate(), Err(DiscussionError::EmptyTurnBody)));
269 }
270
271 #[test]
272 fn blob_roundtrip() {
273 let blob = DiscussionsBlob::new(vec![sample_discussion()]);
274 let bytes = blob.encode().unwrap();
275 let decoded = DiscussionsBlob::decode(&bytes).unwrap();
276 assert_eq!(blob, decoded);
277 }
278
279 #[test]
280 fn body_changed_marker_round_trips() {
281 let mut d = sample_discussion();
282 d.body_changed_since_open = true;
283 let blob = DiscussionsBlob::new(vec![d]);
284 let bytes = blob.encode().unwrap();
285 let decoded = DiscussionsBlob::decode(&bytes).unwrap();
286 assert!(decoded.discussions[0].body_changed_since_open);
287 }
288}