heddle_object_model/object/collaboration/
context.rs1use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use super::{
7 CollaborationAnchor, CollaborationCodecError, CollaborationMetadata, DiscussionRecordId,
8 canonical_body::CanonicalBody,
9};
10use crate::object::{AnnotationKind, ContentHash, StateId};
11
12pub const CONTEXT_FORMAT: &str = "heddle-context-revision-v2";
13
14#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct ContextProvenance {
20 pub revision_id: String,
21 pub kind: AnnotationKind,
22 pub attribution: String,
23 pub source_hash: Option<ContentHash>,
24 pub created_at_state: Option<StateId>,
25}
26
27#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct ContextRevision {
30 pub version: u16,
31 pub id: Uuid,
32 pub parents: Vec<ContentHash>,
34 pub metadata: CollaborationMetadata,
35 pub anchor: CollaborationAnchor,
36 pub content: String,
37 pub tags: Vec<super::AnnotationTag>,
38 pub supersedes: Option<Uuid>,
39 pub extracted_from: Option<DiscussionRecordId>,
40 pub occurred_at_ms: i64,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub provenance: Option<ContextProvenance>,
45 #[serde(skip)]
48 pub canonical_body: CanonicalBody,
49}
50impl ContextRevision {
51 fn encode_fields(&self) -> Result<Vec<u8>, CollaborationCodecError> {
52 self.metadata.validate()?;
53 super::validate_annotation_tags(&self.tags)?;
54 super::operation::validate_anchor(&self.anchor)?;
55 if self.version != 2
56 || self.id.is_nil()
57 || self
58 .supersedes
59 .is_some_and(|id| id.is_nil() || id == self.id)
60 || self.content.trim().is_empty()
61 || self.content.len() > 256 * 1024
62 || self.parents.len() > 128
63 || self.parents.windows(2).any(|pair| pair[0] >= pair[1])
64 {
65 return Err(CollaborationCodecError::Invalid(
66 "invalid or unbounded context revision".into(),
67 ));
68 }
69 rmp_serde::to_vec_named(self).map_err(|e| CollaborationCodecError::Encoding(e.to_string()))
70 }
71 pub fn encode(&self) -> Result<Vec<u8>, CollaborationCodecError> {
72 self.canonical_body.clear();
73 let bytes = self.encode_fields()?;
74 self.canonical_body.store(bytes.clone());
75 Ok(bytes)
76 }
77 pub fn decode(bytes: &[u8]) -> Result<Self, CollaborationCodecError> {
78 if bytes.len() > 512 * 1024 {
79 return Err(CollaborationCodecError::Invalid(
80 "context revision exceeds record bound".into(),
81 ));
82 }
83 let record: Self = rmp_serde::from_slice(bytes)
84 .map_err(|e| CollaborationCodecError::Decoding(e.to_string()))?;
85 if record.encode()? != bytes {
86 return Err(CollaborationCodecError::Invalid(
87 "context revision is not canonical".into(),
88 ));
89 }
90 Ok(record)
91 }
92 pub fn id(&self) -> Result<ContentHash, CollaborationCodecError> {
93 if let Some(bytes) = self.canonical_body.cloned() {
94 CanonicalBody::debug_matches(&bytes, || self.encode_fields());
95 return Ok(ContentHash::compute_typed(CONTEXT_FORMAT, &bytes));
96 }
97 Ok(ContentHash::compute_typed(CONTEXT_FORMAT, &self.encode()?))
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use uuid::Uuid;
104
105 use super::*;
106 use crate::object::{
107 CollaborationActor, CollaborationAnchor, CollaborationMetadata, CollaborationScope,
108 ContentHash,
109 };
110
111 fn sample() -> ContextRevision {
112 ContextRevision {
113 version: 2,
114 id: Uuid::from_u128(9),
115 parents: Vec::new(),
116 metadata: CollaborationMetadata {
117 scope: CollaborationScope {
118 spool: Uuid::from_u128(1),
119 thread: None,
120 },
121 actor: CollaborationActor {
122 principal_id: Uuid::from_u128(2),
123 agent_id: None,
124 },
125 mentions: Vec::new(),
126 },
127 anchor: CollaborationAnchor::Repository,
128 content: "rationale".into(),
129 tags: Vec::new(),
130 supersedes: None,
131 extracted_from: None,
132 occurred_at_ms: 10,
133 provenance: None,
134 canonical_body: Default::default(),
135 }
136 }
137
138 #[test]
139 fn id_matches_reencode_of_canonical_body() {
140 let revision = sample();
141 let bytes = revision.encode().expect("canonical revision");
142 let old = ContentHash::compute_typed(CONTEXT_FORMAT, &bytes);
143 assert_eq!(revision.id().expect("fresh id"), old);
144 assert_eq!(revision.id().expect("cached id"), old);
145
146 let decoded = ContextRevision::decode(&bytes).expect("decode");
147 assert_eq!(decoded, revision);
148 assert_eq!(decoded.id().expect("decoded id"), old);
149 assert_eq!(
150 decoded.id().expect("decoded id"),
151 ContentHash::compute_typed(CONTEXT_FORMAT, &decoded.encode().expect("re-encode"))
152 );
153
154 let mut changed = revision.clone();
155 changed.content = "other rationale".into();
156 let changed_id = changed.id().expect("changed id");
157 assert_ne!(changed_id, old);
158 assert_eq!(
159 changed_id,
160 ContentHash::compute_typed(CONTEXT_FORMAT, &changed.encode().expect("changed"))
161 );
162 }
163}