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_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
36/// Stable opaque identifier for a discussion. Generated server-side at open
37/// time. We use a `String` rather than `ChangeId` to leave room for whatever
38/// id scheme the discussion service ends up choosing (likely a UUID).
39pub 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    /// Unix epoch seconds.
47    pub opened_at: i64,
48    #[serde(default)]
49    pub thread_ref: Option<String>,
50    pub turns: Vec<DiscussionTurn>,
51    pub resolution: DiscussionResolution,
52    /// Set by anchor-travel when the symbol body has changed since this
53    /// discussion was opened. Reviewers see a marker; resolution still
54    /// proceeds normally.
55    #[serde(default)]
56    pub body_changed_since_open: bool,
57    /// Set by anchor-travel when the symbol can't be resolved in the new
58    /// state (deleted or unreachable rename). The discussion stays open with
59    /// this marker for a human to triage.
60    #[serde(default)]
61    pub orphaned: bool,
62    /// Inherits from namespace policy unless explicitly overridden.
63    #[serde(default)]
64    pub visibility: VisibilityTier,
65    /// Bidirectional link populated when [`DiscussionResolution::ResolvedIntoAnnotation`]
66    /// fires. Lets viewers jump from the discussion to the annotation it
67    /// produced (and vice versa, via a back-pointer on the annotation).
68    #[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    /// Unix epoch seconds.
111    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    /// The discussion produced an annotation; the annotation is the durable
128    /// artifact going forward. The bidirectional link is on
129    /// [`Discussion::resolved_annotation_id`] and on the annotation's
130    /// metadata back-pointer.
131    ResolvedIntoAnnotation { annotation_id: String },
132    /// A subsequent edit addressed the discussion's concern. The state ID
133    /// pinpoints which edit was the answer.
134    ResolvedByEdit { state_id: ChangeId },
135    /// The discussion was dismissed without an annotation or follow-up
136    /// edit. A non-empty reason is required so future readers know why.
137    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}