Skip to main content

objects/object/
discussion.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Anchored discussions on symbols.
3//!
4//! A discussion is opened against a symbol (file + symbol name, no line
5//! range), accumulates an ordered list of turns, and resolves into one of
6//! three terminal states. Anchors travel across renames and cross-file moves
7//! — the travel logic lives in `crates/repo/src/discussion_anchor_travel.rs`
8//! because it needs source bytes and tree-sitter; this module owns only the
9//! shape.
10//!
11//! Visibility inherits from the repo's annotation-default policy unless
12//! explicitly overridden when the discussion is opened.
13
14use 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
59/// Stable opaque identifier for a discussion. Generated server-side at open
60/// time. We use a `String` rather than `ChangeId` to leave room for whatever
61/// id scheme the discussion service ends up choosing (likely a UUID).
62pub 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    /// Unix epoch seconds.
70    pub opened_at: i64,
71    #[serde(default)]
72    pub thread_ref: Option<String>,
73    pub turns: Vec<DiscussionTurn>,
74    pub resolution: DiscussionResolution,
75    /// Set by anchor-travel when the symbol body has changed since this
76    /// discussion was opened. Reviewers see a marker; resolution still
77    /// proceeds normally.
78    #[serde(default)]
79    pub body_changed_since_open: bool,
80    /// Set by anchor-travel when the symbol can't be resolved in the new
81    /// state (deleted or unreachable rename). The discussion stays open with
82    /// this marker for a human to triage.
83    #[serde(default)]
84    pub orphaned: bool,
85    /// Inherits from namespace policy unless explicitly overridden.
86    #[serde(default)]
87    pub visibility: AnnotationVisibility,
88    /// Bidirectional link populated when [`DiscussionResolution::ResolvedIntoAnnotation`]
89    /// fires. Lets viewers jump from the discussion to the annotation it
90    /// produced (and vice versa, via a back-pointer on the annotation).
91    #[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    /// Unix epoch seconds.
134    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    /// The discussion produced an annotation; the annotation is the durable
151    /// artifact going forward. The bidirectional link is on
152    /// [`Discussion::resolved_annotation_id`] and on the annotation's
153    /// metadata back-pointer.
154    ResolvedIntoAnnotation { annotation_id: String },
155    /// A subsequent edit addressed the discussion's concern. The state ID
156    /// pinpoints which edit was the answer.
157    ResolvedByEdit { state_id: ChangeId },
158    /// The discussion was dismissed without an annotation or follow-up
159    /// edit. A non-empty reason is required so future readers know why.
160    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}