Skip to main content

uuencoding_multi/
collection.rs

1use std::collections::BTreeMap;
2
3use crate::MultiUuError;
4
5/// A single collected part awaiting reassembly.
6///
7/// The caller is responsible for extracting `body_bytes` from the MIME or
8/// plain-text message layer before constructing a `PartEntry`. This crate
9/// treats `body_bytes` as an opaque UU-encoded byte sequence and passes it
10/// directly to the `uuencoding` decoder.
11///
12/// # Example
13///
14/// ```
15/// use uuencoding_multi::PartEntry;
16///
17/// let entry = PartEntry {
18///     part_number: 1,
19///     body_bytes: b"begin 644 file.bin\nend\n".to_vec(),
20///     subject: Some("myfile.bin (1/3)".to_string()),
21/// };
22/// assert_eq!(entry.part_number, 1);
23/// ```
24#[derive(Clone)]
25pub struct PartEntry {
26    /// 1-based part index; 0 = TOC post.
27    ///
28    /// The value `0` is reserved for the optional table-of-contents post that
29    /// Usenet series sometimes include as the first message. TOC parts do not
30    /// contribute to the sequential `1..=total` count used during reassembly.
31    pub part_number: u32,
32    /// Raw bytes of this part's UU body, already extracted from the MIME layer
33    /// by the caller. Passed verbatim to `uuencoding::decode` during reassembly.
34    pub body_bytes: Vec<u8>,
35    /// Original `Subject` header value, kept for diagnostics and logging.
36    /// Not used during reassembly.
37    pub subject: Option<String>,
38}
39
40/// Ordered, gap-aware collection of [`PartEntry`] values.
41///
42/// Parts are keyed by `part_number` and stored in a [`BTreeMap`] so iteration
43/// is always in ascending order. A declared total can be provided up front via
44/// [`PartCollection::with_total`]; if not, the collection tracks the highest
45/// non-TOC part number it has seen so that
46/// [`missing_parts`][Self::missing_parts] still works once a total is implied
47/// by the highest part observed.
48///
49/// # Example: collecting three parts
50///
51/// ```
52/// use uuencoding_multi::{PartCollection, PartEntry};
53///
54/// let mut coll = PartCollection::with_total(3);
55/// for n in [1u32, 2, 3] {
56///     coll.add(PartEntry { part_number: n, body_bytes: vec![], subject: None }).unwrap();
57/// }
58/// assert!(coll.is_complete());
59/// ```
60#[derive(Clone)]
61pub struct PartCollection {
62    /// Keyed by part_number.
63    parts: BTreeMap<u32, PartEntry>,
64    /// Declared total, if known. Updated upward as parts arrive if their
65    /// part_number exceeds the current value.
66    total: Option<u32>,
67}
68
69impl PartCollection {
70    /// Create an empty collection with no declared total.
71    ///
72    /// The total will be inferred from the highest non-TOC `part_number` added
73    /// via [`add`][Self::add]. Use [`with_total`][Self::with_total] when the
74    /// total is known in advance (e.g. extracted from the subject line).
75    ///
76    /// # Warning: `is_complete()` after a single `add`
77    ///
78    /// Because the total is inferred from the highest part seen, adding a
79    /// single part (e.g. part 1) immediately sets `total = Some(1)` and
80    /// causes [`is_complete`][Self::is_complete] to return `true`. If the
81    /// actual series has more parts, this is a false positive. Prefer
82    /// [`with_total`][Self::with_total] whenever the total is known.
83    ///
84    /// # Example
85    ///
86    /// ```
87    /// use uuencoding_multi::PartCollection;
88    ///
89    /// let coll = PartCollection::new();
90    /// assert!(coll.is_empty());
91    /// assert_eq!(coll.total(), None);
92    /// ```
93    pub fn new() -> Self {
94        Self {
95            parts: BTreeMap::new(),
96            total: None,
97        }
98    }
99
100    /// Create an empty collection with a pre-declared total.
101    ///
102    /// The `total` value sets the upper bound for gap detection: any part
103    /// number in `1..=total` that has not been added will appear in
104    /// [`missing_parts`][Self::missing_parts].
105    ///
106    /// If a later call to [`add`][Self::add] supplies a `part_number` greater
107    /// than `total`, the stored total is bumped upward automatically.
108    ///
109    /// # Note: `total = 0`
110    ///
111    /// Passing `total = 0` sets the expected part range to `1..=0`, which is
112    /// empty. As a result [`missing_parts`][Self::missing_parts] returns `[]`
113    /// and [`is_complete`][Self::is_complete] returns `true` immediately,
114    /// before any parts are added. If you do not yet know the total, use
115    /// [`new`][Self::new] instead.
116    ///
117    /// # Example
118    ///
119    /// ```
120    /// use uuencoding_multi::PartCollection;
121    ///
122    /// let coll = PartCollection::with_total(7);
123    /// assert_eq!(coll.total(), Some(7));
124    /// assert_eq!(coll.missing_parts().len(), 7); // parts 1–7 all missing
125    /// ```
126    pub fn with_total(total: u32) -> Self {
127        Self {
128            parts: BTreeMap::new(),
129            total: Some(total),
130        }
131    }
132
133    /// Add a part to the collection.
134    ///
135    /// Returns [`MultiUuError::DuplicatePart`] if a part with the same
136    /// `part_number` is already present. The collection is left unchanged on
137    /// error.
138    ///
139    /// If the incoming `part_number` is greater than the current `total`, the
140    /// stored total is bumped upward so that [`missing_parts`][Self::missing_parts]
141    /// always covers every number up to the highest seen. TOC parts
142    /// (`part_number = 0`) do not affect the total.
143    ///
144    /// # Errors
145    ///
146    /// Returns [`MultiUuError::DuplicatePart`] when `part_number` is already
147    /// present in the collection.
148    ///
149    /// # Example
150    ///
151    /// ```
152    /// use uuencoding_multi::{PartCollection, PartEntry, MultiUuError};
153    ///
154    /// let mut coll = PartCollection::new();
155    /// coll.add(PartEntry { part_number: 1, body_bytes: vec![], subject: None }).unwrap();
156    ///
157    /// // Adding the same part number again is an error.
158    /// let err = coll.add(PartEntry { part_number: 1, body_bytes: vec![], subject: None })
159    ///     .unwrap_err();
160    /// assert!(matches!(err, MultiUuError::DuplicatePart { part_number: 1 }));
161    /// ```
162    pub fn add(&mut self, entry: PartEntry) -> Result<(), MultiUuError> {
163        let pn = entry.part_number;
164        if self.parts.contains_key(&pn) {
165            return Err(MultiUuError::DuplicatePart { part_number: pn });
166        }
167        // Keep total at least as large as the highest part_number seen (for
168        // non-TOC parts only — part 0 is the TOC and does not count toward the
169        // sequential total).
170        if pn > 0 {
171            self.total = Some(match self.total {
172                Some(t) => t.max(pn),
173                None => pn,
174            });
175        }
176        self.parts.insert(pn, entry);
177        Ok(())
178    }
179
180    /// Returns the declared total part count, or the highest non-TOC part
181    /// number seen if no explicit total was provided.
182    ///
183    /// Returns `None` only when the collection is empty and no total was set.
184    ///
185    /// # Example
186    ///
187    /// ```
188    /// use uuencoding_multi::{PartCollection, PartEntry};
189    ///
190    /// let mut coll = PartCollection::new();
191    /// assert_eq!(coll.total(), None);
192    ///
193    /// coll.add(PartEntry { part_number: 3, body_bytes: vec![], subject: None }).unwrap();
194    /// assert_eq!(coll.total(), Some(3)); // inferred from highest part seen
195    /// ```
196    pub fn total(&self) -> Option<u32> {
197        self.total
198    }
199
200    /// Iterator over the part numbers that are present, in ascending order.
201    ///
202    /// Includes the TOC part (`part_number = 0`) if one was added.
203    ///
204    /// # Example
205    ///
206    /// ```
207    /// use uuencoding_multi::{PartCollection, PartEntry};
208    ///
209    /// let mut coll = PartCollection::new();
210    /// coll.add(PartEntry { part_number: 3, body_bytes: vec![], subject: None }).unwrap();
211    /// coll.add(PartEntry { part_number: 1, body_bytes: vec![], subject: None }).unwrap();
212    ///
213    /// let present: Vec<u32> = coll.present_parts().collect();
214    /// assert_eq!(present, vec![1, 3]); // always ascending
215    /// ```
216    pub fn present_parts(&self) -> impl Iterator<Item = u32> + '_ {
217        self.parts.keys().copied()
218    }
219
220    /// Sorted list of part numbers in `1..=total` that are absent from the
221    /// collection.
222    ///
223    /// Returns an empty `Vec` when `total` is `None`.
224    ///
225    /// # Example
226    ///
227    /// ```
228    /// use uuencoding_multi::{PartCollection, PartEntry};
229    ///
230    /// let mut coll = PartCollection::with_total(4);
231    /// coll.add(PartEntry { part_number: 1, body_bytes: vec![], subject: None }).unwrap();
232    /// coll.add(PartEntry { part_number: 3, body_bytes: vec![], subject: None }).unwrap();
233    ///
234    /// assert_eq!(coll.missing_parts(), vec![2, 4]);
235    /// ```
236    pub fn missing_parts(&self) -> Vec<u32> {
237        match self.total {
238            None => vec![],
239            Some(t) => (1..=t).filter(|n| !self.parts.contains_key(n)).collect(),
240        }
241    }
242
243    /// Returns `true` iff the total is known and every part in `1..=total` is
244    /// present.
245    ///
246    /// Always returns `false` when `total` is `None`, even if parts have been
247    /// added.
248    ///
249    /// # Warning: auto-inferred total
250    ///
251    /// When a collection was created with [`new`][Self::new] (no declared
252    /// total), the total is inferred as the highest part number seen. Adding
253    /// only part 1 sets `total = Some(1)` and this function immediately returns
254    /// `true`, even if the series actually has more parts. Use
255    /// [`with_total`][Self::with_total] to set the authoritative total.
256    ///
257    /// # Example
258    ///
259    /// ```
260    /// use uuencoding_multi::{PartCollection, PartEntry};
261    ///
262    /// let mut coll = PartCollection::with_total(2);
263    /// coll.add(PartEntry { part_number: 1, body_bytes: vec![], subject: None }).unwrap();
264    /// assert!(!coll.is_complete());
265    ///
266    /// coll.add(PartEntry { part_number: 2, body_bytes: vec![], subject: None }).unwrap();
267    /// assert!(coll.is_complete());
268    /// ```
269    pub fn is_complete(&self) -> bool {
270        match self.total {
271            None => false,
272            Some(_) => self.missing_parts().is_empty(),
273        }
274    }
275
276    /// Returns the TOC part (part number 0) if one was added, `None` otherwise.
277    ///
278    /// The TOC part body can be passed to [`parse_toc`][crate::parse_toc] to
279    /// extract file metadata.
280    ///
281    /// # Example
282    ///
283    /// ```
284    /// use uuencoding_multi::{PartCollection, PartEntry};
285    ///
286    /// let mut coll = PartCollection::new();
287    /// assert!(coll.toc_part().is_none());
288    ///
289    /// coll.add(PartEntry { part_number: 0, body_bytes: b"toc".to_vec(), subject: None }).unwrap();
290    /// assert!(coll.toc_part().is_some());
291    /// ```
292    pub fn toc_part(&self) -> Option<&PartEntry> {
293        self.parts.get(&0)
294    }
295
296    /// Look up a part by its part number.
297    ///
298    /// Returns `None` if the part is not present in the collection. Valid for
299    /// any part number including `0` (TOC).
300    ///
301    /// # Example
302    ///
303    /// ```
304    /// use uuencoding_multi::{PartCollection, PartEntry};
305    ///
306    /// let mut coll = PartCollection::new();
307    /// coll.add(PartEntry { part_number: 2, body_bytes: b"data".to_vec(), subject: None }).unwrap();
308    ///
309    /// assert!(coll.get(2).is_some());
310    /// assert!(coll.get(1).is_none());
311    /// ```
312    pub fn get(&self, part_number: u32) -> Option<&PartEntry> {
313        self.parts.get(&part_number)
314    }
315
316    /// Number of entries currently in the collection, including the TOC part
317    /// (`part_number = 0`) if present.
318    ///
319    /// # Example
320    ///
321    /// ```
322    /// use uuencoding_multi::{PartCollection, PartEntry};
323    ///
324    /// let mut coll = PartCollection::new();
325    /// assert_eq!(coll.len(), 0);
326    ///
327    /// coll.add(PartEntry { part_number: 1, body_bytes: vec![], subject: None }).unwrap();
328    /// assert_eq!(coll.len(), 1);
329    /// ```
330    pub fn len(&self) -> usize {
331        self.parts.len()
332    }
333
334    /// Returns `true` iff no parts have been added.
335    ///
336    /// # Example
337    ///
338    /// ```
339    /// use uuencoding_multi::PartCollection;
340    ///
341    /// let coll = PartCollection::new();
342    /// assert!(coll.is_empty());
343    /// ```
344    pub fn is_empty(&self) -> bool {
345        self.parts.is_empty()
346    }
347}
348
349impl Default for PartCollection {
350    fn default() -> Self {
351        Self::new()
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    fn part(n: u32) -> PartEntry {
360        PartEntry {
361            part_number: n,
362            body_bytes: vec![],
363            subject: None,
364        }
365    }
366
367    #[test]
368    fn out_of_order_insertion_sorted() {
369        let mut c = PartCollection::new();
370        c.add(PartEntry {
371            part_number: 3,
372            body_bytes: vec![],
373            subject: None,
374        })
375        .unwrap();
376        c.add(PartEntry {
377            part_number: 1,
378            body_bytes: vec![],
379            subject: None,
380        })
381        .unwrap();
382        let got: Vec<u32> = c.present_parts().collect();
383        assert_eq!(got, vec![1, 3]);
384    }
385
386    #[test]
387    fn gap_detection() {
388        let mut c = PartCollection::with_total(4);
389        c.add(part(1)).unwrap();
390        c.add(part(2)).unwrap();
391        c.add(part(4)).unwrap();
392        assert_eq!(c.missing_parts(), vec![3]);
393    }
394
395    #[test]
396    fn duplicate_returns_error() {
397        let mut c = PartCollection::new();
398        c.add(part(1)).unwrap();
399        assert!(matches!(
400            c.add(part(1)),
401            Err(MultiUuError::DuplicatePart { part_number: 1 })
402        ));
403    }
404
405    #[test]
406    fn is_complete_when_all_present() {
407        let mut c = PartCollection::with_total(2);
408        c.add(part(1)).unwrap();
409        c.add(part(2)).unwrap();
410        assert!(c.is_complete());
411    }
412
413    #[test]
414    fn is_complete_false_when_total_unknown() {
415        let mut c = PartCollection::new();
416        c.add(part(1)).unwrap();
417        // total gets set to 1 automatically (highest seen), so we need to
418        // verify is_complete() returns true when total==1 and part 1 is present.
419        // The test intent from the bead: "false when total unknown" — but with
420        // our auto-bump logic total becomes known. Add a second part expectation
421        // to create a genuine gap instead.
422        let mut c2 = PartCollection::new();
423        c2.add(part(1)).unwrap();
424        c2.add(part(3)).unwrap(); // total bumps to 3; part 2 is missing
425        assert!(!c2.is_complete());
426    }
427
428    #[test]
429    fn toc_part_returned() {
430        let mut c = PartCollection::new();
431        c.add(PartEntry {
432            part_number: 0,
433            body_bytes: b"toc".to_vec(),
434            subject: None,
435        })
436        .unwrap();
437        assert!(c.toc_part().is_some());
438    }
439
440    #[test]
441    fn len_and_is_empty() {
442        let mut c = PartCollection::new();
443        assert!(c.is_empty());
444        assert_eq!(c.len(), 0);
445        c.add(part(1)).unwrap();
446        assert!(!c.is_empty());
447        assert_eq!(c.len(), 1);
448    }
449
450    #[test]
451    fn missing_parts_empty_when_no_total() {
452        let c = PartCollection::new();
453        assert_eq!(c.missing_parts(), vec![] as Vec<u32>);
454    }
455
456    #[test]
457    fn with_total_sets_total() {
458        let c = PartCollection::with_total(5);
459        assert_eq!(c.total(), Some(5));
460    }
461
462    #[test]
463    fn add_bumps_total_upward() {
464        let mut c = PartCollection::with_total(3);
465        c.add(part(5)).unwrap(); // exceeds declared total
466        assert_eq!(c.total(), Some(5));
467    }
468
469    #[test]
470    fn toc_does_not_affect_total() {
471        let mut c = PartCollection::new();
472        c.add(PartEntry {
473            part_number: 0,
474            body_bytes: vec![],
475            subject: None,
476        })
477        .unwrap();
478        // Part 0 (TOC) must not cause total to be set to 0.
479        assert_eq!(c.total(), None);
480    }
481
482    #[test]
483    fn default_is_same_as_new() {
484        let c: PartCollection = Default::default();
485        assert!(c.is_empty());
486        assert_eq!(c.total(), None);
487    }
488}