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