1use serde::{Deserialize, Serialize};
15
16use crate::object::{
17 hash::ChangeId, state_attribution::Principal, state_context::AnnotationVisibility,
18 state_review::SymbolAnchor,
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
27impl DiscussionsBlob {
28 pub const FORMAT_VERSION: u8 = 1;
29
30 pub fn new(discussions: Vec<Discussion>) -> Self {
31 Self {
32 format_version: Self::FORMAT_VERSION,
33 discussions,
34 }
35 }
36
37 pub fn encode(&self) -> Result<Vec<u8>, DiscussionError> {
38 rmp_serde::to_vec(self).map_err(|err| DiscussionError::Encoding(err.to_string()))
39 }
40
41 pub fn decode(bytes: &[u8]) -> Result<Self, DiscussionError> {
42 let blob: Self = rmp_serde::from_slice(bytes)
43 .map_err(|err| DiscussionError::Encoding(err.to_string()))?;
44 blob.validate()?;
45 Ok(blob)
46 }
47
48 pub fn validate(&self) -> Result<(), DiscussionError> {
49 if self.format_version != Self::FORMAT_VERSION {
50 return Err(DiscussionError::UnsupportedVersion(self.format_version));
51 }
52 for d in &self.discussions {
53 d.validate()?;
54 }
55 Ok(())
56 }
57}
58
59pub type DiscussionId = String;
63
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65pub struct Discussion {
66 pub id: DiscussionId,
67 pub anchor: SymbolAnchor,
68 pub opened_against_state: ChangeId,
69 pub opened_at: i64,
71 #[serde(default)]
72 pub thread_ref: Option<String>,
73 pub turns: Vec<DiscussionTurn>,
74 pub resolution: DiscussionResolution,
75 #[serde(default)]
79 pub body_changed_since_open: bool,
80 #[serde(default)]
84 pub orphaned: bool,
85 #[serde(default)]
87 pub visibility: AnnotationVisibility,
88 #[serde(default)]
92 pub resolved_annotation_id: Option<String>,
93}
94
95impl Discussion {
96 pub fn validate(&self) -> Result<(), DiscussionError> {
97 if self.id.is_empty() {
98 return Err(DiscussionError::EmptyId);
99 }
100 if self.anchor.file.is_empty() {
101 return Err(DiscussionError::EmptyAnchorFile);
102 }
103 if self.anchor.symbol.is_empty() {
104 return Err(DiscussionError::EmptyAnchorSymbol);
105 }
106 for turn in &self.turns {
107 turn.validate()?;
108 }
109 if let DiscussionResolution::Dismissed { reason } = &self.resolution
110 && reason.trim().is_empty()
111 {
112 return Err(DiscussionError::EmptyDismissReason);
113 }
114 if matches!(
115 self.resolution,
116 DiscussionResolution::ResolvedIntoAnnotation { .. }
117 ) && self.resolved_annotation_id.is_none()
118 {
119 return Err(DiscussionError::MissingAnnotationLink);
120 }
121 Ok(())
122 }
123
124 pub fn is_open(&self) -> bool {
125 matches!(self.resolution, DiscussionResolution::Open)
126 }
127}
128
129#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
130pub struct DiscussionTurn {
131 pub author: Principal,
132 pub body: String,
133 pub posted_at: i64,
135}
136
137impl DiscussionTurn {
138 pub fn validate(&self) -> Result<(), DiscussionError> {
139 if self.body.trim().is_empty() {
140 return Err(DiscussionError::EmptyTurnBody);
141 }
142 Ok(())
143 }
144}
145
146#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
147pub enum DiscussionResolution {
148 #[default]
149 Open,
150 ResolvedIntoAnnotation { annotation_id: String },
155 ResolvedByEdit { state_id: ChangeId },
158 Dismissed { reason: String },
161}
162
163#[derive(Debug, thiserror::Error)]
164pub enum DiscussionError {
165 #[error("unsupported discussions blob version {0}")]
166 UnsupportedVersion(u8),
167 #[error("discussion id must not be empty")]
168 EmptyId,
169 #[error("discussion anchor must reference a non-empty file")]
170 EmptyAnchorFile,
171 #[error("discussion anchor must reference a non-empty symbol")]
172 EmptyAnchorSymbol,
173 #[error("discussion turn body must not be empty")]
174 EmptyTurnBody,
175 #[error("dismissed discussion must include a non-empty reason")]
176 EmptyDismissReason,
177 #[error("resolved-into-annotation discussion must set resolved_annotation_id")]
178 MissingAnnotationLink,
179 #[error("discussions blob encoding error: {0}")]
180 Encoding(String),
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 fn sample_principal() -> Principal {
188 Principal::new("Alice", "alice@example.com")
189 }
190
191 fn sample_discussion() -> Discussion {
192 Discussion {
193 id: "disc-1".into(),
194 anchor: SymbolAnchor::new("src/lib.rs", "foo"),
195 opened_against_state: ChangeId::from_bytes([7; 16]),
196 opened_at: 1_700_000_000,
197 thread_ref: None,
198 turns: vec![DiscussionTurn {
199 author: sample_principal(),
200 body: "why does this branch exist?".into(),
201 posted_at: 1_700_000_000,
202 }],
203 resolution: DiscussionResolution::Open,
204 body_changed_since_open: false,
205 orphaned: false,
206 visibility: AnnotationVisibility::default(),
207 resolved_annotation_id: None,
208 }
209 }
210
211 #[test]
212 fn open_discussion_validates() {
213 sample_discussion().validate().unwrap();
214 }
215
216 #[test]
217 fn dismissed_with_empty_reason_rejected() {
218 let mut d = sample_discussion();
219 d.resolution = DiscussionResolution::Dismissed {
220 reason: " ".into(),
221 };
222 assert!(matches!(
223 d.validate(),
224 Err(DiscussionError::EmptyDismissReason)
225 ));
226 }
227
228 #[test]
229 fn resolved_into_annotation_requires_link() {
230 let mut d = sample_discussion();
231 d.resolution = DiscussionResolution::ResolvedIntoAnnotation {
232 annotation_id: "ann-7".into(),
233 };
234 d.resolved_annotation_id = None;
235 assert!(matches!(
236 d.validate(),
237 Err(DiscussionError::MissingAnnotationLink)
238 ));
239 d.resolved_annotation_id = Some("ann-7".into());
240 d.validate().unwrap();
241 }
242
243 #[test]
244 fn empty_turn_body_rejected() {
245 let mut d = sample_discussion();
246 d.turns[0].body = " ".into();
247 assert!(matches!(d.validate(), Err(DiscussionError::EmptyTurnBody)));
248 }
249
250 #[test]
251 fn blob_roundtrip() {
252 let blob = DiscussionsBlob::new(vec![sample_discussion()]);
253 let bytes = blob.encode().unwrap();
254 let decoded = DiscussionsBlob::decode(&bytes).unwrap();
255 assert_eq!(blob, decoded);
256 }
257
258 #[test]
259 fn body_changed_marker_round_trips() {
260 let mut d = sample_discussion();
261 d.body_changed_since_open = true;
262 let blob = DiscussionsBlob::new(vec![d]);
263 let bytes = blob.encode().unwrap();
264 let decoded = DiscussionsBlob::decode(&bytes).unwrap();
265 assert!(decoded.discussions[0].body_changed_since_open);
266 }
267}