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 semantic analysis found plausible targets but
62    /// no single high-confidence winner. The durable symbol anchor remains
63    /// unchanged for human triage.
64    #[serde(default)]
65    pub anchor_ambiguous: bool,
66    /// Set by anchor-travel when the symbol can't be resolved in the new
67    /// state (deleted or unreachable rename). The discussion stays open with
68    /// this marker for a human to triage.
69    #[serde(default)]
70    pub orphaned: bool,
71    /// Inherits from namespace policy unless explicitly overridden.
72    #[serde(default)]
73    pub visibility: VisibilityTier,
74    /// Bidirectional link populated when [`DiscussionResolution::ResolvedIntoAnnotation`]
75    /// fires. Lets viewers jump from the discussion to the annotation it
76    /// produced (and vice versa, via a back-pointer on the annotation).
77    #[serde(default)]
78    pub resolved_annotation_id: Option<String>,
79}
80
81impl Discussion {
82    pub fn validate(&self) -> Result<(), DiscussionError> {
83        if self.id.is_empty() {
84            return Err(DiscussionError::EmptyId);
85        }
86        if self.anchor.file.is_empty() {
87            return Err(DiscussionError::EmptyAnchorFile);
88        }
89        if self.anchor.symbol.is_empty() {
90            return Err(DiscussionError::EmptyAnchorSymbol);
91        }
92        for turn in &self.turns {
93            turn.validate()?;
94        }
95        if let DiscussionResolution::Dismissed { reason } = &self.resolution
96            && reason.trim().is_empty()
97        {
98            return Err(DiscussionError::EmptyDismissReason);
99        }
100        if matches!(
101            self.resolution,
102            DiscussionResolution::ResolvedIntoAnnotation { .. }
103        ) && self.resolved_annotation_id.is_none()
104        {
105            return Err(DiscussionError::MissingAnnotationLink);
106        }
107        Ok(())
108    }
109
110    pub fn is_open(&self) -> bool {
111        matches!(self.resolution, DiscussionResolution::Open)
112    }
113}
114
115#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
116pub struct DiscussionTurn {
117    pub author: Principal,
118    pub body: String,
119    /// Unix epoch seconds.
120    pub posted_at: i64,
121    /// Structured references occupying byte spans within [`Self::body`].
122    #[serde(default)]
123    pub references: Vec<DiscussionReference>,
124}
125
126/// One durable entity reference embedded in a discussion turn.
127#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
128pub struct DiscussionReference {
129    /// Entity category used to resolve [`Self::id`].
130    pub kind: DiscussionReferenceKind,
131    /// Stable identity of the referent, never its display label.
132    pub id: String,
133    /// State where a file, line, or symbol reference was known to be valid.
134    pub at: Option<StateId>,
135    /// Inclusive UTF-8 byte offset into the turn body.
136    pub start: u32,
137    /// Exclusive UTF-8 byte offset into the turn body.
138    pub end: u32,
139}
140
141/// Kind of entity named by a [`DiscussionReference`].
142#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
143pub enum DiscussionReferenceKind {
144    /// Human user identity.
145    User,
146    /// Agent identity.
147    Agent,
148    /// Spool identity.
149    Spool,
150    /// Thread identity.
151    Thread,
152    /// State identity.
153    State,
154    /// File path relative to a state.
155    File,
156    /// File and line identity relative to a state.
157    Line,
158    /// File and symbol identity relative to a state.
159    Symbol,
160}
161
162impl DiscussionTurn {
163    pub fn validate(&self) -> Result<(), DiscussionError> {
164        if self.body.trim().is_empty() {
165            return Err(DiscussionError::EmptyTurnBody);
166        }
167        Ok(())
168    }
169}
170
171#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
172pub enum DiscussionResolution {
173    #[default]
174    Open,
175    /// The discussion produced an annotation; the annotation is the durable
176    /// artifact going forward. The bidirectional link is on
177    /// [`Discussion::resolved_annotation_id`] and on the annotation's
178    /// metadata back-pointer.
179    ResolvedIntoAnnotation { annotation_id: String },
180    /// A subsequent edit addressed the discussion's concern. The state ID
181    /// pinpoints which edit was the answer.
182    ResolvedByEdit { state_id: StateId },
183    /// The discussion was dismissed without an annotation or follow-up
184    /// edit. A non-empty reason is required so future readers know why.
185    Dismissed { reason: String },
186}
187
188#[derive(Debug, thiserror::Error)]
189pub enum DiscussionError {
190    #[error("unsupported discussions blob version {0}")]
191    UnsupportedVersion(u8),
192    #[error("discussion id must not be empty")]
193    EmptyId,
194    #[error("discussion anchor must reference a non-empty file")]
195    EmptyAnchorFile,
196    #[error("discussion anchor must reference a non-empty symbol")]
197    EmptyAnchorSymbol,
198    #[error("discussion turn body must not be empty")]
199    EmptyTurnBody,
200    #[error("dismissed discussion must include a non-empty reason")]
201    EmptyDismissReason,
202    #[error("resolved-into-annotation discussion must set resolved_annotation_id")]
203    MissingAnnotationLink,
204    #[error("discussions blob encoding error: {0}")]
205    Encoding(String),
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn sample_principal() -> Principal {
213        Principal::new("Alice", "alice@example.com")
214    }
215
216    fn sample_discussion() -> Discussion {
217        Discussion {
218            id: "disc-1".into(),
219            anchor: SymbolAnchor::new("src/lib.rs", "foo"),
220            opened_against_state: StateId::from_bytes([7; 32]),
221            opened_at: 1_700_000_000,
222            thread_ref: None,
223            turns: vec![DiscussionTurn {
224                author: sample_principal(),
225                body: "why does this branch exist?".into(),
226                posted_at: 1_700_000_000,
227                references: Vec::new(),
228            }],
229            resolution: DiscussionResolution::Open,
230            body_changed_since_open: false,
231            anchor_ambiguous: false,
232            orphaned: false,
233            visibility: VisibilityTier::default(),
234            resolved_annotation_id: None,
235        }
236    }
237
238    #[test]
239    fn open_discussion_validates() {
240        sample_discussion().validate().unwrap();
241    }
242
243    #[test]
244    fn dismissed_with_empty_reason_rejected() {
245        let mut d = sample_discussion();
246        d.resolution = DiscussionResolution::Dismissed {
247            reason: "  ".into(),
248        };
249        assert!(matches!(
250            d.validate(),
251            Err(DiscussionError::EmptyDismissReason)
252        ));
253    }
254
255    #[test]
256    fn resolved_into_annotation_requires_link() {
257        let mut d = sample_discussion();
258        d.resolution = DiscussionResolution::ResolvedIntoAnnotation {
259            annotation_id: "ann-7".into(),
260        };
261        d.resolved_annotation_id = None;
262        assert!(matches!(
263            d.validate(),
264            Err(DiscussionError::MissingAnnotationLink)
265        ));
266        d.resolved_annotation_id = Some("ann-7".into());
267        d.validate().unwrap();
268    }
269
270    #[test]
271    fn empty_turn_body_rejected() {
272        let mut d = sample_discussion();
273        d.turns[0].body = "   ".into();
274        assert!(matches!(d.validate(), Err(DiscussionError::EmptyTurnBody)));
275    }
276
277    #[test]
278    fn blob_roundtrip() {
279        let blob = DiscussionsBlob::new(vec![sample_discussion()]);
280        let bytes = blob.encode().unwrap();
281        let decoded = DiscussionsBlob::decode(&bytes).unwrap();
282        assert_eq!(blob, decoded);
283    }
284
285    #[test]
286    fn body_changed_marker_round_trips() {
287        let mut d = sample_discussion();
288        d.body_changed_since_open = true;
289        let blob = DiscussionsBlob::new(vec![d]);
290        let bytes = blob.encode().unwrap();
291        let decoded = DiscussionsBlob::decode(&bytes).unwrap();
292        assert!(decoded.discussions[0].body_changed_since_open);
293    }
294}