Skip to main content

repo/
state_attachments.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use objects::{
4    object::{StateAttachment, StateAttachmentBody, StateAttachmentId, StateId},
5    store::ObjectStore,
6};
7use oplog::OpLogBackend;
8use refs::RefBackend;
9
10use crate::{HeddleError, Repository, Result};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum StateAttachmentKind {
14    Context,
15    RiskSignals,
16    ReviewSignatures,
17    Discussions,
18    StructuredConflicts,
19    SemanticIndex,
20    Signature,
21}
22
23impl StateAttachmentKind {
24    fn of(body: &StateAttachmentBody) -> Self {
25        match body {
26            StateAttachmentBody::Context(_) => Self::Context,
27            StateAttachmentBody::RiskSignals(_) => Self::RiskSignals,
28            StateAttachmentBody::ReviewSignatures(_) => Self::ReviewSignatures,
29            StateAttachmentBody::Discussions(_) => Self::Discussions,
30            StateAttachmentBody::StructuredConflicts(_) => Self::StructuredConflicts,
31            StateAttachmentBody::SemanticIndex(_) => Self::SemanticIndex,
32            StateAttachmentBody::Signature(_) => Self::Signature,
33        }
34    }
35}
36
37impl<R, O, S> Repository<R, O, S>
38where
39    R: RefBackend,
40    O: OpLogBackend,
41    S: ObjectStore,
42{
43    pub fn put_state_attachment(&self, attachment: &StateAttachment) -> Result<StateAttachmentId> {
44        if !self.store().has_state(&attachment.state_id)? {
45            return Err(HeddleError::StateNotFound(attachment.state_id));
46        }
47
48        if let Some(prior_id) = attachment.supersedes {
49            let prior = self
50                .get_state_attachment(&attachment.state_id, &prior_id)?
51                .ok_or_else(|| HeddleError::NotFound(format!("state attachment {prior_id}")))?;
52            if StateAttachmentKind::of(&prior.body) != StateAttachmentKind::of(&attachment.body) {
53                return Err(HeddleError::InvalidObject(
54                    "state attachment can only supersede the same attachment kind".to_string(),
55                ));
56            }
57        }
58
59        let id = attachment.id();
60        if let Some(existing) = self
61            .store()
62            .get_state_attachment(&attachment.state_id, &id)?
63        {
64            return match self.get_state_attachment(&attachment.state_id, &id)? {
65                Some(_) if existing == *attachment => Ok(id),
66                _ => Err(HeddleError::InvalidObject(
67                    "state attachment address collision".to_string(),
68                )),
69            };
70        }
71        self.store().put_state_attachment(attachment)
72    }
73
74    pub fn get_state_attachment(
75        &self,
76        state_id: &StateId,
77        attachment_id: &StateAttachmentId,
78    ) -> Result<Option<StateAttachment>> {
79        self.store().get_state_attachment(state_id, attachment_id)
80    }
81
82    pub fn list_state_attachments(&self, state_id: &StateId) -> Result<Vec<StateAttachment>> {
83        let mut attachments = self.store().list_state_attachments(state_id)?;
84        attachments.sort_by_key(|attachment| (attachment.created_at, attachment.id()));
85        Ok(attachments)
86    }
87
88    pub fn latest_state_attachment(
89        &self,
90        state_id: &StateId,
91        kind: StateAttachmentKind,
92    ) -> Result<Option<StateAttachment>> {
93        let attachments: Vec<_> = self
94            .list_state_attachments(state_id)?
95            .into_iter()
96            .filter(|attachment| StateAttachmentKind::of(&attachment.body) == kind)
97            .collect();
98        let superseded: std::collections::HashSet<_> = attachments
99            .iter()
100            .filter_map(|attachment| attachment.supersedes)
101            .collect();
102        Ok(attachments
103            .into_iter()
104            .filter(|attachment| !superseded.contains(&attachment.id()))
105            .max_by_key(StateAttachment::id))
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use chrono::{Duration, Utc};
112    use objects::object::{Attribution, ContentHash, Principal, StateAttachment};
113    use tempfile::TempDir;
114
115    use super::*;
116
117    fn attachment(state_id: StateId, body: StateAttachmentBody) -> StateAttachment {
118        StateAttachment {
119            state_id,
120            body,
121            attribution: Attribution::human(Principal::new("Test", "test@example.com")),
122            created_at: Utc::now(),
123            supersedes: None,
124        }
125    }
126
127    #[test]
128    fn attachment_history_preserves_state_bytes() {
129        let temp = TempDir::new().unwrap();
130        let repo = Repository::init_default(temp.path()).unwrap();
131        let state_id = repo.head().unwrap().unwrap();
132        let before = repo.store().get_state(&state_id).unwrap().unwrap();
133
134        let first = attachment(
135            state_id,
136            StateAttachmentBody::Context(ContentHash::compute(b"first")),
137        );
138        let first_id = repo.put_state_attachment(&first).unwrap();
139        let mut second = attachment(
140            state_id,
141            StateAttachmentBody::Context(ContentHash::compute(b"second")),
142        );
143        second.created_at = first.created_at + Duration::seconds(1);
144        second.supersedes = Some(first_id);
145        repo.put_state_attachment(&second).unwrap();
146
147        let latest = repo
148            .latest_state_attachment(&state_id, StateAttachmentKind::Context)
149            .unwrap()
150            .unwrap();
151        assert_eq!(latest, second);
152        assert_eq!(repo.list_state_attachments(&state_id).unwrap().len(), 2);
153        assert_eq!(repo.store().get_state(&state_id).unwrap().unwrap(), before);
154    }
155
156    #[test]
157    fn latest_follows_supersession_heads_not_wall_clock() {
158        let temp = TempDir::new().unwrap();
159        let repo = Repository::init_default(temp.path()).unwrap();
160        let state_id = repo.head().unwrap().unwrap();
161        let root = attachment(
162            state_id,
163            StateAttachmentBody::Context(ContentHash::compute(b"root")),
164        );
165        let root_id = repo.put_state_attachment(&root).unwrap();
166
167        let mut older_clock = attachment(
168            state_id,
169            StateAttachmentBody::Context(ContentHash::compute(b"older-clock")),
170        );
171        older_clock.created_at = root.created_at - Duration::seconds(10);
172        older_clock.supersedes = Some(root_id);
173        repo.put_state_attachment(&older_clock).unwrap();
174
175        let mut fork = attachment(
176            state_id,
177            StateAttachmentBody::Context(ContentHash::compute(b"fork")),
178        );
179        fork.created_at = root.created_at + Duration::seconds(10);
180        fork.supersedes = Some(root_id);
181        repo.put_state_attachment(&fork).unwrap();
182
183        let expected = [older_clock.clone(), fork.clone()]
184            .into_iter()
185            .max_by_key(StateAttachment::id)
186            .unwrap();
187        assert_eq!(
188            repo.latest_state_attachment(&state_id, StateAttachmentKind::Context)
189                .unwrap()
190                .unwrap(),
191            expected
192        );
193    }
194}