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 struct PackBuilder {
26 objects: Vec<PackObjectRecord>,
27 compression: CompressionConfig,
28 delta_window: usize,
29}
30
31struct WindowEntry {
33 hash: ContentHash,
34 data: Vec<u8>,
35 index: HashMap<[u8; 4], Vec<usize>>,
36 chain_depth: usize,
37}
38
39impl PackBuilder {
40 pub fn new(compression: CompressionConfig) -> Self {
42 Self {
43 objects: Vec::new(),
44 compression,
45 delta_window: DEFAULT_DELTA_WINDOW,
46 }
47 }
48
49 pub fn for_repack(compression: CompressionConfig, delta_window: usize) -> Self {
54 Self {
55 objects: Vec::new(),
56 compression,
57 delta_window,
58 }
59 }
60
61 pub fn add(&mut self, hash: ContentHash, obj_type: ObjectType, data: Vec<u8>) {
63 self.add_id(PackObjectId::Hash(hash), obj_type, data);
64 }
65
66 pub fn add_id(&mut self, id: PackObjectId, obj_type: ObjectType, data: Vec<u8>) {
67 self.objects.push(PackObjectRecord {
68 id,
69 obj_type,
70 data,
71 delta_base: None,
72 path_hint: None,
73 });
74 }
75
76 pub fn add_with_path(
82 &mut self,
83 hash: ContentHash,
84 obj_type: ObjectType,
85 data: Vec<u8>,
86 path: Option<String>,
87 ) {
88 self.add_with_path_id(PackObjectId::Hash(hash), obj_type, data, path);
89 }
90
91 pub fn add_with_path_id(
92 &mut self,
93 id: PackObjectId,
94 obj_type: ObjectType,
95 data: Vec<u8>,
96 path: Option<String>,
97 ) {
98 self.objects.push(PackObjectRecord {
99 id,
100 obj_type,
101 data,
102 delta_base: None,
103 path_hint: path,
104 });
105 }
106
107 pub fn logical_id(&self) -> PackLogicalId {
112 logical_id_from_objects(
113 self.objects
114 .iter()
115 .map(|record| (record.id, record.obj_type, record.data.as_slice())),
116 )
117 }
118
119 pub fn build(self) -> Result<(Vec<u8>, Vec<u8>, PackStats)> {
123 let mut pack_data = Vec::new();
124 let mut index = PackIndex::new();
125
126 write_container_header(
127 &mut pack_data,
128 pack_container_spec(),
129 self.objects.len() as u64,
130 );
131
132 let mut total_uncompressed = 0u64;
133 let mut total_compressed = 0u64;
134 let mut delta_count = 0u64;
135
136 let object_count = self.objects.len() as u64;
137 let grouped = Self::group_by_type(self.objects);
138
139 for (obj_type, mut objects) in grouped {
140 if objects.len() < 2
141 || matches!(
142 obj_type,
143 ObjectType::State | ObjectType::StateAttachment | ObjectType::AnnotatedTag
144 )
145 || self.compression.max_delta_size == 0
146 || objects
147 .iter()
148 .all(|record| record.data.len() < MIN_DELTA_SIZE)
149 {
150 for record in objects {
154 let offset = pack_data.len() as u64;
155 index.add(record.id, offset);
156
157 total_uncompressed += record.data.len() as u64;
158 let compressed = compress_pack_payload(&record.data, &self.compression)?;
159 total_compressed += compressed.len() as u64;
160
161 Self::write_entry(&mut pack_data, &record, obj_type, &compressed)?;
162 }
163 } else {
164 Self::sort_for_delta_window(&mut objects);
165 Self::encode_with_sliding_window(
166 &mut pack_data,
167 &mut index,
168 &mut total_uncompressed,
169 &mut total_compressed,
170 &mut delta_count,
171 obj_type,
172 objects,
173 &self.compression,
174 self.delta_window,
175 )?;
176 }
177 }
178
179 index.sort();
180
181 append_container_checksum(&mut pack_data);
182
183 let stats = PackStats {
184 object_count,
185 total_uncompressed,
186 total_compressed,
187 delta_count,
188 compression_ratio: total_compressed as f64 / total_uncompressed as f64,
189 };
190
191 Ok((pack_data, index.to_bytes(), stats))
192 }
193
194 fn sort_for_delta_window(objects: &mut [PackObjectRecord]) {
201 objects.sort_by(|a, b| {
202 let key_a = Self::sort_key(&a.path_hint);
203 let key_b = Self::sort_key(&b.path_hint);
204 key_a.cmp(&key_b).then(b.data.len().cmp(&a.data.len()))
205 });
206 }
207
208 fn sort_key(path: &Option<String>) -> (String, String) {
211 match path {
212 Some(p) => {
213 let filename = p.rsplit('/').next().unwrap_or(p);
214 if let Some(dot_pos) = filename.rfind('.') {
215 let ext = filename[dot_pos + 1..].to_string();
216 let stem = filename[..dot_pos].to_string();
217 (ext, stem)
218 } else {
219 (String::new(), filename.to_string())
220 }
221 }
222 None => ("\u{FFFF}".to_string(), String::new()),
223 }
224 }
225
226 #[allow(clippy::too_many_arguments)]
232 fn encode_with_sliding_window(
233 pack_data: &mut Vec<u8>,
234 index: &mut PackIndex,
235 total_uncompressed: &mut u64,
236 total_compressed: &mut u64,
237 delta_count: &mut u64,
238 obj_type: ObjectType,
239 objects: Vec<PackObjectRecord>,
240 compression: &CompressionConfig,
241 delta_window: usize,
242 ) -> Result<()> {
243 let mut window: VecDeque<WindowEntry> =
244 VecDeque::with_capacity(delta_window.min(objects.len()));
245
246 for record in objects {
247 let hash = match record.id {
248 PackObjectId::Hash(hash) => hash,
249 PackObjectId::StateId(_) | PackObjectId::AnnotatedTag(_) => {
250 let offset = pack_data.len() as u64;
251 index.add(record.id, offset);
252 *total_uncompressed += record.data.len() as u64;
253 let compressed = compress_pack_payload(&record.data, compression)?;
254 *total_compressed += compressed.len() as u64;
255 Self::write_entry(pack_data, &record, obj_type, &compressed)?;
256 continue;
257 }
258 };
259 let data = record.data;
260 let offset = pack_data.len() as u64;
261 index.add(PackObjectId::Hash(hash), offset);
262 *total_uncompressed += data.len() as u64;
263
264 let mut best_base_idx: Option<usize> = None;
266 let mut best_delta_estimate = usize::MAX;
267
268 if data.len() >= MIN_DELTA_SIZE {
269 for (i, entry) in window.iter().enumerate() {
270 if entry.chain_depth >= MAX_DELTA_CHAIN_DEPTH {
272 continue;
273 }
274 if entry.data.len() < MIN_DELTA_SIZE {
276 continue;
277 }
278
279 let estimate = DeltaEncoder::estimate_delta_size_with_index(
280 &entry.index,
281 &entry.data,
282 &data,
283 );
284
285 if estimate < best_delta_estimate {
286 best_delta_estimate = estimate;
287 best_base_idx = Some(i);
288 }
289 }
290 }
291
292 let (final_data, entry_type, base_hash, chain_depth) =
294 if let Some(base_idx) = best_base_idx {
295 let base_entry = &window[base_idx];
296 let delta =
297 DeltaEncoder::encode_with_index(&base_entry.index, &base_entry.data, &data);
298 let delta_compressed = compress_pack_payload(&delta, compression)?;
299
300 if delta_compressed.len() < data.len() {
301 *delta_count += 1;
302 let bh = base_entry.hash;
303 let depth = base_entry.chain_depth + 1;
304 (delta_compressed, ObjectType::Delta, Some(bh), depth)
305 } else {
306 let compressed = compress_pack_payload(&data, compression)?;
307 (compressed, obj_type, None, 0)
308 }
309 } else {
310 let compressed = compress_pack_payload(&data, compression)?;
311 (compressed, obj_type, None, 0)
312 };
313
314 *total_compressed += final_data.len() as u64;
315
316 Self::write_entry_parts(
317 pack_data,
318 PackObjectId::Hash(hash),
319 entry_type,
320 data.len(),
321 base_hash.map(PackObjectId::Hash),
322 &final_data,
323 )?;
324
325 if delta_window > 0 {
326 let entry_index = DeltaEncoder::build_index(&data);
328 if window.len() >= delta_window {
329 window.pop_front();
330 }
331 window.push_back(WindowEntry {
332 hash,
333 data,
334 index: entry_index,
335 chain_depth,
336 });
337 }
338 }
339
340 Ok(())
341 }
342
343 fn group_by_type(objects: Vec<PackObjectRecord>) -> GroupedPackMap {
344 let mut groups: GroupedPackMap = HashMap::new();
345
346 for record in objects {
347 groups.entry(record.obj_type).or_default().push(record);
348 }
349
350 groups
351 }
352
353 fn write_entry(
364 pack: &mut Vec<u8>,
365 record: &PackObjectRecord,
366 obj_type: ObjectType,
367 compressed: &[u8],
368 ) -> Result<()> {
369 encode_tagged_entry(pack, record, obj_type, compressed)
370 }
371
372 fn write_entry_parts(
373 pack: &mut Vec<u8>,
374 id: PackObjectId,
375 obj_type: ObjectType,
376 uncompressed_size: usize,
377 delta_base: Option<PackObjectId>,
378 compressed: &[u8],
379 ) -> Result<()> {
380 encode_tagged_entry_parts(
381 pack,
382 id,
383 obj_type,
384 uncompressed_size,
385 delta_base,
386 compressed,
387 )
388 }
389}