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 builder for creating packfiles.
25pub struct PackBuilder {
26    objects: Vec<PackObjectRecord>,
27    compression: CompressionConfig,
28    delta_window: usize,
29}
30
31/// A recent object in the sliding window, with cached hash index for fast delta estimation.
32struct WindowEntry {
33    hash: ContentHash,
34    data: Vec<u8>,
35    index: HashMap<[u8; 4], Vec<usize>>,
36    chain_depth: usize,
37}
38
39impl PackBuilder {
40    /// Create a new pack builder.
41    pub fn new(compression: CompressionConfig) -> Self {
42        Self {
43            objects: Vec::new(),
44            compression,
45            delta_window: DEFAULT_DELTA_WINDOW,
46        }
47    }
48
49    /// Create a pack builder tuned for repacking an existing object corpus.
50    ///
51    /// `delta_window` controls how many recently sorted objects are considered
52    /// as delta bases. A zero-sized window disables delta-base selection.
53    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    /// Add an object to the pack.
62    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    /// Add an object with a path hint for better delta grouping.
77    ///
78    /// Objects sharing the same path (e.g. successive versions of `src/main.rs`)
79    /// will be sorted together for delta encoding, producing much better
80    /// compression ratios than size-only ordering.
81    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    /// Compute the logical identity of the objects currently in this builder.
108    ///
109    /// Path hints, compression, output order, and later delta-base selection do
110    /// not participate in this root-spool-scoped identity.
111    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    /// Build the packfile and index.
120    ///
121    /// Returns the pack data, index data, and statistics.
122    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                // Single objects, states, all-small groups, or transfer-tuned packs
151                // with delta disabled: write entries directly without constructing
152                // sliding-window indexes that cannot produce a delta.
153                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    /// Sort objects for optimal delta window traversal.
195    ///
196    /// Sorts by: file extension → basename → size descending.
197    /// This ensures files with the same extension are adjacent (like Git sorting
198    /// `.rs` files together), within that group same-named files are adjacent,
199    /// and within that the largest comes first (best delta base candidate).
200    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    /// Extract a sort key from a path: (extension, basename_without_extension).
209    /// Objects without paths sort last.
210    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    /// Encode objects using a sliding window for delta base selection.
227    ///
228    /// For each object, tries delta encoding against the W most recent objects
229    /// in the window, picking the base that produces the smallest delta. This
230    /// is the same approach Git uses with `--window=10`.
231    #[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            // Try delta against each window entry, pick the best
265            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                    // Skip if this base is already at max chain depth
271                    if entry.chain_depth >= MAX_DELTA_CHAIN_DEPTH {
272                        continue;
273                    }
274                    // Skip if base is too small
275                    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            // Decide: delta or raw?
293            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                // Build the index once, then reuse it for all future comparisons.
327                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    /// Write a pack entry with varint-encoded sizes.
354    ///
355    /// Format per entry:
356    /// ```text
357    /// [tagged_id][type+uncompressed_size: varint][compressed_size: varint]
358    /// [tagged_base_id (delta only)][compressed_data]
359    /// ```
360    ///
361    /// The compressed data is raw zstd — no wrapper header — since the
362    /// entry already records both sizes.
363    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}