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    let mut state: State = rmp_serde::from_slice(&decoded)?;
57    state.state_id = state.id();
58    Ok(state)
59}
60
61pub fn encode_action(
62    action: &mut Action,
63    config: &CompressionConfig,
64) -> Result<(ActionId, Vec<u8>)> {
65    let id = action.id();
66    let serialized = rmp_serde::to_vec(action)?;
67    let data = compress(&serialized, config)?.unwrap_or(serialized);
68    Ok((id, data))
69}
70
71pub fn decode_action(data: &[u8]) -> Result<Action> {
72    let decoded = decode_body(data)?;
73    Ok(rmp_serde::from_slice(&decoded)?)
74}
75
76fn decode_body(data: &[u8]) -> Result<Vec<u8>> {
77    if is_compressed(data) {
78        Ok(decompress(data)?)
79    } else {
80        Ok(data.to_vec())
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::object::{Attribution, Operation, Principal, StateId, TreeEntry};
88
89    #[test]
90    fn encode_decode_blob_content_matches_old_recipe() {
91        let content = b"codec blob content ".repeat(64);
92        for config in compression_configs() {
93            let expected = old_encode_raw(&content, &config).unwrap();
94            let encoded = encode_blob_content(&content, &config).unwrap();
95            assert_eq!(encoded, expected);
96            assert_eq!(decode_blob_content(&encoded).unwrap(), content);
97        }
98    }
99
100    #[test]
101    fn encode_decode_tree_matches_old_recipe() {
102        let blob_hash = ContentHash::compute(b"codec-tree-blob");
103        let tree = Tree::from_entries(vec![TreeEntry::file("file.txt", blob_hash, false).unwrap()]);
104        for config in compression_configs() {
105            let serialized = rmp_serde::to_vec(&tree).unwrap();
106            let expected = old_encode_raw(&serialized, &config).unwrap();
107            let (hash, encoded) = encode_tree(&tree, &config).unwrap();
108            assert_eq!(hash, tree.hash());
109            assert_eq!(encoded, expected);
110            assert_eq!(decode_tree(&encoded).unwrap(), tree);
111        }
112    }
113
114    #[test]
115    fn encode_decode_state_matches_old_recipe() {
116        let attribution = sample_attribution();
117        let state = State::new(ContentHash::compute(b"codec-tree"), vec![], attribution)
118            .with_intent("codec state");
119        for config in compression_configs() {
120            let serialized = rmp_serde::to_vec(&state).unwrap();
121            let expected = old_encode_raw(&serialized, &config).unwrap();
122            let encoded = encode_state(&state, &config).unwrap();
123            assert_eq!(encoded, expected);
124            assert_eq!(decode_state(&encoded).unwrap(), state);
125        }
126    }
127
128    #[test]
129    fn encode_decode_action_matches_old_recipe() {
130        let attribution = sample_attribution();
131        for config in compression_configs() {
132            let mut action = Action::new(
133                None,
134                StateId::from_bytes([1; 32]),
135                Operation::Snapshot,
136                "codec action",
137                attribution.clone(),
138            );
139            let id = action.id();
140            let serialized = rmp_serde::to_vec(&action).unwrap();
141            let expected = old_encode_raw(&serialized, &config).unwrap();
142
143            let (encoded_id, encoded) = encode_action(&mut action, &config).unwrap();
144            assert_eq!(encoded_id, id);
145            assert_eq!(encoded, expected);
146
147            let decoded = decode_action(&encoded).unwrap();
148            assert_eq!(decoded.compute_id(), id);
149            assert_eq!(decoded.from_state, action.from_state);
150            assert_eq!(decoded.to_state, action.to_state);
151            assert_eq!(decoded.operation, action.operation);
152            assert_eq!(decoded.description, action.description);
153            assert_eq!(decoded.semantic_changes, action.semantic_changes);
154            assert_eq!(decoded.attribution, action.attribution);
155            assert_eq!(decoded.timestamp, action.timestamp);
156        }
157    }
158
159    fn old_encode_raw(data: &[u8], config: &CompressionConfig) -> Result<Vec<u8>> {
160        Ok(compress(data, config)?.unwrap_or_else(|| data.to_vec()))
161    }
162
163    fn compression_configs() -> Vec<CompressionConfig> {
164        #[cfg(feature = "zstd")]
165        {
166            vec![
167                CompressionConfig::default(),
168                CompressionConfig::disabled(),
169                CompressionConfig {
170                    enabled: true,
171                    level: 9,
172                    min_size: 0,
173                    max_delta_size: CompressionConfig::default().max_delta_size,
174                },
175            ]
176        }
177        #[cfg(not(feature = "zstd"))]
178        {
179            vec![CompressionConfig::default(), CompressionConfig::disabled()]
180        }
181    }
182
183    fn sample_attribution() -> Attribution {
184        Attribution::human(Principal::new("Codec Test", "codec@example.com"))
185    }
186}