heddle_object_model/object/collaboration/
source.rs1use serde::{Deserialize, Serialize};
3
4use super::CollaborationCodecError;
5use crate::object::StateId;
6
7#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case", tag = "kind")]
9pub enum CollaborationRevision {
10 State { state_id: StateId },
11 GitCommit { oid: String },
12}
13#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
14pub struct CollaborationSourceAnchor {
15 pub revision: CollaborationRevision,
16 pub path: String,
17 pub symbol_id: String,
18 pub start_line: Option<u32>,
19 pub end_line: Option<u32>,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub target: Option<crate::object::source_target::SourceTargetReference>,
24}
25impl CollaborationSourceAnchor {
26 pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
27 if let Some(target) = &self.target {
28 target
29 .validate()
30 .map_err(|error| CollaborationCodecError::Invalid(error.to_string()))?;
31 }
32 if let CollaborationRevision::GitCommit { oid } = &self.revision
33 && (!matches!(oid.len(), 40 | 64)
34 || !oid
35 .bytes()
36 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
37 {
38 return Err(CollaborationCodecError::Invalid(
39 "source anchor requires an exact lowercase Git object ID".into(),
40 ));
41 }
42 if self.path.len() > 4096
43 || self.symbol_id.len() > 4096
44 || self.path.chars().any(char::is_control)
45 || self.symbol_id.chars().any(char::is_control)
46 || (self.path.is_empty()
47 && (!self.symbol_id.is_empty()
48 || self.start_line.is_some()
49 || self.end_line.is_some()))
50 || self.start_line == Some(0)
51 || self.end_line == Some(0)
52 || self
53 .end_line
54 .is_some_and(|end| self.start_line.is_none_or(|start| end < start))
55 {
56 return Err(CollaborationCodecError::Invalid(
57 "invalid source anchor path or line span".into(),
58 ));
59 }
60 Ok(())
61 }
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67 use crate::object::{
68 Attribution, CollaborationAnchor, CollaborationIdempotencyKey,
69 CollaborationOperationBodyV1, CollaborationOperationEnvelope, DiscussionRecordId,
70 DiscussionTurnV1, Principal, VisibilityTier,
71 };
72 #[test]
73 fn exact_source_span_round_trips_and_invalid_ranges_fail() {
74 let source = CollaborationSourceAnchor {
75 revision: CollaborationRevision::GitCommit {
76 oid: "a".repeat(40),
77 },
78 path: "src/main.rs".into(),
79 symbol_id: "run".into(),
80 start_line: Some(12),
81 end_line: Some(18),
82 target: None,
83 };
84 let record = CollaborationOperationEnvelope::new(
85 DiscussionRecordId::generate(),
86 vec![],
87 CollaborationIdempotencyKey::new("source-review").expect("ID"),
88 Attribution::human(Principal::new("Reviewer", "")),
89 100,
90 CollaborationOperationBodyV1::Open {
91 blocking: true,
92 title: "Review exact lines".into(),
93 anchor: CollaborationAnchor::Source {
94 source: source.clone(),
95 },
96 visibility: VisibilityTier::Public,
97 turn: DiscussionTurnV1::new("Explain this change").expect("turn"),
98 thread_ref: None,
99 },
100 )
101 .expect("record");
102 let decoded = CollaborationOperationEnvelope::decode(&record.encode().expect("canonical"))
103 .expect("decode");
104 assert_eq!(decoded.operation, record, "all source fields survive sync");
105 let mut invalid = source;
106 invalid.end_line = Some(11);
107 assert!(
108 invalid.validate().is_err(),
109 "inverted span must be rejected"
110 );
111 invalid.end_line = Some(18);
112 invalid.path.clear();
113 assert!(
114 invalid.validate().is_err(),
115 "line numbers require an exact file path"
116 );
117 }
118}