1use heddle_format::compression::{
5 CompressionConfig, CompressionDictionary, compress, compress_with_dictionary, decompress,
6 decompress_with_dictionary, is_compressed,
7};
8
9use crate::{
10 object::{Action, ActionId, ContentHash, State, Tree, TreeDecodeError},
11 store::{HeddleError, Result},
12};
13
14pub fn encode_blob_content(content: &[u8], config: &CompressionConfig) -> Result<Vec<u8>> {
15 Ok(compress(content, config)?.unwrap_or_else(|| content.to_vec()))
16}
17
18pub fn decode_blob_content(data: &[u8]) -> Result<Vec<u8>> {
19 if is_compressed(data) {
20 Ok(decompress(data)?)
21 } else {
22 Ok(data.to_vec())
23 }
24}
25
26pub fn encode_tree(tree: &Tree, config: &CompressionConfig) -> Result<(ContentHash, Vec<u8>)> {
27 let hash = tree.hash();
28 let serialized = rmp_serde::to_vec(tree)?;
29 let data = compress_with_dictionary(&serialized, config, CompressionDictionary::TreeStateV1)?
30 .unwrap_or(serialized);
31 Ok((hash, data))
32}
33
34pub fn decode_tree(data: &[u8]) -> Result<Tree> {
35 let decoded = decode_tree_body(data)?;
36 decode_tree_serialized(&decoded)
37}
38
39pub fn decode_tree_serialized(data: &[u8]) -> Result<Tree> {
40 Tree::decode_current_msgpack(data).map_err(|error| match error {
41 TreeDecodeError::Decode(error) => HeddleError::from(error),
42 TreeDecodeError::Invalid(error) => HeddleError::InvalidTreeEntry(error),
43 })
44}
45
46pub fn decode_tree_body(data: &[u8]) -> Result<Vec<u8>> {
50 Ok(decompress_with_dictionary(data)?)
51}
52
53pub fn encode_state(state: &State, config: &CompressionConfig) -> Result<Vec<u8>> {
54 let serialized = rmp_serde::to_vec(state)?;
55 Ok(
56 compress_with_dictionary(&serialized, config, CompressionDictionary::TreeStateV1)?
57 .unwrap_or(serialized),
58 )
59}
60
61pub fn decode_state(data: &[u8]) -> Result<State> {
62 let decoded = decompress_with_dictionary(data)?;
63 let mut state: State = rmp_serde::from_slice(&decoded)?;
64 state.state_id = state.id();
65 Ok(state)
66}
67
68pub fn encode_action(
69 action: &mut Action,
70 config: &CompressionConfig,
71) -> Result<(ActionId, Vec<u8>)> {
72 let id = action.id();
73 let serialized = rmp_serde::to_vec(action)?;
74 let data = compress(&serialized, config)?.unwrap_or(serialized);
75 Ok((id, data))
76}
77
78pub fn decode_action(data: &[u8]) -> Result<Action> {
79 let decoded = decode_body(data)?;
80 Ok(rmp_serde::from_slice(&decoded)?)
81}
82
83fn decode_body(data: &[u8]) -> Result<Vec<u8>> {
84 if is_compressed(data) {
85 Ok(decompress(data)?)
86 } else {
87 Ok(data.to_vec())
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use crate::object::{Attribution, Operation, Principal, StateId, TreeEntry};
95
96 #[test]
97 fn encode_decode_blob_content_matches_old_recipe() {
98 let content = b"codec blob content ".repeat(64);
99 for config in compression_configs() {
100 let expected = old_encode_raw(&content, &config).unwrap();
101 let encoded = encode_blob_content(&content, &config).unwrap();
102 assert_eq!(encoded, expected);
103 assert_eq!(decode_blob_content(&encoded).unwrap(), content);
104 }
105 }
106
107 #[test]
108 fn encode_decode_tree() {
109 let blob_hash = ContentHash::compute(b"codec-tree-blob");
110 let tree = Tree::from_entries(vec![TreeEntry::file("file.txt", blob_hash, false).unwrap()]);
111 for config in compression_configs() {
112 let (hash, encoded) = encode_tree(&tree, &config).unwrap();
113 assert_eq!(hash, tree.hash());
114 assert_eq!(decode_tree(&encoded).unwrap(), tree);
115 }
116 }
117
118 #[test]
119 #[cfg(feature = "zstd")]
120 fn tree_and_state_use_versioned_dictionary_frames() {
121 let tree = Tree::from_entries(
122 (0..24)
123 .map(|index| {
124 TreeEntry::file(
125 format!("module_{index:02}.rs"),
126 ContentHash::compute(format!("blob-{index}").as_bytes()),
127 false,
128 )
129 .unwrap()
130 })
131 .collect(),
132 );
133 let state = State::new(
134 tree.hash(),
135 vec![StateId::from_bytes([7; 32])],
136 sample_attribution(),
137 )
138 .with_intent("dictionary frame verification ".repeat(32));
139
140 let (_, encoded_tree) = encode_tree(&tree, &CompressionConfig::default()).unwrap();
141 let encoded_state = encode_state(&state, &CompressionConfig::default()).unwrap();
142
143 assert_eq!(&encoded_tree[9..13], &1_u32.to_be_bytes());
144 assert_eq!(&encoded_state[9..13], &1_u32.to_be_bytes());
145 }
146
147 #[test]
148 #[cfg(feature = "zstd")]
149 fn tree_state_dictionary_corpus_roundtrips_byte_identically() {
150 let config = CompressionConfig::default();
151
152 for revision in 0..64 {
153 let tree = Tree::from_entries(
154 (0..32)
155 .map(|entry| {
156 TreeEntry::file(
157 format!("module_{entry:02}.rs"),
158 ContentHash::compute(
159 format!("revision-{revision}-blob-{entry}").as_bytes(),
160 ),
161 entry % 11 == 0,
162 )
163 .unwrap()
164 })
165 .collect(),
166 );
167 let serialized_tree = rmp_serde::to_vec(&tree).unwrap();
168 let (_, encoded_tree) = encode_tree(&tree, &config).unwrap();
169 assert_eq!(decode_tree_body(&encoded_tree).unwrap(), serialized_tree);
170
171 let state = State::new(
172 tree.hash(),
173 vec![StateId::from_bytes([revision; 32])],
174 sample_attribution(),
175 )
176 .with_intent(format!(
177 "Update the representative tree/state corpus at revision {revision}. {}",
178 "Preserve byte-identical object bodies. ".repeat(12)
179 ));
180 let serialized_state = rmp_serde::to_vec(&state).unwrap();
181 let encoded_state = encode_state(&state, &config).unwrap();
182 assert_eq!(
183 decompress_with_dictionary(&encoded_state).unwrap(),
184 serialized_state
185 );
186 }
187 }
188
189 #[test]
190 fn encode_decode_state() {
191 let attribution = sample_attribution();
192 let state = State::new(ContentHash::compute(b"codec-tree"), vec![], attribution)
193 .with_intent("codec state");
194 for config in compression_configs() {
195 let encoded = encode_state(&state, &config).unwrap();
196 assert_eq!(decode_state(&encoded).unwrap(), state);
197 }
198 }
199
200 #[test]
201 fn encode_decode_action_matches_old_recipe() {
202 let attribution = sample_attribution();
203 for config in compression_configs() {
204 let mut action = Action::new(
205 None,
206 StateId::from_bytes([1; 32]),
207 Operation::Snapshot,
208 "codec action",
209 attribution.clone(),
210 );
211 let id = action.id();
212 let serialized = rmp_serde::to_vec(&action).unwrap();
213 let expected = old_encode_raw(&serialized, &config).unwrap();
214
215 let (encoded_id, encoded) = encode_action(&mut action, &config).unwrap();
216 assert_eq!(encoded_id, id);
217 assert_eq!(encoded, expected);
218
219 let decoded = decode_action(&encoded).unwrap();
220 assert_eq!(decoded.compute_id(), id);
221 assert_eq!(decoded.from_state, action.from_state);
222 assert_eq!(decoded.to_state, action.to_state);
223 assert_eq!(decoded.operation, action.operation);
224 assert_eq!(decoded.description, action.description);
225 assert_eq!(decoded.semantic_changes, action.semantic_changes);
226 assert_eq!(decoded.attribution, action.attribution);
227 assert_eq!(decoded.timestamp, action.timestamp);
228 }
229 }
230
231 fn old_encode_raw(data: &[u8], config: &CompressionConfig) -> Result<Vec<u8>> {
232 Ok(compress(data, config)?.unwrap_or_else(|| data.to_vec()))
233 }
234
235 fn compression_configs() -> Vec<CompressionConfig> {
236 #[cfg(feature = "zstd")]
237 {
238 vec![
239 CompressionConfig::default(),
240 CompressionConfig::disabled(),
241 CompressionConfig {
242 enabled: true,
243 level: 9,
244 min_size: 0,
245 max_delta_size: CompressionConfig::default().max_delta_size,
246 },
247 ]
248 }
249 #[cfg(not(feature = "zstd"))]
250 {
251 vec![CompressionConfig::default(), CompressionConfig::disabled()]
252 }
253 }
254
255 fn sample_attribution() -> Attribution {
256 Attribution::human(Principal::new("Codec Test", "codec@example.com"))
257 }
258}