1use std::collections::{HashMap, VecDeque};
5
6use heddle_format::{compression::CompressionConfig, delta::DeltaEncoder};
7
8use super::{
9 ObjectType, PackLogicalId, PackObjectId, PackObjectRecord, PackStats,
10 append_container_checksum, compress_pack_payload, encode_tagged_entry,
11 encode_tagged_entry_parts, pack_container_spec, pack_identity::logical_id_from_objects,
12 pack_index::PackIndex, write_container_header,
13};
14use crate::{object::ContentHash, store::Result};
15
16const MIN_DELTA_SIZE: usize = 64;
17const MAX_DELTA_CHAIN_DEPTH: usize = 50;
19const DEFAULT_DELTA_WINDOW: usize = 10;
21
22type GroupedPackMap = HashMap<ObjectType, Vec<PackObjectRecord>>;
23
24pub type RetainedPackBuild = (
26 Vec<u8>,
27 Vec<u8>,
28 PackStats,
29 Vec<(PackObjectId, ObjectType, Vec<u8>)>,
30);
31
32pub struct PackBuilder {
34 objects: Vec<PackObjectRecord>,
35 compression: CompressionConfig,
36 delta_window: usize,
37}
38
39struct WindowEntry {
41 hash: ContentHash,
42 data: Vec<u8>,
43 index: HashMap<[u8; 4], Vec<usize>>,
44 chain_depth: usize,
45}
46
47impl PackBuilder {
48 pub fn new(compression: CompressionConfig) -> Self {
50 Self {
51 objects: Vec::new(),
52 compression,
53 delta_window: DEFAULT_DELTA_WINDOW,
54 }
55 }
56
57 pub fn for_repack(compression: CompressionConfig, delta_window: usize) -> Self {
62 Self {
63 objects: Vec::new(),
64 compression,
65 delta_window,
66 }
67 }
68
69 pub fn add(&mut self, hash: ContentHash, obj_type: ObjectType, data: Vec<u8>) {
71 self.add_id(PackObjectId::Hash(hash), obj_type, data);
72 }
73
74 pub fn add_id(&mut self, id: PackObjectId, obj_type: ObjectType, data: Vec<u8>) {
75 self.objects.push(PackObjectRecord {
76 id,
77 obj_type,
78 data,
79 delta_base: None,
80 path_hint: None,
81 });
82 }
83
84 pub fn add_with_path(
90 &mut self,
91 hash: ContentHash,
92 obj_type: ObjectType,
93 data: Vec<u8>,
94 path: Option<String>,
95 ) {
96 self.add_with_path_id(PackObjectId::Hash(hash), obj_type, data, path);
97 }
98
99 pub fn add_with_path_id(
100 &mut self,
101 id: PackObjectId,
102 obj_type: ObjectType,
103 data: Vec<u8>,
104 path: Option<String>,
105 ) {
106 self.objects.push(PackObjectRecord {
107 id,
108 obj_type,
109 data,
110 delta_base: None,
111 path_hint: path,
112 });
113 }
114
115 pub fn logical_id(&self) -> PackLogicalId {
120 logical_id_from_objects(
121 self.objects
122 .iter()
123 .map(|record| (record.id, record.obj_type, record.data.as_slice())),
124 )
125 }
126
127 pub fn build(self) -> Result<(Vec<u8>, Vec<u8>, PackStats)> {
131 let (pack_data, index_data, stats, _) = self.build_impl(false)?;
132 Ok((pack_data, index_data, stats))
133 }
134
135 pub fn build_retaining_objects(self) -> Result<RetainedPackBuild> {
141 self.build_impl(true)
142 }
143
144 fn build_impl(self, retain_objects: bool) -> Result<RetainedPackBuild> {
145 let mut pack_data = Vec::new();
146 let mut index = PackIndex::new();
147
148 write_container_header(
149 &mut pack_data,
150 pack_container_spec(),
151 self.objects.len() as u64,
152 );
153
154 let mut total_uncompressed = 0u64;
155 let mut total_compressed = 0u64;
156 let mut delta_count = 0u64;
157
158 let object_count = self.objects.len() as u64;
159 let mut retained_objects = Vec::with_capacity(if retain_objects {
160 self.objects.len()
161 } else {
162 0
163 });
164 let grouped = Self::group_by_type(self.objects);
165
166 for (obj_type, mut objects) in grouped {
167 if objects.len() < 2
168 || matches!(
169 obj_type,
170 ObjectType::State | ObjectType::StateAttachment | ObjectType::AnnotatedTag
171 )
172 || self.compression.max_delta_size == 0
173 || objects
174 .iter()
175 .all(|record| record.data.len() < MIN_DELTA_SIZE)
176 {
177 for record in objects {
181 let offset = pack_data.len() as u64;
182 index.add(record.id, offset);
183
184 total_uncompressed += record.data.len() as u64;
185 let compressed = compress_pack_payload(&record.data, &self.compression)?;
186 total_compressed += compressed.len() as u64;
187
188 Self::write_entry(&mut pack_data, &record, obj_type, &compressed)?;
189 if retain_objects {
190 retained_objects.push((record.id, obj_type, record.data));
191 }
192 }
193 } else {
194 Self::sort_for_delta_window(&mut objects);
195 retained_objects.extend(Self::encode_with_sliding_window(
196 &mut pack_data,
197 &mut index,
198 &mut total_uncompressed,
199 &mut total_compressed,
200 &mut delta_count,
201 obj_type,
202 objects,
203 &self.compression,
204 self.delta_window,
205 retain_objects,
206 )?);
207 }
208 }
209
210 index.sort();
211
212 append_container_checksum(&mut pack_data);
213
214 let stats = PackStats {
215 object_count,
216 total_uncompressed,
217 total_compressed,
218 delta_count,
219 compression_ratio: total_compressed as f64 / total_uncompressed as f64,
220 };
221
222 Ok((pack_data, index.to_bytes(), stats, retained_objects))
223 }
224
225 fn sort_for_delta_window(objects: &mut [PackObjectRecord]) {
232 objects.sort_by(|a, b| {
233 let key_a = Self::sort_key(&a.path_hint);
234 let key_b = Self::sort_key(&b.path_hint);
235 key_a.cmp(&key_b).then(b.data.len().cmp(&a.data.len()))
236 });
237 }
238
239 fn sort_key(path: &Option<String>) -> (String, String) {
242 match path {
243 Some(p) => {
244 let filename = p.rsplit('/').next().unwrap_or(p);
245 if let Some(dot_pos) = filename.rfind('.') {
246 let ext = filename[dot_pos + 1..].to_string();
247 let stem = filename[..dot_pos].to_string();
248 (ext, stem)
249 } else {
250 (String::new(), filename.to_string())
251 }
252 }
253 None => ("\u{FFFF}".to_string(), String::new()),
254 }
255 }
256
257 #[allow(clippy::too_many_arguments)]
263 fn encode_with_sliding_window(
264 pack_data: &mut Vec<u8>,
265 index: &mut PackIndex,
266 total_uncompressed: &mut u64,
267 total_compressed: &mut u64,
268 delta_count: &mut u64,
269 obj_type: ObjectType,
270 objects: Vec<PackObjectRecord>,
271 compression: &CompressionConfig,
272 delta_window: usize,
273 retain_objects: bool,
274 ) -> Result<Vec<(PackObjectId, ObjectType, Vec<u8>)>> {
275 let mut window: VecDeque<WindowEntry> =
276 VecDeque::with_capacity(delta_window.min(objects.len()));
277 let mut retained_objects =
278 Vec::with_capacity(if retain_objects { objects.len() } else { 0 });
279
280 for record in objects {
281 let hash = match record.id {
282 PackObjectId::Hash(hash) => hash,
283 PackObjectId::StateId(_) | PackObjectId::AnnotatedTag(_) => {
284 let offset = pack_data.len() as u64;
285 index.add(record.id, offset);
286 *total_uncompressed += record.data.len() as u64;
287 let compressed = compress_pack_payload(&record.data, compression)?;
288 *total_compressed += compressed.len() as u64;
289 Self::write_entry(pack_data, &record, obj_type, &compressed)?;
290 if retain_objects {
291 retained_objects.push((record.id, obj_type, record.data));
292 }
293 continue;
294 }
295 };
296 let data = record.data;
297 let offset = pack_data.len() as u64;
298 index.add(PackObjectId::Hash(hash), offset);
299 *total_uncompressed += data.len() as u64;
300
301 let mut best_base_idx: Option<usize> = None;
303 let mut best_delta_estimate = usize::MAX;
304
305 if data.len() >= MIN_DELTA_SIZE {
306 for (i, entry) in window.iter().enumerate() {
307 if entry.chain_depth >= MAX_DELTA_CHAIN_DEPTH {
309 continue;
310 }
311 if entry.data.len() < MIN_DELTA_SIZE {
313 continue;
314 }
315
316 let estimate = DeltaEncoder::estimate_delta_size_with_index(
317 &entry.index,
318 &entry.data,
319 &data,
320 );
321
322 if estimate < best_delta_estimate {
323 best_delta_estimate = estimate;
324 best_base_idx = Some(i);
325 }
326 }
327 }
328
329 let (final_data, entry_type, base_hash, chain_depth) =
331 if let Some(base_idx) = best_base_idx {
332 let base_entry = &window[base_idx];
333 let delta =
334 DeltaEncoder::encode_with_index(&base_entry.index, &base_entry.data, &data);
335 let delta_compressed = compress_pack_payload(&delta, compression)?;
336
337 if delta_compressed.len() < data.len() {
338 *delta_count += 1;
339 let bh = base_entry.hash;
340 let depth = base_entry.chain_depth + 1;
341 (delta_compressed, ObjectType::Delta, Some(bh), depth)
342 } else {
343 let compressed = compress_pack_payload(&data, compression)?;
344 (compressed, obj_type, None, 0)
345 }
346 } else {
347 let compressed = compress_pack_payload(&data, compression)?;
348 (compressed, obj_type, None, 0)
349 };
350
351 *total_compressed += final_data.len() as u64;
352
353 Self::write_entry_parts(
354 pack_data,
355 PackObjectId::Hash(hash),
356 entry_type,
357 data.len(),
358 base_hash.map(PackObjectId::Hash),
359 &final_data,
360 )?;
361
362 if delta_window > 0 {
363 let entry_index = DeltaEncoder::build_index(&data);
365 if window.len() >= delta_window
366 && let Some(evicted) = window.pop_front()
367 && retain_objects
368 {
369 retained_objects.push((
370 PackObjectId::Hash(evicted.hash),
371 obj_type,
372 evicted.data,
373 ));
374 }
375 window.push_back(WindowEntry {
376 hash,
377 data,
378 index: entry_index,
379 chain_depth,
380 });
381 } else if retain_objects {
382 retained_objects.push((PackObjectId::Hash(hash), obj_type, data));
383 }
384 }
385
386 if retain_objects {
387 retained_objects.extend(
388 window
389 .into_iter()
390 .map(|entry| (PackObjectId::Hash(entry.hash), obj_type, entry.data)),
391 );
392 }
393 Ok(retained_objects)
394 }
395
396 fn group_by_type(objects: Vec<PackObjectRecord>) -> GroupedPackMap {
397 let mut groups: GroupedPackMap = HashMap::new();
398
399 for record in objects {
400 groups.entry(record.obj_type).or_default().push(record);
401 }
402
403 groups
404 }
405
406 fn write_entry(
417 pack: &mut Vec<u8>,
418 record: &PackObjectRecord,
419 obj_type: ObjectType,
420 compressed: &[u8],
421 ) -> Result<()> {
422 encode_tagged_entry(pack, record, obj_type, compressed)
423 }
424
425 fn write_entry_parts(
426 pack: &mut Vec<u8>,
427 id: PackObjectId,
428 obj_type: ObjectType,
429 uncompressed_size: usize,
430 delta_base: Option<PackObjectId>,
431 compressed: &[u8],
432 ) -> Result<()> {
433 encode_tagged_entry_parts(
434 pack,
435 id,
436 obj_type,
437 uncompressed_size,
438 delta_base,
439 compressed,
440 )
441 }
442}