1use 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}
117
118impl DiscussionTurn {
119 pub fn validate(&self) -> Result<(), DiscussionError> {
120 if self.body.trim().is_empty() {
121 return Err(DiscussionError::EmptyTurnBody);
122 }
123 Ok(())
124 }
125}
126
127#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
128pub enum DiscussionResolution {
129 #[default]
130 Open,
131 ResolvedIntoAnnotation { annotation_id: String },
136 ResolvedByEdit { state_id: StateId },
139 Dismissed { reason: String },
142}
143
144#[derive(Debug, thiserror::Error)]
145pub enum DiscussionError {
146 #[error("unsupported discussions blob version {0}")]
147 UnsupportedVersion(u8),
148 #[error("discussion id must not be empty")]
149 EmptyId,
150 #[error("discussion anchor must reference a non-empty file")]
151 EmptyAnchorFile,
152 #[error("discussion anchor must reference a non-empty symbol")]
153 EmptyAnchorSymbol,
154 #[error("discussion turn body must not be empty")]
155 EmptyTurnBody,
156 #[error("dismissed discussion must include a non-empty reason")]
157 EmptyDismissReason,
158 #[error("resolved-into-annotation discussion must set resolved_annotation_id")]
159 MissingAnnotationLink,
160 #[error("discussions blob encoding error: {0}")]
161 Encoding(String),
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 fn sample_principal() -> Principal {
169 Principal::new("Alice", "alice@example.com")
170 }
171
172 fn sample_discussion() -> Discussion {
173 Discussion {
174 id: "disc-1".into(),
175 anchor: SymbolAnchor::new("src/lib.rs", "foo"),
176 opened_against_state: StateId::from_bytes([7; 32]),
177 opened_at: 1_700_000_000,
178 thread_ref: None,
179 turns: vec![DiscussionTurn {
180 author: sample_principal(),
181 body: "why does this branch exist?".into(),
182 posted_at: 1_700_000_000,
183 }],
184 resolution: DiscussionResolution::Open,
185 body_changed_since_open: false,
186 orphaned: false,
187 visibility: VisibilityTier::default(),
188 resolved_annotation_id: None,
189 }
190 }
191
192 #[test]
193 fn open_discussion_validates() {
194 sample_discussion().validate().unwrap();
195 }
196
197 #[test]
198 fn dismissed_with_empty_reason_rejected() {
199 let mut d = sample_discussion();
200 d.resolution = DiscussionResolution::Dismissed {
201 reason: " ".into(),
202 };
203 assert!(matches!(
204 d.validate(),
205 Err(DiscussionError::EmptyDismissReason)
206 ));
207 }
208
209 #[test]
210 fn resolved_into_annotation_requires_link() {
211 let mut d = sample_discussion();
212 d.resolution = DiscussionResolution::ResolvedIntoAnnotation {
213 annotation_id: "ann-7".into(),
214 };
215 d.resolved_annotation_id = None;
216 assert!(matches!(
217 d.validate(),
218 Err(DiscussionError::MissingAnnotationLink)
219 ));
220 d.resolved_annotation_id = Some("ann-7".into());
221 d.validate().unwrap();
222 }
223
224 #[test]
225 fn empty_turn_body_rejected() {
226 let mut d = sample_discussion();
227 d.turns[0].body = " ".into();
228 assert!(matches!(d.validate(), Err(DiscussionError::EmptyTurnBody)));
229 }
230
231 #[test]
232 fn blob_roundtrip() {
233 let blob = DiscussionsBlob::new(vec![sample_discussion()]);
234 let bytes = blob.encode().unwrap();
235 let decoded = DiscussionsBlob::decode(&bytes).unwrap();
236 assert_eq!(blob, decoded);
237 }
238
239 #[test]
240 fn body_changed_marker_round_trips() {
241 let mut d = sample_discussion();
242 d.body_changed_since_open = true;
243 let blob = DiscussionsBlob::new(vec![d]);
244 let bytes = blob.encode().unwrap();
245 let decoded = DiscussionsBlob::decode(&bytes).unwrap();
246 assert!(decoded.discussions[0].body_changed_since_open);
247 }
248}