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/// Unknown keys are refused, never dropped. In particular the signed optional
14/// `path_kind` extension (api `saved-source.md`) is not supported yet
15/// (heddle#1959); discarding it would misrepresent the signed source map.
16#[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    /// Original coordinates remain evidence. Without an explicit target this
25    /// is an exact location, never an implicitly tracking reference.
26    #[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                tags: Vec::new(),
96                blocking: true,
97                title: "Review exact lines".into(),
98                anchor: CollaborationAnchor::Source {
99                    source: source.clone(),
100                },
101                visibility: VisibilityTier::Public,
102                turn: DiscussionTurnV1::new("Explain this change").expect("turn"),
103                thread_ref: None,
104            },
105        )
106        .expect("record");
107        let decoded = CollaborationOperationEnvelope::decode(&record.encode().expect("canonical"))
108            .expect("decode");
109        assert_eq!(decoded.operation, record, "all source fields survive sync");
110        let mut invalid = source;
111        invalid.end_line = Some(11);
112        assert!(
113            invalid.validate().is_err(),
114            "inverted span must be rejected"
115        );
116        invalid.end_line = Some(18);
117        invalid.path.clear();
118        assert!(
119            invalid.validate().is_err(),
120            "line numbers require an exact file path"
121        );
122    }
123
124    #[test]
125    fn signed_path_kind_extension_is_refused_not_dropped() {
126        #[derive(Serialize)]
127        struct Extended<'a> {
128            revision: &'a CollaborationRevision,
129            path: &'a str,
130            symbol_id: &'a str,
131            start_line: Option<u32>,
132            end_line: Option<u32>,
133            path_kind: &'a str,
134        }
135        let source = CollaborationSourceAnchor {
136            revision: CollaborationRevision::GitCommit {
137                oid: "a".repeat(40),
138            },
139            path: "src/main.rs".into(),
140            symbol_id: String::new(),
141            start_line: None,
142            end_line: None,
143            target: None,
144        };
145        let plain = rmp_serde::to_vec_named(&source).expect("plain map");
146        assert_eq!(
147            rmp_serde::from_slice::<CollaborationSourceAnchor>(&plain).expect("plain decodes"),
148            source
149        );
150        let extended = rmp_serde::to_vec_named(&Extended {
151            revision: &source.revision,
152            path: &source.path,
153            symbol_id: &source.symbol_id,
154            start_line: None,
155            end_line: None,
156            path_kind: "file",
157        })
158        .expect("extended map");
159        let error = rmp_serde::from_slice::<CollaborationSourceAnchor>(&extended)
160            .expect_err("a signed path_kind must not be silently discarded");
161        assert!(error.to_string().contains("path_kind"), "{error}");
162    }
163}