use serde::{Deserialize, Serialize};
use crate::object::{
hash::ChangeId, state_attribution::Principal, state_review::SymbolAnchor,
visibility_tier::VisibilityTier,
};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiscussionsBlob {
pub format_version: u8,
pub discussions: Vec<Discussion>,
}
versioned_msgpack_blob! {
blob: DiscussionsBlob,
item: Discussion,
field: discussions,
error: DiscussionError,
codec_err: Encoding,
version: 1,
}
pub type DiscussionId = String;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Discussion {
pub id: DiscussionId,
pub anchor: SymbolAnchor,
pub opened_against_state: ChangeId,
pub opened_at: i64,
#[serde(default)]
pub thread_ref: Option<String>,
pub turns: Vec<DiscussionTurn>,
pub resolution: DiscussionResolution,
#[serde(default)]
pub body_changed_since_open: bool,
#[serde(default)]
pub orphaned: bool,
#[serde(default)]
pub visibility: VisibilityTier,
#[serde(default)]
pub resolved_annotation_id: Option<String>,
}
impl Discussion {
pub fn validate(&self) -> Result<(), DiscussionError> {
if self.id.is_empty() {
return Err(DiscussionError::EmptyId);
}
if self.anchor.file.is_empty() {
return Err(DiscussionError::EmptyAnchorFile);
}
if self.anchor.symbol.is_empty() {
return Err(DiscussionError::EmptyAnchorSymbol);
}
for turn in &self.turns {
turn.validate()?;
}
if let DiscussionResolution::Dismissed { reason } = &self.resolution
&& reason.trim().is_empty()
{
return Err(DiscussionError::EmptyDismissReason);
}
if matches!(
self.resolution,
DiscussionResolution::ResolvedIntoAnnotation { .. }
) && self.resolved_annotation_id.is_none()
{
return Err(DiscussionError::MissingAnnotationLink);
}
Ok(())
}
pub fn is_open(&self) -> bool {
matches!(self.resolution, DiscussionResolution::Open)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiscussionTurn {
pub author: Principal,
pub body: String,
pub posted_at: i64,
}
impl DiscussionTurn {
pub fn validate(&self) -> Result<(), DiscussionError> {
if self.body.trim().is_empty() {
return Err(DiscussionError::EmptyTurnBody);
}
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum DiscussionResolution {
#[default]
Open,
ResolvedIntoAnnotation { annotation_id: String },
ResolvedByEdit { state_id: ChangeId },
Dismissed { reason: String },
}
#[derive(Debug, thiserror::Error)]
pub enum DiscussionError {
#[error("unsupported discussions blob version {0}")]
UnsupportedVersion(u8),
#[error("discussion id must not be empty")]
EmptyId,
#[error("discussion anchor must reference a non-empty file")]
EmptyAnchorFile,
#[error("discussion anchor must reference a non-empty symbol")]
EmptyAnchorSymbol,
#[error("discussion turn body must not be empty")]
EmptyTurnBody,
#[error("dismissed discussion must include a non-empty reason")]
EmptyDismissReason,
#[error("resolved-into-annotation discussion must set resolved_annotation_id")]
MissingAnnotationLink,
#[error("discussions blob encoding error: {0}")]
Encoding(String),
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_principal() -> Principal {
Principal::new("Alice", "alice@example.com")
}
fn sample_discussion() -> Discussion {
Discussion {
id: "disc-1".into(),
anchor: SymbolAnchor::new("src/lib.rs", "foo"),
opened_against_state: ChangeId::from_bytes([7; 16]),
opened_at: 1_700_000_000,
thread_ref: None,
turns: vec![DiscussionTurn {
author: sample_principal(),
body: "why does this branch exist?".into(),
posted_at: 1_700_000_000,
}],
resolution: DiscussionResolution::Open,
body_changed_since_open: false,
orphaned: false,
visibility: VisibilityTier::default(),
resolved_annotation_id: None,
}
}
#[test]
fn open_discussion_validates() {
sample_discussion().validate().unwrap();
}
#[test]
fn dismissed_with_empty_reason_rejected() {
let mut d = sample_discussion();
d.resolution = DiscussionResolution::Dismissed {
reason: " ".into(),
};
assert!(matches!(
d.validate(),
Err(DiscussionError::EmptyDismissReason)
));
}
#[test]
fn resolved_into_annotation_requires_link() {
let mut d = sample_discussion();
d.resolution = DiscussionResolution::ResolvedIntoAnnotation {
annotation_id: "ann-7".into(),
};
d.resolved_annotation_id = None;
assert!(matches!(
d.validate(),
Err(DiscussionError::MissingAnnotationLink)
));
d.resolved_annotation_id = Some("ann-7".into());
d.validate().unwrap();
}
#[test]
fn empty_turn_body_rejected() {
let mut d = sample_discussion();
d.turns[0].body = " ".into();
assert!(matches!(d.validate(), Err(DiscussionError::EmptyTurnBody)));
}
#[test]
fn blob_roundtrip() {
let blob = DiscussionsBlob::new(vec![sample_discussion()]);
let bytes = blob.encode().unwrap();
let decoded = DiscussionsBlob::decode(&bytes).unwrap();
assert_eq!(blob, decoded);
}
#[test]
fn body_changed_marker_round_trips() {
let mut d = sample_discussion();
d.body_changed_since_open = true;
let blob = DiscussionsBlob::new(vec![d]);
let bytes = blob.encode().unwrap();
let decoded = DiscussionsBlob::decode(&bytes).unwrap();
assert!(decoded.discussions[0].body_changed_since_open);
}
}