1use std::collections::{HashMap, VecDeque};
5
6use heddle_format::{compression::CompressionConfig, delta::DeltaEncoder};
7
8use super::{
9 ObjectType, PackObjectId, PackObjectRecord, PackStats, append_container_checksum,
10 compress_pack_payload, encode_tagged_entry, encode_tagged_entry_parts, pack_container_spec,
11 pack_index::PackIndex, write_container_header,
12};
13use crate::{object::ContentHash, store::Result};
14
15const MIN_DELTA_SIZE: usize = 64;
16const MAX_DELTA_CHAIN_DEPTH: usize = 50;
18const WINDOW_SIZE: usize = 10;
20
21type GroupedPackMap = HashMap<ObjectType, Vec<PackObjectRecord>>;
22
23pub struct PackBuilder {
25 objects: Vec<PackObjectRecord>,
26 compression: CompressionConfig,
27}
28
29struct WindowEntry {
31 hash: ContentHash,
32 data: Vec<u8>,
33 index: HashMap<[u8; 4], Vec<usize>>,
34 chain_depth: usize,
35}
36
37impl PackBuilder {
38 pub fn new(compression: CompressionConfig) -> Self {
40 Self {
41 objects: Vec::new(),
42 compression,
43 }
44 }
45
46 pub fn add(&mut self, hash: ContentHash, obj_type: ObjectType, data: Vec<u8>) {
48 self.add_id(PackObjectId::Hash(hash), obj_type, data);
49 }
50
51 pub fn add_id(&mut self, id: PackObjectId, obj_type: ObjectType, data: Vec<u8>) {
52 self.objects.push(PackObjectRecord {
53 id,
54 obj_type,
55 data,
56 delta_base: None,
57 path_hint: None,
58 });
59 }
60
61 pub fn add_with_path(
67 &mut self,
68 hash: ContentHash,
69 obj_type: ObjectType,
70 data: Vec<u8>,
71 path: Option<String>,
72 ) {
73 self.add_with_path_id(PackObjectId::Hash(hash), obj_type, data, path);
74 }
75
76 pub fn add_with_path_id(
77 &mut self,
78 id: PackObjectId,
79 obj_type: ObjectType,
80 data: Vec<u8>,
81 path: Option<String>,
82 ) {
83 self.objects.push(PackObjectRecord {
84 id,
85 obj_type,
86 data,
87 delta_base: None,
88 path_hint: path,
89 });
90 }
91
92 pub fn build(self) -> Result<(Vec<u8>, Vec<u8>, PackStats)> {
96 let mut pack_data = Vec::new();
97 let mut index = PackIndex::new();
98
99 write_container_header(
100 &mut pack_data,
101 pack_container_spec(),
102 self.objects.len() as u64,
103 );
104
105 let mut total_uncompressed = 0u64;
106 let mut total_compressed = 0u64;
107 let mut delta_count = 0u64;
108
109 let object_count = self.objects.len() as u64;
110 let grouped = Self::group_by_type(self.objects);
111
112 for (obj_type, mut objects) in grouped {
113 if objects.len() < 2
114 || matches!(obj_type, ObjectType::State | ObjectType::StateAttachment)
115 || self.compression.max_delta_size == 0
116 || objects
117 .iter()
118 .all(|record| record.data.len() < MIN_DELTA_SIZE)
119 {
120 for record in objects {
124 let offset = pack_data.len() as u64;
125 index.add(record.id, offset);
126
127 total_uncompressed += record.data.len() as u64;
128 let compressed = compress_pack_payload(&record.data, &self.compression)?;
129 total_compressed += compressed.len() as u64;
130
131 Self::write_entry(&mut pack_data, &record, obj_type, &compressed)?;
132 }
133 } else {
134 Self::sort_for_delta_window(&mut objects);
135 Self::encode_with_sliding_window(
136 &mut pack_data,
137 &mut index,
138 &mut total_uncompressed,
139 &mut total_compressed,
140 &mut delta_count,
141 obj_type,
142 objects,
143 &self.compression,
144 )?;
145 }
146 }
147
148 index.sort();
149
150 append_container_checksum(&mut pack_data);
151
152 let stats = PackStats {
153 object_count,
154 total_uncompressed,
155 total_compressed,
156 delta_count,
157 compression_ratio: total_compressed as f64 / total_uncompressed as f64,
158 };
159
160 Ok((pack_data, index.to_bytes(), stats))
161 }
162
163 fn sort_for_delta_window(objects: &mut [PackObjectRecord]) {
170 objects.sort_by(|a, b| {
171 let key_a = Self::sort_key(&a.path_hint);
172 let key_b = Self::sort_key(&b.path_hint);
173 key_a.cmp(&key_b).then(b.data.len().cmp(&a.data.len()))
174 });
175 }
176
177 fn sort_key(path: &Option<String>) -> (String, String) {
180 match path {
181 Some(p) => {
182 let filename = p.rsplit('/').next().unwrap_or(p);
183 if let Some(dot_pos) = filename.rfind('.') {
184 let ext = filename[dot_pos + 1..].to_string();
185 let stem = filename[..dot_pos].to_string();
186 (ext, stem)
187 } else {
188 (String::new(), filename.to_string())
189 }
190 }
191 None => ("\u{FFFF}".to_string(), String::new()),
192 }
193 }
194
195 #[allow(clippy::too_many_arguments)]
201 fn encode_with_sliding_window(
202 pack_data: &mut Vec<u8>,
203 index: &mut PackIndex,
204 total_uncompressed: &mut u64,
205 total_compressed: &mut u64,
206 delta_count: &mut u64,
207 obj_type: ObjectType,
208 objects: Vec<PackObjectRecord>,
209 compression: &CompressionConfig,
210 ) -> Result<()> {
211 let mut window: VecDeque<WindowEntry> = VecDeque::with_capacity(WINDOW_SIZE);
212
213 for record in objects {
214 let hash = match record.id {
215 PackObjectId::Hash(hash) => hash,
216 PackObjectId::StateId(_) => {
217 let offset = pack_data.len() as u64;
218 index.add(record.id, offset);
219 *total_uncompressed += record.data.len() as u64;
220 let compressed = compress_pack_payload(&record.data, compression)?;
221 *total_compressed += compressed.len() as u64;
222 Self::write_entry(pack_data, &record, obj_type, &compressed)?;
223 continue;
224 }
225 };
226 let data = record.data;
227 let offset = pack_data.len() as u64;
228 index.add(PackObjectId::Hash(hash), offset);
229 *total_uncompressed += data.len() as u64;
230
231 let mut best_base_idx: Option<usize> = None;
233 let mut best_delta_estimate = usize::MAX;
234
235 if data.len() >= MIN_DELTA_SIZE {
236 for (i, entry) in window.iter().enumerate() {
237 if entry.chain_depth >= MAX_DELTA_CHAIN_DEPTH {
239 continue;
240 }
241 if entry.data.len() < MIN_DELTA_SIZE {
243 continue;
244 }
245
246 let estimate = DeltaEncoder::estimate_delta_size_with_index(
247 &entry.index,
248 &entry.data,
249 &data,
250 );
251
252 if estimate < best_delta_estimate {
253 best_delta_estimate = estimate;
254 best_base_idx = Some(i);
255 }
256 }
257 }
258
259 let (final_data, entry_type, base_hash, chain_depth) =
261 if let Some(base_idx) = best_base_idx {
262 let base_entry = &window[base_idx];
263 let delta =
264 DeltaEncoder::encode_with_index(&base_entry.index, &base_entry.data, &data);
265 let delta_compressed = compress_pack_payload(&delta, compression)?;
266
267 if delta_compressed.len() < data.len() {
268 *delta_count += 1;
269 let bh = base_entry.hash;
270 let depth = base_entry.chain_depth + 1;
271 (delta_compressed, ObjectType::Delta, Some(bh), depth)
272 } else {
273 let compressed = compress_pack_payload(&data, compression)?;
274 (compressed, obj_type, None, 0)
275 }
276 } else {
277 let compressed = compress_pack_payload(&data, compression)?;
278 (compressed, obj_type, None, 0)
279 };
280
281 *total_compressed += final_data.len() as u64;
282
283 Self::write_entry_parts(
284 pack_data,
285 PackObjectId::Hash(hash),
286 entry_type,
287 data.len(),
288 base_hash.map(PackObjectId::Hash),
289 &final_data,
290 )?;
291
292 let entry_index = DeltaEncoder::build_index(&data);
294 if window.len() >= WINDOW_SIZE {
295 window.pop_front();
296 }
297 window.push_back(WindowEntry {
298 hash,
299 data,
300 index: entry_index,
301 chain_depth,
302 });
303 }
304
305 Ok(())
306 }
307
308 fn group_by_type(objects: Vec<PackObjectRecord>) -> GroupedPackMap {
309 let mut groups: GroupedPackMap = HashMap::new();
310
311 for record in objects {
312 groups.entry(record.obj_type).or_default().push(record);
313 }
314
315 groups
316 }
317
318 fn write_entry(
329 pack: &mut Vec<u8>,
330 record: &PackObjectRecord,
331 obj_type: ObjectType,
332 compressed: &[u8],
333 ) -> Result<()> {
334 encode_tagged_entry(pack, record, obj_type, compressed)
335 }
336
337 fn write_entry_parts(
338 pack: &mut Vec<u8>,
339 id: PackObjectId,
340 obj_type: ObjectType,
341 uncompressed_size: usize,
342 delta_base: Option<PackObjectId>,
343 compressed: &[u8],
344 ) -> Result<()> {
345 encode_tagged_entry_parts(
346 pack,
347 id,
348 obj_type,
349 uncompressed_size,
350 delta_base,
351 compressed,
352 )
353 }
354}