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