Skip to main content

heddle_pack/store/pack/
pack_builder.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pack builder for creating packfiles.
3
4use 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;
17/// Maximum depth for delta chains (matches Git's default).
18const MAX_DELTA_CHAIN_DEPTH: usize = 50;
19/// Default number of recent objects to try as delta bases (Git default: 10).
20const DEFAULT_DELTA_WINDOW: usize = 10;
21
22type GroupedPackMap = HashMap<ObjectType, Vec<PackObjectRecord>>;
23
24/// Pack bytes, index bytes, statistics, and the original uncompressed inputs.
25pub type RetainedPackBuild = (
26    Vec<u8>,
27    Vec<u8>,
28    PackStats,
29    Vec<(PackObjectId, ObjectType, Vec<u8>)>,
30);
31
32/// Pack builder for creating packfiles.
33pub struct PackBuilder {
34    objects: Vec<PackObjectRecord>,
35    compression: CompressionConfig,
36    delta_window: usize,
37}
38
39/// A recent object in the sliding window, with cached hash index for fast delta estimation.
40struct WindowEntry {
41    hash: ContentHash,
42    data: Vec<u8>,
43    index: HashMap<[u8; 4], Vec<usize>>,
44    chain_depth: usize,
45}
46
47impl PackBuilder {
48    /// Create a new pack builder.
49    pub fn new(compression: CompressionConfig) -> Self {
50        Self {
51            objects: Vec::new(),
52            compression,
53            delta_window: DEFAULT_DELTA_WINDOW,
54        }
55    }
56
57    /// Create a pack builder tuned for repacking an existing object corpus.
58    ///
59    /// `delta_window` controls how many recently sorted objects are considered
60    /// as delta bases. A zero-sized window disables delta-base selection.
61    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    /// Add an object to the pack.
70    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    /// Add an object with a path hint for better delta grouping.
85    ///
86    /// Objects sharing the same path (e.g. successive versions of `src/main.rs`)
87    /// will be sorted together for delta encoding, producing much better
88    /// compression ratios than size-only ordering.
89    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    /// Compute the logical identity of the objects currently in this builder.
116    ///
117    /// Path hints, compression, output order, and later delta-base selection do
118    /// not participate in this root-spool-scoped identity.
119    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    /// Build the packfile and index.
128    ///
129    /// Returns the pack data, index data, and statistics.
130    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    /// Build the packfile and return ownership of the uncompressed inputs.
136    ///
137    /// This is useful for callers that need to populate a decoded-object cache
138    /// after durable installation. Returning the original buffers avoids
139    /// cloning every payload before the build merely to keep it alive.
140    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                // Single objects, states, all-small groups, or transfer-tuned packs
178                // with delta disabled: write entries directly without constructing
179                // sliding-window indexes that cannot produce a delta.
180                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    /// Sort objects for optimal delta window traversal.
226    ///
227    /// Sorts by: file extension → basename → size descending.
228    /// This ensures files with the same extension are adjacent (like Git sorting
229    /// `.rs` files together), within that group same-named files are adjacent,
230    /// and within that the largest comes first (best delta base candidate).
231    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    /// Extract a sort key from a path: (extension, basename_without_extension).
240    /// Objects without paths sort last.
241    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    /// Encode objects using a sliding window for delta base selection.
258    ///
259    /// For each object, tries delta encoding against the W most recent objects
260    /// in the window, picking the base that produces the smallest delta. This
261    /// is the same approach Git uses with `--window=10`.
262    #[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            // Try delta against each window entry, pick the best
302            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                    // Skip if this base is already at max chain depth
308                    if entry.chain_depth >= MAX_DELTA_CHAIN_DEPTH {
309                        continue;
310                    }
311                    // Skip if base is too small
312                    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            // Decide: delta or raw?
330            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                // Build the index once, then reuse it for all future comparisons.
364                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    /// Write a pack entry with varint-encoded sizes.
407    ///
408    /// Format per entry:
409    /// ```text
410    /// [tagged_id][type+uncompressed_size: varint][compressed_size: varint]
411    /// [tagged_base_id (delta only)][compressed_data]
412    /// ```
413    ///
414    /// The compressed data is raw zstd — no wrapper header — since the
415    /// entry already records both sizes.
416    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}