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