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)]
17#[serde(deny_unknown_fields)]
18pub struct CollaborationSourceAnchor {
19 pub revision: CollaborationRevision,
20 pub path: String,
21 pub symbol_id: String,
22 pub start_line: Option<u32>,
23 pub end_line: Option<u32>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub target: Option<crate::object::source_target::SourceTargetReference>,
28}
29impl CollaborationSourceAnchor {
30 pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
31 if let Some(target) = &self.target {
32 target
33 .validate()
34 .map_err(|error| CollaborationCodecError::Invalid(error.to_string()))?;
35 }
36 if let CollaborationRevision::GitCommit { oid } = &self.revision
37 && (!matches!(oid.len(), 40 | 64)
38 || !oid
39 .bytes()
40 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
41 {
42 return Err(CollaborationCodecError::Invalid(
43 "source anchor requires an exact lowercase Git object ID".into(),
44 ));
45 }
46 if self.path.len() > 4096
47 || self.symbol_id.len() > 4096
48 || self.path.chars().any(char::is_control)
49 || self.symbol_id.chars().any(char::is_control)
50 || (self.path.is_empty()
51 && (!self.symbol_id.is_empty()
52 || self.start_line.is_some()
53 || self.end_line.is_some()))
54 || self.start_line == Some(0)
55 || self.end_line == Some(0)
56 || self
57 .end_line
58 .is_some_and(|end| self.start_line.is_none_or(|start| end < start))
59 {
60 return Err(CollaborationCodecError::Invalid(
61 "invalid source anchor path or line span".into(),
62 ));
63 }
64 Ok(())
65 }
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71 use crate::object::{
72 Attribution, CollaborationAnchor, CollaborationIdempotencyKey,
73 CollaborationOperationBodyV1, CollaborationOperationEnvelope, DiscussionRecordId,
74 DiscussionTurnV1, Principal, VisibilityTier,
75 };
76 #[test]
77 fn exact_source_span_round_trips_and_invalid_ranges_fail() {
78 let source = CollaborationSourceAnchor {
79 revision: CollaborationRevision::GitCommit {
80 oid: "a".repeat(40),
81 },
82 path: "src/main.rs".into(),
83 symbol_id: "run".into(),
84 start_line: Some(12),
85 end_line: Some(18),
86 target: None,
87 };
88 let record = CollaborationOperationEnvelope::new(
89 DiscussionRecordId::generate(),
90 vec![],
91 CollaborationIdempotencyKey::new("source-review").expect("ID"),
92 Attribution::human(Principal::new("Reviewer", "")),
93 100,
94 CollaborationOperationBodyV1::Open {
95 blocking: true,
96 title: "Review exact lines".into(),
97 anchor: CollaborationAnchor::Source {
98 source: source.clone(),
99 },
100 visibility: VisibilityTier::Public,
101 turn: DiscussionTurnV1::new("Explain this change").expect("turn"),
102 thread_ref: None,
103 },
104 )
105 .expect("record");
106 let decoded = CollaborationOperationEnvelope::decode(&record.encode().expect("canonical"))
107 .expect("decode");
108 assert_eq!(decoded.operation, record, "all source fields survive sync");
109 let mut invalid = source;
110 invalid.end_line = Some(11);
111 assert!(
112 invalid.validate().is_err(),
113 "inverted span must be rejected"
114 );
115 invalid.end_line = Some(18);
116 invalid.path.clear();
117 assert!(
118 invalid.validate().is_err(),
119 "line numbers require an exact file path"
120 );
121 }
122
123 #[test]
124 fn signed_path_kind_extension_is_refused_not_dropped() {
125 #[derive(Serialize)]
126 struct Extended<'a> {
127 revision: &'a CollaborationRevision,
128 path: &'a str,
129 symbol_id: &'a str,
130 start_line: Option<u32>,
131 end_line: Option<u32>,
132 path_kind: &'a str,
133 }
134 let source = CollaborationSourceAnchor {
135 revision: CollaborationRevision::GitCommit {
136 oid: "a".repeat(40),
137 },
138 path: "src/main.rs".into(),
139 symbol_id: String::new(),
140 start_line: None,
141 end_line: None,
142 target: None,
143 };
144 let plain = rmp_serde::to_vec_named(&source).expect("plain map");
145 assert_eq!(
146 rmp_serde::from_slice::<CollaborationSourceAnchor>(&plain).expect("plain decodes"),
147 source
148 );
149 let extended = rmp_serde::to_vec_named(&Extended {
150 revision: &source.revision,
151 path: &source.path,
152 symbol_id: &source.symbol_id,
153 start_line: None,
154 end_line: None,
155 path_kind: "file",
156 })
157 .expect("extended map");
158 let error = rmp_serde::from_slice::<CollaborationSourceAnchor>(&extended)
159 .expect_err("a signed path_kind must not be silently discarded");
160 assert!(error.to_string().contains("path_kind"), "{error}");
161 }
162}