Skip to main content

objects/store/
codec.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Object body codecs for loose-object backends.
3
4use heddle_format::compression::{CompressionConfig, compress, decompress, is_compressed};
5
6use crate::{
7    object::{Action, ActionId, ContentHash, State, Tree, TreeDecodeError},
8    store::{HeddleError, Result},
9};
10
11pub fn encode_blob_content(content: &[u8], config: &CompressionConfig) -> Result<Vec<u8>> {
12    Ok(compress(content, config)?.unwrap_or_else(|| content.to_vec()))
13}
14
15pub fn decode_blob_content(data: &[u8]) -> Result<Vec<u8>> {
16    if is_compressed(data) {
17        Ok(decompress(data)?)
18    } else {
19        Ok(data.to_vec())
20    }
21}
22
23pub fn encode_tree(tree: &Tree, config: &CompressionConfig) -> Result<(ContentHash, Vec<u8>)> {
24    let hash = tree.hash();
25    let serialized = rmp_serde::to_vec(tree)?;
26    let data = compress(&serialized, config)?.unwrap_or(serialized);
27    Ok((hash, data))
28}
29
30pub fn decode_tree(data: &[u8]) -> Result<Tree> {
31    let decoded = decode_tree_body(data)?;
32    decode_tree_serialized(&decoded)
33}
34
35pub fn decode_tree_serialized(data: &[u8]) -> Result<Tree> {
36    Tree::decode_current_msgpack(data).map_err(|error| match error {
37        TreeDecodeError::Decode(error) => HeddleError::from(error),
38        TreeDecodeError::Invalid(error) => HeddleError::InvalidTreeEntry(error),
39    })
40}
41
42/// Return the serialized tree body stored in a loose object, decompressing
43/// only the loose-object wrapper. Migration code uses this to decode older
44/// tree schemas without teaching the current [`Tree`] reader to accept them.
45pub fn decode_tree_body(data: &[u8]) -> Result<Vec<u8>> {
46    decode_body(data)
47}
48
49pub fn encode_state(state: &State, config: &CompressionConfig) -> Result<Vec<u8>> {
50    let serialized = rmp_serde::to_vec(state)?;
51    Ok(compress(&serialized, config)?.unwrap_or(serialized))
52}
53
54pub fn decode_state(data: &[u8]) -> Result<State> {
55    let decoded = decode_body(data)?;
56    Ok(rmp_serde::from_slice(&decoded)?)
57}
58
59pub fn encode_action(
60    action: &mut Action,
61    config: &CompressionConfig,
62) -> Result<(ActionId, Vec<u8>)> {
63    let id = action.id();
64    let serialized = rmp_serde::to_vec(action)?;
65    let data = compress(&serialized, config)?.unwrap_or(serialized);
66    Ok((id, data))
67}
68
69pub fn decode_action(data: &[u8]) -> Result<Action> {
70    let decoded = decode_body(data)?;
71    Ok(rmp_serde::from_slice(&decoded)?)
72}
73
74fn decode_body(data: &[u8]) -> Result<Vec<u8>> {
75    if is_compressed(data) {
76        Ok(decompress(data)?)
77    } else {
78        Ok(data.to_vec())
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::object::{Attribution, ChangeId, Operation, Principal, TreeEntry};
86
87    #[test]
88    fn encode_decode_blob_content_matches_old_recipe() {
89        let content = b"codec blob content ".repeat(64);
90        for config in compression_configs() {
91            let expected = old_encode_raw(&content, &config).unwrap();
92            let encoded = encode_blob_content(&content, &config).unwrap();
93            assert_eq!(encoded, expected);
94            assert_eq!(decode_blob_content(&encoded).unwrap(), content);
95        }
96    }
97
98    #[test]
99    fn encode_decode_tree_matches_old_recipe() {
100        let blob_hash = ContentHash::compute(b"codec-tree-blob");
101        let tree = Tree::from_entries(vec![TreeEntry::file("file.txt", blob_hash, false).unwrap()]);
102        for config in compression_configs() {
103            let serialized = rmp_serde::to_vec(&tree).unwrap();
104            let expected = old_encode_raw(&serialized, &config).unwrap();
105            let (hash, encoded) = encode_tree(&tree, &config).unwrap();
106            assert_eq!(hash, tree.hash());
107            assert_eq!(encoded, expected);
108            assert_eq!(decode_tree(&encoded).unwrap(), tree);
109        }
110    }
111
112    #[test]
113    fn encode_decode_state_matches_old_recipe() {
114        let attribution = sample_attribution();
115        let state = State::new(ContentHash::compute(b"codec-tree"), vec![], attribution)
116            .with_intent("codec state");
117        for config in compression_configs() {
118            let serialized = rmp_serde::to_vec(&state).unwrap();
119            let expected = old_encode_raw(&serialized, &config).unwrap();
120            let encoded = encode_state(&state, &config).unwrap();
121            assert_eq!(encoded, expected);
122            assert_eq!(decode_state(&encoded).unwrap(), state);
123        }
124    }
125
126    #[test]
127    fn encode_decode_action_matches_old_recipe() {
128        let attribution = sample_attribution();
129        for config in compression_configs() {
130            let mut action = Action::new(
131                None,
132                ChangeId::generate(),
133                Operation::Snapshot,
134                "codec action",
135                attribution.clone(),
136            );
137            let id = action.id();
138            let serialized = rmp_serde::to_vec(&action).unwrap();
139            let expected = old_encode_raw(&serialized, &config).unwrap();
140
141            let (encoded_id, encoded) = encode_action(&mut action, &config).unwrap();
142            assert_eq!(encoded_id, id);
143            assert_eq!(encoded, expected);
144
145            let decoded = decode_action(&encoded).unwrap();
146            assert_eq!(decoded.compute_id(), id);
147            assert_eq!(decoded.from_state, action.from_state);
148            assert_eq!(decoded.to_state, action.to_state);
149            assert_eq!(decoded.operation, action.operation);
150            assert_eq!(decoded.description, action.description);
151            assert_eq!(decoded.semantic_changes, action.semantic_changes);
152            assert_eq!(decoded.attribution, action.attribution);
153            assert_eq!(decoded.timestamp, action.timestamp);
154        }
155    }
156
157    fn old_encode_raw(data: &[u8], config: &CompressionConfig) -> Result<Vec<u8>> {
158        Ok(compress(data, config)?.unwrap_or_else(|| data.to_vec()))
159    }
160
161    fn compression_configs() -> Vec<CompressionConfig> {
162        #[cfg(feature = "zstd")]
163        {
164            vec![
165                CompressionConfig::default(),
166                CompressionConfig::disabled(),
167                CompressionConfig {
168                    enabled: true,
169                    level: 9,
170                    min_size: 0,
171                    max_delta_size: CompressionConfig::default().max_delta_size,
172                },
173            ]
174        }
175        #[cfg(not(feature = "zstd"))]
176        {
177            vec![CompressionConfig::default(), CompressionConfig::disabled()]
178        }
179    }
180
181    fn sample_attribution() -> Attribution {
182        Attribution::human(Principal::new("Codec Test", "codec@example.com"))
183    }
184}