Skip to main content

heddle_object_model/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    /// Structured references occupying byte spans within [`Self::body`].
117    #[serde(default)]
118    pub references: Vec<DiscussionReference>,
119}
120
121/// One durable entity reference embedded in a discussion turn.
122#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
123pub struct DiscussionReference {
124    /// Entity category used to resolve [`Self::id`].
125    pub kind: DiscussionReferenceKind,
126    /// Stable identity of the referent, never its display label.
127    pub id: String,
128    /// State where a file, line, or symbol reference was known to be valid.
129    pub at: Option<StateId>,
130    /// Inclusive UTF-8 byte offset into the turn body.
131    pub start: u32,
132    /// Exclusive UTF-8 byte offset into the turn body.
133    pub end: u32,
134}
135
136/// Kind of entity named by a [`DiscussionReference`].
137#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
138pub enum DiscussionReferenceKind {
139    /// Human user identity.
140    User,
141    /// Agent identity.
142    Agent,
143    /// Spool identity.
144    Spool,
145    /// Thread identity.
146    Thread,
147    /// State identity.
148    State,
149    /// File path relative to a state.
150    File,
151    /// File and line identity relative to a state.
152    Line,
153    /// File and symbol identity relative to a state.
154    Symbol,
155}
156
157impl DiscussionTurn {
158    pub fn validate(&self) -> Result<(), DiscussionError> {
159        if self.body.trim().is_empty() {
160            return Err(DiscussionError::EmptyTurnBody);
161        }
162        Ok(())
163    }
164}
165
166#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
167pub enum DiscussionResolution {
168    #[default]
169    Open,
170    /// The discussion produced an annotation; the annotation is the durable
171    /// artifact going forward. The bidirectional link is on
172    /// [`Discussion::resolved_annotation_id`] and on the annotation's
173    /// metadata back-pointer.
174    ResolvedIntoAnnotation { annotation_id: String },
175    /// A subsequent edit addressed the discussion's concern. The state ID
176    /// pinpoints which edit was the answer.
177    ResolvedByEdit { state_id: StateId },
178    /// The discussion was dismissed without an annotation or follow-up
179    /// edit. A non-empty reason is required so future readers know why.
180    Dismissed { reason: String },
181}
182
183#[derive(Debug, thiserror::Error)]
184pub enum DiscussionError {
185    #[error("unsupported discussions blob version {0}")]
186    UnsupportedVersion(u8),
187    #[error("discussion id must not be empty")]
188    EmptyId,
189    #[error("discussion anchor must reference a non-empty file")]
190    EmptyAnchorFile,
191    #[error("discussion anchor must reference a non-empty symbol")]
192    EmptyAnchorSymbol,
193    #[error("discussion turn body must not be empty")]
194    EmptyTurnBody,
195    #[error("dismissed discussion must include a non-empty reason")]
196    EmptyDismissReason,
197    #[error("resolved-into-annotation discussion must set resolved_annotation_id")]
198    MissingAnnotationLink,
199    #[error("discussions blob encoding error: {0}")]
200    Encoding(String),
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn sample_principal() -> Principal {
208        Principal::new("Alice", "alice@example.com")
209    }
210
211    fn sample_discussion() -> Discussion {
212        Discussion {
213            id: "disc-1".into(),
214            anchor: SymbolAnchor::new("src/lib.rs", "foo"),
215            opened_against_state: StateId::from_bytes([7; 32]),
216            opened_at: 1_700_000_000,
217            thread_ref: None,
218            turns: vec![DiscussionTurn {
219                author: sample_principal(),
220                body: "why does this branch exist?".into(),
221                posted_at: 1_700_000_000,
222                references: Vec::new(),
223            }],
224            resolution: DiscussionResolution::Open,
225            body_changed_since_open: false,
226            orphaned: false,
227            visibility: VisibilityTier::default(),
228            resolved_annotation_id: None,
229        }
230    }
231
232    #[test]
233    fn open_discussion_validates() {
234        sample_discussion().validate().unwrap();
235    }
236
237    #[test]
238    fn dismissed_with_empty_reason_rejected() {
239        let mut d = sample_discussion();
240        d.resolution = DiscussionResolution::Dismissed {
241            reason: "  ".into(),
242        };
243        assert!(matches!(
244            d.validate(),
245            Err(DiscussionError::EmptyDismissReason)
246        ));
247    }
248
249    #[test]
250    fn resolved_into_annotation_requires_link() {
251        let mut d = sample_discussion();
252        d.resolution = DiscussionResolution::ResolvedIntoAnnotation {
253            annotation_id: "ann-7".into(),
254        };
255        d.resolved_annotation_id = None;
256        assert!(matches!(
257            d.validate(),
258            Err(DiscussionError::MissingAnnotationLink)
259        ));
260        d.resolved_annotation_id = Some("ann-7".into());
261        d.validate().unwrap();
262    }
263
264    #[test]
265    fn empty_turn_body_rejected() {
266        let mut d = sample_discussion();
267        d.turns[0].body = "   ".into();
268        assert!(matches!(d.validate(), Err(DiscussionError::EmptyTurnBody)));
269    }
270
271    #[test]
272    fn blob_roundtrip() {
273        let blob = DiscussionsBlob::new(vec![sample_discussion()]);
274        let bytes = blob.encode().unwrap();
275        let decoded = DiscussionsBlob::decode(&bytes).unwrap();
276        assert_eq!(blob, decoded);
277    }
278
279    #[test]
280    fn body_changed_marker_round_trips() {
281        let mut d = sample_discussion();
282        d.body_changed_since_open = true;
283        let blob = DiscussionsBlob::new(vec![d]);
284        let bytes = blob.encode().unwrap();
285        let decoded = DiscussionsBlob::decode(&bytes).unwrap();
286        assert!(decoded.discussions[0].body_changed_since_open);
287    }
288}