heddle_object_model/object/collaboration/
requirements.rs1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use super::{
8 AnnotationTag, CollaborationAnchor, CollaborationCodecError, CollaborationRevision,
9 CollaborationScope, CollaborationSourceAnchor,
10};
11use crate::object::{
12 ChangeId, ContentHash, StateId,
13 source_target::SourceTargetBinding,
14 thread_replication::metadata::{Review, ReviewCoverage},
15};
16
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)]
22pub enum AuthoredSourceRequirement {
23 Thread { scope: CollaborationScope },
25 Revision {
27 scope: CollaborationScope,
28 revision: CollaborationRevision,
29 path: String,
30 },
31 Change {
34 scope: CollaborationScope,
35 change_id: ChangeId,
36 },
37 WholeSource {
41 scope: CollaborationScope,
42 state_id: StateId,
43 },
44}
45
46impl AuthoredSourceRequirement {
47 pub fn id(&self) -> Result<ContentHash, CollaborationCodecError> {
49 let bytes = rmp_serde::to_vec_named(self)
50 .map_err(|error| CollaborationCodecError::Encoding(error.to_string()))?;
51 Ok(ContentHash::compute_typed(
52 "heddle-authored-source-requirement-v1",
53 &bytes,
54 ))
55 }
56}
57
58pub fn authored_source_requirements(
68 scope: &CollaborationScope,
69 anchor: &CollaborationAnchor,
70 tags: &[AnnotationTag],
71) -> Result<Vec<AuthoredSourceRequirement>, CollaborationCodecError> {
72 super::operation::validate_anchor(anchor)?;
73 super::validate_annotation_tags(tags)?;
74 let mut requirements = BTreeMap::new();
75 let mut insert = |value: AuthoredSourceRequirement| {
76 requirements.insert(value.id()?, value);
77 Ok::<_, CollaborationCodecError>(())
78 };
79 match anchor {
80 CollaborationAnchor::Repository => {}
81 CollaborationAnchor::Source { source } => source_requirements(scope, source, &mut insert)?,
82 CollaborationAnchor::State { state_id } => insert(AuthoredSourceRequirement::Revision {
83 scope: scope.clone(),
84 revision: CollaborationRevision::State {
85 state_id: *state_id,
86 },
87 path: String::new(),
88 })?,
89 CollaborationAnchor::Path { state_id, path }
90 | CollaborationAnchor::Symbol { state_id, path, .. } => {
91 insert(AuthoredSourceRequirement::Revision {
92 scope: scope.clone(),
93 revision: CollaborationRevision::State {
94 state_id: *state_id,
95 },
96 path: path.clone(),
97 })?;
98 }
99 CollaborationAnchor::Change { change_id } => insert(AuthoredSourceRequirement::Change {
100 scope: scope.clone(),
101 change_id: *change_id,
102 })?,
103 }
104 for tag in tags {
105 if let Some(target) = tag.source() {
106 source_requirements(&target.scope, &target.source, &mut insert)?;
107 }
108 }
109 Ok(requirements.into_values().collect())
110}
111
112pub fn authored_review_requirements(
116 source_scope: &CollaborationScope,
117 target_scope: &CollaborationScope,
118 review: &Review,
119) -> Result<Vec<AuthoredSourceRequirement>, CollaborationCodecError> {
120 if source_scope.spool != target_scope.spool
121 || source_scope.thread.is_none()
122 || target_scope.thread.is_none()
123 {
124 return Err(CollaborationCodecError::Invalid(
125 "review source and target require exact Threads in one Spool".into(),
126 ));
127 }
128 let mut requirements = BTreeMap::new();
129 let mut insert = |value: AuthoredSourceRequirement| {
130 requirements.insert(value.id()?, value);
131 Ok::<_, CollaborationCodecError>(())
132 };
133 insert(AuthoredSourceRequirement::Revision {
134 scope: source_scope.clone(),
135 revision: CollaborationRevision::State {
136 state_id: review.source,
137 },
138 path: String::new(),
139 })?;
140 insert(AuthoredSourceRequirement::Revision {
141 scope: target_scope.clone(),
142 revision: CollaborationRevision::State {
143 state_id: review.target,
144 },
145 path: String::new(),
146 })?;
147 match &review.coverage {
148 Some(ReviewCoverage::WholeSource) => insert(AuthoredSourceRequirement::WholeSource {
149 scope: source_scope.clone(),
150 state_id: review.source,
151 })?,
152 Some(ReviewCoverage::Symbols(anchors)) => {
153 if anchors.is_empty() || anchors.len() > 128 {
154 return Err(CollaborationCodecError::Invalid(
155 "review symbol coverage exceeds bounds".into(),
156 ));
157 }
158 for anchor in anchors {
159 if anchor.file.is_empty()
160 || anchor.file.len() > 4096
161 || anchor.file.starts_with('/')
162 || anchor
163 .file
164 .split('/')
165 .any(|part| part.is_empty() || part == "." || part == "..")
166 {
167 return Err(CollaborationCodecError::Invalid(
168 "review symbol path must be relative and canonical".into(),
169 ));
170 }
171 insert(AuthoredSourceRequirement::Revision {
172 scope: source_scope.clone(),
173 revision: CollaborationRevision::State {
174 state_id: review.source,
175 },
176 path: anchor.file.clone(),
177 })?;
178 }
179 }
180 None => {}
181 }
182 Ok(requirements.into_values().collect())
183}
184
185fn source_requirements(
186 scope: &CollaborationScope,
187 source: &CollaborationSourceAnchor,
188 insert: &mut impl FnMut(AuthoredSourceRequirement) -> Result<(), CollaborationCodecError>,
189) -> Result<(), CollaborationCodecError> {
190 insert(AuthoredSourceRequirement::Revision {
191 scope: scope.clone(),
192 revision: source.revision.clone(),
193 path: source.path.clone(),
194 })?;
195 match source.target.as_ref().map(|target| &target.binding) {
196 None | Some(SourceTargetBinding::ViewedThread) => Ok(()),
197 Some(SourceTargetBinding::NamedThread { scope }) => {
198 insert(AuthoredSourceRequirement::Thread {
199 scope: scope.clone(),
200 })
201 }
202 Some(SourceTargetBinding::PinnedRevision { scope, revision }) => {
203 insert(AuthoredSourceRequirement::Revision {
204 scope: scope.clone(),
205 revision: revision.clone(),
206 path: String::new(),
207 })
208 }
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use crate::object::thread_replication::metadata::{ReviewKind, ReviewSymbolAnchor};
216
217 fn review(coverage: Option<ReviewCoverage>) -> Review {
218 Review {
219 id: uuid::Uuid::from_u128(1),
220 source: StateId::from_bytes([11; 32]),
221 target: StateId::from_bytes([12; 32]),
222 policy_version: ContentHash::from_bytes([13; 32]),
223 kind: ReviewKind::Read,
224 explanation: String::new(),
225 revokes: None,
226 expires_at_unix_seconds: None,
227 coverage,
228 }
229 }
230
231 #[test]
232 fn complete_read_requires_whole_source_in_addition_to_both_exact_revisions() {
233 let source = CollaborationScope {
234 spool: uuid::Uuid::from_u128(1),
235 thread: Some(ContentHash::from_bytes([1; 32])),
236 };
237 let target = CollaborationScope {
238 thread: Some(ContentHash::from_bytes([2; 32])),
239 ..source.clone()
240 };
241 let value = review(Some(ReviewCoverage::WholeSource));
242 let requirements = authored_review_requirements(&source, &target, &value)
243 .expect("exact review requirements");
244 assert_eq!(requirements.len(), 3);
245 assert!(
246 requirements.contains(&AuthoredSourceRequirement::WholeSource {
247 scope: source.clone(),
248 state_id: value.source,
249 })
250 );
251 assert!(requirements.contains(&AuthoredSourceRequirement::Revision {
252 scope: target.clone(),
253 revision: CollaborationRevision::State {
254 state_id: value.target
255 },
256 path: String::new(),
257 }));
258 assert_ne!(
259 AuthoredSourceRequirement::WholeSource {
260 scope: source.clone(),
261 state_id: value.source
262 }
263 .id()
264 .expect("whole source key"),
265 AuthoredSourceRequirement::Revision {
266 scope: source,
267 revision: CollaborationRevision::State {
268 state_id: value.source
269 },
270 path: String::new(),
271 }
272 .id()
273 .expect("state key"),
274 );
275 }
276
277 #[test]
278 fn symbol_read_requires_each_original_path_and_rejects_unscoped_reviews() {
279 let scope = CollaborationScope {
280 spool: uuid::Uuid::from_u128(1),
281 thread: Some(ContentHash::from_bytes([1; 32])),
282 };
283 let value = review(Some(ReviewCoverage::Symbols(vec![
284 ReviewSymbolAnchor {
285 file: "src/main.rs".into(),
286 symbol: "run".into(),
287 },
288 ReviewSymbolAnchor {
289 file: "src/main.rs".into(),
290 symbol: "main".into(),
291 },
292 ReviewSymbolAnchor {
293 file: "src/lib.rs".into(),
294 symbol: "parse".into(),
295 },
296 ])));
297 let requirements = authored_review_requirements(&scope, &scope, &value)
298 .expect("exact symbol requirements");
299 assert_eq!(
300 requirements.len(),
301 4,
302 "two revisions and two distinct paths"
303 );
304 assert!(requirements.contains(&AuthoredSourceRequirement::Revision {
305 scope: scope.clone(),
306 revision: CollaborationRevision::State {
307 state_id: value.source
308 },
309 path: "src/main.rs".into(),
310 }));
311 assert!(
312 authored_review_requirements(
313 &CollaborationScope {
314 thread: None,
315 ..scope.clone()
316 },
317 &scope,
318 &value
319 )
320 .is_err()
321 );
322 assert!(
323 authored_review_requirements(
324 &scope,
325 &CollaborationScope {
326 spool: uuid::Uuid::from_u128(2),
327 ..scope.clone()
328 },
329 &value
330 )
331 .is_err()
332 );
333 }
334}