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, PackObjectId, PackObjectRecord, PackStats, append_container_checksum,
10    compress_pack_payload, encode_tagged_entry, encode_tagged_entry_parts, pack_container_spec,
11    pack_index::PackIndex, write_container_header,
12};
13use crate::{object::ContentHash, store::Result};
14
15const MIN_DELTA_SIZE: usize = 64;
16/// Maximum depth for delta chains (matches Git's default).
17const MAX_DELTA_CHAIN_DEPTH: usize = 50;
18/// Number of recent objects to try as delta bases (Git default: 10).
19const WINDOW_SIZE: usize = 10;
20
21type GroupedPackMap = HashMap<ObjectType, Vec<PackObjectRecord>>;
22
23/// Pack builder for creating packfiles.
24pub struct PackBuilder {
25    objects: Vec<PackObjectRecord>,
26    compression: CompressionConfig,
27}
28
29/// A recent object in the sliding window, with cached hash index for fast delta estimation.
30struct WindowEntry {
31    hash: ContentHash,
32    data: Vec<u8>,
33    index: HashMap<[u8; 4], Vec<usize>>,
34    chain_depth: usize,
35}
36
37impl PackBuilder {
38    /// Create a new pack builder.
39    pub fn new(compression: CompressionConfig) -> Self {
40        Self {
41            objects: Vec::new(),
42            compression,
43        }
44    }
45
46    /// Add an object to the pack.
47    pub fn add(&mut self, hash: ContentHash, obj_type: ObjectType, data: Vec<u8>) {
48        self.add_id(PackObjectId::Hash(hash), obj_type, data);
49    }
50
51    pub fn add_id(&mut self, id: PackObjectId, obj_type: ObjectType, data: Vec<u8>) {
52        self.objects.push(PackObjectRecord {
53            id,
54            obj_type,
55            data,
56            delta_base: None,
57            path_hint: None,
58        });
59    }
60
61    /// Add an object with a path hint for better delta grouping.
62    ///
63    /// Objects sharing the same path (e.g. successive versions of `src/main.rs`)
64    /// will be sorted together for delta encoding, producing much better
65    /// compression ratios than size-only ordering.
66    pub fn add_with_path(
67        &mut self,
68        hash: ContentHash,
69        obj_type: ObjectType,
70        data: Vec<u8>,
71        path: Option<String>,
72    ) {
73        self.add_with_path_id(PackObjectId::Hash(hash), obj_type, data, path);
74    }
75
76    pub fn add_with_path_id(
77        &mut self,
78        id: PackObjectId,
79        obj_type: ObjectType,
80        data: Vec<u8>,
81        path: Option<String>,
82    ) {
83        self.objects.push(PackObjectRecord {
84            id,
85            obj_type,
86            data,
87            delta_base: None,
88            path_hint: path,
89        });
90    }
91
92    /// Build the packfile and index.
93    ///
94    /// Returns the pack data, index data, and statistics.
95    pub fn build(self) -> Result<(Vec<u8>, Vec<u8>, PackStats)> {
96        let mut pack_data = Vec::new();
97        let mut index = PackIndex::new();
98
99        write_container_header(
100            &mut pack_data,
101            pack_container_spec(),
102            self.objects.len() as u64,
103        );
104
105        let mut total_uncompressed = 0u64;
106        let mut total_compressed = 0u64;
107        let mut delta_count = 0u64;
108
109        let object_count = self.objects.len() as u64;
110        let grouped = Self::group_by_type(self.objects);
111
112        for (obj_type, mut objects) in grouped {
113            if objects.len() < 2
114                || matches!(obj_type, ObjectType::State | ObjectType::StateAttachment)
115                || self.compression.max_delta_size == 0
116                || objects
117                    .iter()
118                    .all(|record| record.data.len() < MIN_DELTA_SIZE)
119            {
120                // Single objects, states, all-small groups, or transfer-tuned packs
121                // with delta disabled: write entries directly without constructing
122                // sliding-window indexes that cannot produce a delta.
123                for record in objects {
124                    let offset = pack_data.len() as u64;
125                    index.add(record.id, offset);
126
127                    total_uncompressed += record.data.len() as u64;
128                    let compressed = compress_pack_payload(&record.data, &self.compression)?;
129                    total_compressed += compressed.len() as u64;
130
131                    Self::write_entry(&mut pack_data, &record, obj_type, &compressed)?;
132                }
133            } else {
134                Self::sort_for_delta_window(&mut objects);
135                Self::encode_with_sliding_window(
136                    &mut pack_data,
137                    &mut index,
138                    &mut total_uncompressed,
139                    &mut total_compressed,
140                    &mut delta_count,
141                    obj_type,
142                    objects,
143                    &self.compression,
144                )?;
145            }
146        }
147
148        index.sort();
149
150        append_container_checksum(&mut pack_data);
151
152        let stats = PackStats {
153            object_count,
154            total_uncompressed,
155            total_compressed,
156            delta_count,
157            compression_ratio: total_compressed as f64 / total_uncompressed as f64,
158        };
159
160        Ok((pack_data, index.to_bytes(), stats))
161    }
162
163    /// Sort objects for optimal delta window traversal.
164    ///
165    /// Sorts by: file extension → basename → size descending.
166    /// This ensures files with the same extension are adjacent (like Git sorting
167    /// `.rs` files together), within that group same-named files are adjacent,
168    /// and within that the largest comes first (best delta base candidate).
169    fn sort_for_delta_window(objects: &mut [PackObjectRecord]) {
170        objects.sort_by(|a, b| {
171            let key_a = Self::sort_key(&a.path_hint);
172            let key_b = Self::sort_key(&b.path_hint);
173            key_a.cmp(&key_b).then(b.data.len().cmp(&a.data.len()))
174        });
175    }
176
177    /// Extract a sort key from a path: (extension, basename_without_extension).
178    /// Objects without paths sort last.
179    fn sort_key(path: &Option<String>) -> (String, String) {
180        match path {
181            Some(p) => {
182                let filename = p.rsplit('/').next().unwrap_or(p);
183                if let Some(dot_pos) = filename.rfind('.') {
184                    let ext = filename[dot_pos + 1..].to_string();
185                    let stem = filename[..dot_pos].to_string();
186                    (ext, stem)
187                } else {
188                    (String::new(), filename.to_string())
189                }
190            }
191            None => ("\u{FFFF}".to_string(), String::new()),
192        }
193    }
194
195    /// Encode objects using a sliding window for delta base selection.
196    ///
197    /// For each object, tries delta encoding against the W most recent objects
198    /// in the window, picking the base that produces the smallest delta. This
199    /// is the same approach Git uses with `--window=10`.
200    #[allow(clippy::too_many_arguments)]
201    fn encode_with_sliding_window(
202        pack_data: &mut Vec<u8>,
203        index: &mut PackIndex,
204        total_uncompressed: &mut u64,
205        total_compressed: &mut u64,
206        delta_count: &mut u64,
207        obj_type: ObjectType,
208        objects: Vec<PackObjectRecord>,
209        compression: &CompressionConfig,
210    ) -> Result<()> {
211        let mut window: VecDeque<WindowEntry> = VecDeque::with_capacity(WINDOW_SIZE);
212
213        for record in objects {
214            let hash = match record.id {
215                PackObjectId::Hash(hash) => hash,
216                PackObjectId::StateId(_) => {
217                    let offset = pack_data.len() as u64;
218                    index.add(record.id, offset);
219                    *total_uncompressed += record.data.len() as u64;
220                    let compressed = compress_pack_payload(&record.data, compression)?;
221                    *total_compressed += compressed.len() as u64;
222                    Self::write_entry(pack_data, &record, obj_type, &compressed)?;
223                    continue;
224                }
225            };
226            let data = record.data;
227            let offset = pack_data.len() as u64;
228            index.add(PackObjectId::Hash(hash), offset);
229            *total_uncompressed += data.len() as u64;
230
231            // Try delta against each window entry, pick the best
232            let mut best_base_idx: Option<usize> = None;
233            let mut best_delta_estimate = usize::MAX;
234
235            if data.len() >= MIN_DELTA_SIZE {
236                for (i, entry) in window.iter().enumerate() {
237                    // Skip if this base is already at max chain depth
238                    if entry.chain_depth >= MAX_DELTA_CHAIN_DEPTH {
239                        continue;
240                    }
241                    // Skip if base is too small
242                    if entry.data.len() < MIN_DELTA_SIZE {
243                        continue;
244                    }
245
246                    let estimate = DeltaEncoder::estimate_delta_size_with_index(
247                        &entry.index,
248                        &entry.data,
249                        &data,
250                    );
251
252                    if estimate < best_delta_estimate {
253                        best_delta_estimate = estimate;
254                        best_base_idx = Some(i);
255                    }
256                }
257            }
258
259            // Decide: delta or raw?
260            let (final_data, entry_type, base_hash, chain_depth) =
261                if let Some(base_idx) = best_base_idx {
262                    let base_entry = &window[base_idx];
263                    let delta =
264                        DeltaEncoder::encode_with_index(&base_entry.index, &base_entry.data, &data);
265                    let delta_compressed = compress_pack_payload(&delta, compression)?;
266
267                    if delta_compressed.len() < data.len() {
268                        *delta_count += 1;
269                        let bh = base_entry.hash;
270                        let depth = base_entry.chain_depth + 1;
271                        (delta_compressed, ObjectType::Delta, Some(bh), depth)
272                    } else {
273                        let compressed = compress_pack_payload(&data, compression)?;
274                        (compressed, obj_type, None, 0)
275                    }
276                } else {
277                    let compressed = compress_pack_payload(&data, compression)?;
278                    (compressed, obj_type, None, 0)
279                };
280
281            *total_compressed += final_data.len() as u64;
282
283            Self::write_entry_parts(
284                pack_data,
285                PackObjectId::Hash(hash),
286                entry_type,
287                data.len(),
288                base_hash.map(PackObjectId::Hash),
289                &final_data,
290            )?;
291
292            // Add to window (build index once, reuse for all future comparisons)
293            let entry_index = DeltaEncoder::build_index(&data);
294            if window.len() >= WINDOW_SIZE {
295                window.pop_front();
296            }
297            window.push_back(WindowEntry {
298                hash,
299                data,
300                index: entry_index,
301                chain_depth,
302            });
303        }
304
305        Ok(())
306    }
307
308    fn group_by_type(objects: Vec<PackObjectRecord>) -> GroupedPackMap {
309        let mut groups: GroupedPackMap = HashMap::new();
310
311        for record in objects {
312            groups.entry(record.obj_type).or_default().push(record);
313        }
314
315        groups
316    }
317
318    /// Write a pack entry with varint-encoded sizes.
319    ///
320    /// Format per entry:
321    /// ```text
322    /// [tagged_id][type+uncompressed_size: varint][compressed_size: varint]
323    /// [tagged_base_id (delta only)][compressed_data]
324    /// ```
325    ///
326    /// The compressed data is raw zstd — no wrapper header — since the
327    /// entry already records both sizes.
328    fn write_entry(
329        pack: &mut Vec<u8>,
330        record: &PackObjectRecord,
331        obj_type: ObjectType,
332        compressed: &[u8],
333    ) -> Result<()> {
334        encode_tagged_entry(pack, record, obj_type, compressed)
335    }
336
337    fn write_entry_parts(
338        pack: &mut Vec<u8>,
339        id: PackObjectId,
340        obj_type: ObjectType,
341        uncompressed_size: usize,
342        delta_base: Option<PackObjectId>,
343        compressed: &[u8],
344    ) -> Result<()> {
345        encode_tagged_entry_parts(
346            pack,
347            id,
348            obj_type,
349            uncompressed_size,
350            delta_base,
351            compressed,
352        )
353    }
354}