1use serde::{Deserialize, Serialize};
15
16use crate::object::{
17 hash::ChangeId, 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
41#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Discussion {
43 pub id: DiscussionId,
44 pub anchor: SymbolAnchor,
45 pub opened_against_state: ChangeId,
46 pub opened_at: i64,
48 #[serde(default)]
49 pub thread_ref: Option<String>,
50 pub turns: Vec<DiscussionTurn>,
51 pub resolution: DiscussionResolution,
52 #[serde(default)]
56 pub body_changed_since_open: bool,
57 #[serde(default)]
61 pub orphaned: bool,
62 #[serde(default)]
64 pub visibility: VisibilityTier,
65 #[serde(default)]
69 pub resolved_annotation_id: Option<String>,
70}
71
72impl Discussion {
73 pub fn validate(&self) -> Result<(), DiscussionError> {
74 if self.id.is_empty() {
75 return Err(DiscussionError::EmptyId);
76 }
77 if self.anchor.file.is_empty() {
78 return Err(DiscussionError::EmptyAnchorFile);
79 }
80 if self.anchor.symbol.is_empty() {
81 return Err(DiscussionError::EmptyAnchorSymbol);
82 }
83 for turn in &self.turns {
84 turn.validate()?;
85 }
86 if let DiscussionResolution::Dismissed { reason } = &self.resolution
87 && reason.trim().is_empty()
88 {
89 return Err(DiscussionError::EmptyDismissReason);
90 }
91 if matches!(
92 self.resolution,
93 DiscussionResolution::ResolvedIntoAnnotation { .. }
94 ) && self.resolved_annotation_id.is_none()
95 {
96 return Err(DiscussionError::MissingAnnotationLink);
97 }
98 Ok(())
99 }
100
101 pub fn is_open(&self) -> bool {
102 matches!(self.resolution, DiscussionResolution::Open)
103 }
104}
105
106#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
107pub struct DiscussionTurn {
108 pub author: Principal,
109 pub body: String,
110 pub posted_at: i64,
112}
113
114impl DiscussionTurn {
115 pub fn validate(&self) -> Result<(), DiscussionError> {
116 if self.body.trim().is_empty() {
117 return Err(DiscussionError::EmptyTurnBody);
118 }
119 Ok(())
120 }
121}
122
123#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
124pub enum DiscussionResolution {
125 #[default]
126 Open,
127 ResolvedIntoAnnotation { annotation_id: String },
132 ResolvedByEdit { state_id: ChangeId },
135 Dismissed { reason: String },
138}
139
140#[derive(Debug, thiserror::Error)]
141pub enum DiscussionError {
142 #[error("unsupported discussions blob version {0}")]
143 UnsupportedVersion(u8),
144 #[error("discussion id must not be empty")]
145 EmptyId,
146 #[error("discussion anchor must reference a non-empty file")]
147 EmptyAnchorFile,
148 #[error("discussion anchor must reference a non-empty symbol")]
149 EmptyAnchorSymbol,
150 #[error("discussion turn body must not be empty")]
151 EmptyTurnBody,
152 #[error("dismissed discussion must include a non-empty reason")]
153 EmptyDismissReason,
154 #[error("resolved-into-annotation discussion must set resolved_annotation_id")]
155 MissingAnnotationLink,
156 #[error("discussions blob encoding error: {0}")]
157 Encoding(String),
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 fn sample_principal() -> Principal {
165 Principal::new("Alice", "alice@example.com")
166 }
167
168 fn sample_discussion() -> Discussion {
169 Discussion {
170 id: "disc-1".into(),
171 anchor: SymbolAnchor::new("src/lib.rs", "foo"),
172 opened_against_state: ChangeId::from_bytes([7; 16]),
173 opened_at: 1_700_000_000,
174 thread_ref: None,
175 turns: vec![DiscussionTurn {
176 author: sample_principal(),
177 body: "why does this branch exist?".into(),
178 posted_at: 1_700_000_000,
179 }],
180 resolution: DiscussionResolution::Open,
181 body_changed_since_open: false,
182 orphaned: false,
183 visibility: VisibilityTier::default(),
184 resolved_annotation_id: None,
185 }
186 }
187
188 #[test]
189 fn open_discussion_validates() {
190 sample_discussion().validate().unwrap();
191 }
192
193 #[test]
194 fn dismissed_with_empty_reason_rejected() {
195 let mut d = sample_discussion();
196 d.resolution = DiscussionResolution::Dismissed {
197 reason: " ".into(),
198 };
199 assert!(matches!(
200 d.validate(),
201 Err(DiscussionError::EmptyDismissReason)
202 ));
203 }
204
205 #[test]
206 fn resolved_into_annotation_requires_link() {
207 let mut d = sample_discussion();
208 d.resolution = DiscussionResolution::ResolvedIntoAnnotation {
209 annotation_id: "ann-7".into(),
210 };
211 d.resolved_annotation_id = None;
212 assert!(matches!(
213 d.validate(),
214 Err(DiscussionError::MissingAnnotationLink)
215 ));
216 d.resolved_annotation_id = Some("ann-7".into());
217 d.validate().unwrap();
218 }
219
220 #[test]
221 fn empty_turn_body_rejected() {
222 let mut d = sample_discussion();
223 d.turns[0].body = " ".into();
224 assert!(matches!(d.validate(), Err(DiscussionError::EmptyTurnBody)));
225 }
226
227 #[test]
228 fn blob_roundtrip() {
229 let blob = DiscussionsBlob::new(vec![sample_discussion()]);
230 let bytes = blob.encode().unwrap();
231 let decoded = DiscussionsBlob::decode(&bytes).unwrap();
232 assert_eq!(blob, decoded);
233 }
234
235 #[test]
236 fn body_changed_marker_round_trips() {
237 let mut d = sample_discussion();
238 d.body_changed_since_open = true;
239 let blob = DiscussionsBlob::new(vec![d]);
240 let bytes = blob.encode().unwrap();
241 let decoded = DiscussionsBlob::decode(&bytes).unwrap();
242 assert!(decoded.discussions[0].body_changed_since_open);
243 }
244}