Skip to main content

heddle_object_model/object/collaboration/
source.rs

1//! Exact source locations preserved across local and hosted collaboration.
2use 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    /// Original coordinates remain evidence. Without an explicit target this
21    /// is an exact location, never an implicitly tracking reference.
22    #[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}