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::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
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
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    /// Unix epoch seconds.
51    pub opened_at: i64,
52    #[serde(default)]
53    pub thread_ref: Option<String>,
54    pub turns: Vec<DiscussionTurn>,
55    pub resolution: DiscussionResolution,
56    /// Set by anchor-travel when the symbol body has changed since this
57    /// discussion was opened. Reviewers see a marker; resolution still
58    /// proceeds normally.
59    #[serde(default)]
60    pub body_changed_since_open: bool,
61    /// Set by anchor-travel when the symbol can't be resolved in the new
62    /// state (deleted or unreachable rename). The discussion stays open with
63    /// this marker for a human to triage.
64    #[serde(default)]
65    pub orphaned: bool,
66    /// Inherits from namespace policy unless explicitly overridden.
67    #[serde(default)]
68    pub visibility: VisibilityTier,
69    /// Bidirectional link populated when [`DiscussionResolution::ResolvedIntoAnnotation`]
70    /// fires. Lets viewers jump from the discussion to the annotation it
71    /// produced (and vice versa, via a back-pointer on the annotation).
72    #[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    /// Unix epoch seconds.
115    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    /// The discussion produced an annotation; the annotation is the durable
132    /// artifact going forward. The bidirectional link is on
133    /// [`Discussion::resolved_annotation_id`] and on the annotation's
134    /// metadata back-pointer.
135    ResolvedIntoAnnotation { annotation_id: String },
136    /// A subsequent edit addressed the discussion's concern. The state ID
137    /// pinpoints which edit was the answer.
138    ResolvedByEdit { state_id: StateId },
139    /// The discussion was dismissed without an annotation or follow-up
140    /// edit. A non-empty reason is required so future readers know why.
141    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}