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, Debug)]
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, Debug)]
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` is treated the same as calling [`new`][Self::new]:
112    /// the total is considered unknown and will be inferred from the highest
113    /// part number added. A subject like `"file.bin (3/0)"` produces
114    /// `part_total = Some(0)`, which is nonsensical; passing that value here
115    /// is safe because `0` is silently normalised to "unknown".
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    ///
127    /// ```
128    /// use uuencoding_multi::PartCollection;
129    ///
130    /// // total = 0 is treated as unknown, same as PartCollection::new().
131    /// let coll = PartCollection::with_total(0);
132    /// assert_eq!(coll.total(), None);
133    /// assert!(!coll.is_complete());
134    /// ```
135    pub fn with_total(total: u32) -> Self {
136        Self {
137            parts: BTreeMap::new(),
138            total: if total == 0 { None } else { Some(total) },
139        }
140    }
141
142    /// Add a part to the collection.
143    ///
144    /// Returns [`MultiUuError::DuplicatePart`] if a part with the same
145    /// `part_number` is already present. The collection is left unchanged on
146    /// error.
147    ///
148    /// If the incoming `part_number` is greater than the current `total`, the
149    /// stored total is bumped upward so that [`missing_parts`][Self::missing_parts]
150    /// always covers every number up to the highest seen. TOC parts
151    /// (`part_number = 0`) do not affect the total.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`MultiUuError::DuplicatePart`] when `part_number` is already
156    /// present in the collection.
157    ///
158    /// # Example
159    ///
160    /// ```
161    /// use uuencoding_multi::{PartCollection, PartEntry, MultiUuError};
162    ///
163    /// let mut coll = PartCollection::new();
164    /// coll.add(PartEntry { part_number: 1, body_bytes: vec![], subject: None }).unwrap();
165    ///
166    /// // Adding the same part number again is an error.
167    /// let err = coll.add(PartEntry { part_number: 1, body_bytes: vec![], subject: None })
168    ///     .unwrap_err();
169    /// assert!(matches!(err, MultiUuError::DuplicatePart { part_number: 1 }));
170    /// ```
171    pub fn add(&mut self, entry: PartEntry) -> Result<(), MultiUuError> {
172        let pn = entry.part_number;
173        if self.parts.contains_key(&pn) {
174            return Err(MultiUuError::DuplicatePart { part_number: pn });
175        }
176        // Keep total at least as large as the highest part_number seen (for
177        // non-TOC parts only — part 0 is the TOC and does not count toward the
178        // sequential total).
179        if pn > 0 {
180            self.total = Some(match self.total {
181                Some(t) => t.max(pn),
182                None => pn,
183            });
184        }
185        self.parts.insert(pn, entry);
186        Ok(())
187    }
188
189    /// Returns the declared total part count, or the highest non-TOC part
190    /// number seen if no explicit total was provided.
191    ///
192    /// Returns `None` only when the collection is empty and no total was set.
193    ///
194    /// # Example
195    ///
196    /// ```
197    /// use uuencoding_multi::{PartCollection, PartEntry};
198    ///
199    /// let mut coll = PartCollection::new();
200    /// assert_eq!(coll.total(), None);
201    ///
202    /// coll.add(PartEntry { part_number: 3, body_bytes: vec![], subject: None }).unwrap();
203    /// assert_eq!(coll.total(), Some(3)); // inferred from highest part seen
204    /// ```
205    pub fn total(&self) -> Option<u32> {
206        self.total
207    }
208
209    /// Iterator over the part numbers that are present, in ascending order.
210    ///
211    /// Includes the TOC part (`part_number = 0`) if one was added.
212    ///
213    /// # Example
214    ///
215    /// ```
216    /// use uuencoding_multi::{PartCollection, PartEntry};
217    ///
218    /// let mut coll = PartCollection::new();
219    /// coll.add(PartEntry { part_number: 3, body_bytes: vec![], subject: None }).unwrap();
220    /// coll.add(PartEntry { part_number: 1, body_bytes: vec![], subject: None }).unwrap();
221    ///
222    /// let present: Vec<u32> = coll.present_parts().collect();
223    /// assert_eq!(present, vec![1, 3]); // always ascending
224    /// ```
225    pub fn present_parts(&self) -> impl Iterator<Item = u32> + '_ {
226        self.parts.keys().copied()
227    }
228
229    /// Sorted list of part numbers in `1..=total` that are absent from the
230    /// collection.
231    ///
232    /// Returns an empty `Vec` when `total` is `None`.
233    ///
234    /// # Example
235    ///
236    /// ```
237    /// use uuencoding_multi::{PartCollection, PartEntry};
238    ///
239    /// let mut coll = PartCollection::with_total(4);
240    /// coll.add(PartEntry { part_number: 1, body_bytes: vec![], subject: None }).unwrap();
241    /// coll.add(PartEntry { part_number: 3, body_bytes: vec![], subject: None }).unwrap();
242    ///
243    /// assert_eq!(coll.missing_parts(), vec![2, 4]);
244    /// ```
245    pub fn missing_parts(&self) -> Vec<u32> {
246        match self.total {
247            None => vec![],
248            Some(t) => (1..=t).filter(|n| !self.parts.contains_key(n)).collect(),
249        }
250    }
251
252    /// Returns `true` iff the total is known and every part in `1..=total` is
253    /// present.
254    ///
255    /// Always returns `false` when `total` is `None`, even if parts have been
256    /// added.
257    ///
258    /// # Warning: auto-inferred total
259    ///
260    /// When a collection was created with [`new`][Self::new] (no declared
261    /// total), the total is inferred as the highest part number seen. Adding
262    /// only part 1 sets `total = Some(1)` and this function immediately returns
263    /// `true`, even if the series actually has more parts. Use
264    /// [`with_total`][Self::with_total] to set the authoritative total.
265    ///
266    /// # Example
267    ///
268    /// ```
269    /// use uuencoding_multi::{PartCollection, PartEntry};
270    ///
271    /// let mut coll = PartCollection::with_total(2);
272    /// coll.add(PartEntry { part_number: 1, body_bytes: vec![], subject: None }).unwrap();
273    /// assert!(!coll.is_complete());
274    ///
275    /// coll.add(PartEntry { part_number: 2, body_bytes: vec![], subject: None }).unwrap();
276    /// assert!(coll.is_complete());
277    /// ```
278    pub fn is_complete(&self) -> bool {
279        match self.total {
280            None => false,
281            Some(_) => self.missing_parts().is_empty(),
282        }
283    }
284
285    /// Returns the TOC part (part number 0) if one was added, `None` otherwise.
286    ///
287    /// The TOC part body can be passed to [`parse_toc`][crate::parse_toc] to
288    /// extract file metadata.
289    ///
290    /// # Example
291    ///
292    /// ```
293    /// use uuencoding_multi::{PartCollection, PartEntry};
294    ///
295    /// let mut coll = PartCollection::new();
296    /// assert!(coll.toc_part().is_none());
297    ///
298    /// coll.add(PartEntry { part_number: 0, body_bytes: b"toc".to_vec(), subject: None }).unwrap();
299    /// assert!(coll.toc_part().is_some());
300    /// ```
301    pub fn toc_part(&self) -> Option<&PartEntry> {
302        self.parts.get(&0)
303    }
304
305    /// Look up a part by its part number.
306    ///
307    /// Returns `None` if the part is not present in the collection. Valid for
308    /// any part number including `0` (TOC).
309    ///
310    /// # Example
311    ///
312    /// ```
313    /// use uuencoding_multi::{PartCollection, PartEntry};
314    ///
315    /// let mut coll = PartCollection::new();
316    /// coll.add(PartEntry { part_number: 2, body_bytes: b"data".to_vec(), subject: None }).unwrap();
317    ///
318    /// assert!(coll.get(2).is_some());
319    /// assert!(coll.get(1).is_none());
320    /// ```
321    pub fn get(&self, part_number: u32) -> Option<&PartEntry> {
322        self.parts.get(&part_number)
323    }
324
325    /// Number of entries currently in the collection, including the TOC part
326    /// (`part_number = 0`) if present.
327    ///
328    /// # Example
329    ///
330    /// ```
331    /// use uuencoding_multi::{PartCollection, PartEntry};
332    ///
333    /// let mut coll = PartCollection::new();
334    /// assert_eq!(coll.len(), 0);
335    ///
336    /// coll.add(PartEntry { part_number: 1, body_bytes: vec![], subject: None }).unwrap();
337    /// assert_eq!(coll.len(), 1);
338    /// ```
339    pub fn len(&self) -> usize {
340        self.parts.len()
341    }
342
343    /// Returns `true` iff no parts have been added.
344    ///
345    /// # Example
346    ///
347    /// ```
348    /// use uuencoding_multi::PartCollection;
349    ///
350    /// let coll = PartCollection::new();
351    /// assert!(coll.is_empty());
352    /// ```
353    pub fn is_empty(&self) -> bool {
354        self.parts.is_empty()
355    }
356}
357
358impl Default for PartCollection {
359    fn default() -> Self {
360        Self::new()
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    fn part(n: u32) -> PartEntry {
369        PartEntry {
370            part_number: n,
371            body_bytes: vec![],
372            subject: None,
373        }
374    }
375
376    #[test]
377    fn out_of_order_insertion_sorted() {
378        let mut c = PartCollection::new();
379        c.add(PartEntry {
380            part_number: 3,
381            body_bytes: vec![],
382            subject: None,
383        })
384        .unwrap();
385        c.add(PartEntry {
386            part_number: 1,
387            body_bytes: vec![],
388            subject: None,
389        })
390        .unwrap();
391        let got: Vec<u32> = c.present_parts().collect();
392        assert_eq!(got, vec![1, 3]);
393    }
394
395    #[test]
396    fn gap_detection() {
397        let mut c = PartCollection::with_total(4);
398        c.add(part(1)).unwrap();
399        c.add(part(2)).unwrap();
400        c.add(part(4)).unwrap();
401        assert_eq!(c.missing_parts(), vec![3]);
402    }
403
404    #[test]
405    fn duplicate_returns_error() {
406        let mut c = PartCollection::new();
407        c.add(part(1)).unwrap();
408        assert!(matches!(
409            c.add(part(1)),
410            Err(MultiUuError::DuplicatePart { part_number: 1 })
411        ));
412    }
413
414    #[test]
415    fn is_complete_when_all_present() {
416        let mut c = PartCollection::with_total(2);
417        c.add(part(1)).unwrap();
418        c.add(part(2)).unwrap();
419        assert!(c.is_complete());
420    }
421
422    /// `is_complete()` returns `false` when there is a gap: parts 1 and 3
423    /// are present (total inferred as 3) but part 2 is missing.
424    #[test]
425    fn is_complete_false_with_gap() {
426        let mut c = PartCollection::new();
427        c.add(part(1)).unwrap();
428        c.add(part(3)).unwrap(); // total bumps to 3; part 2 is missing
429        assert!(!c.is_complete());
430        assert_eq!(c.missing_parts(), vec![2]);
431    }
432
433    /// `is_complete()` returns `false` when the total is genuinely unknown
434    /// (empty collection with no declared total).
435    #[test]
436    fn is_complete_false_when_total_unknown() {
437        let c = PartCollection::new();
438        assert_eq!(c.total(), None, "empty collection has no total");
439        assert!(!c.is_complete(), "cannot be complete without a known total");
440    }
441
442    #[test]
443    fn toc_part_returned() {
444        let mut c = PartCollection::new();
445        c.add(PartEntry {
446            part_number: 0,
447            body_bytes: b"toc".to_vec(),
448            subject: None,
449        })
450        .unwrap();
451        assert!(c.toc_part().is_some());
452    }
453
454    #[test]
455    fn len_and_is_empty() {
456        let mut c = PartCollection::new();
457        assert!(c.is_empty());
458        assert_eq!(c.len(), 0);
459        c.add(part(1)).unwrap();
460        assert!(!c.is_empty());
461        assert_eq!(c.len(), 1);
462    }
463
464    #[test]
465    fn missing_parts_empty_when_no_total() {
466        let c = PartCollection::new();
467        assert_eq!(c.missing_parts(), vec![] as Vec<u32>);
468    }
469
470    #[test]
471    fn with_total_sets_total() {
472        let c = PartCollection::with_total(5);
473        assert_eq!(c.total(), Some(5));
474    }
475
476    #[test]
477    fn with_total_zero_treated_as_unknown() {
478        // with_total(0) must behave like new(): total unknown, is_complete false.
479        let c = PartCollection::with_total(0);
480        assert_eq!(c.total(), None);
481        assert!(!c.is_complete());
482        assert!(c.missing_parts().is_empty());
483    }
484
485    #[test]
486    fn add_bumps_total_upward() {
487        let mut c = PartCollection::with_total(3);
488        c.add(part(5)).unwrap(); // exceeds declared total
489        assert_eq!(c.total(), Some(5));
490    }
491
492    #[test]
493    fn toc_does_not_affect_total() {
494        let mut c = PartCollection::new();
495        c.add(PartEntry {
496            part_number: 0,
497            body_bytes: vec![],
498            subject: None,
499        })
500        .unwrap();
501        // Part 0 (TOC) must not cause total to be set to 0.
502        assert_eq!(c.total(), None);
503    }
504
505    #[test]
506    fn default_is_same_as_new() {
507        let c: PartCollection = Default::default();
508        assert!(c.is_empty());
509        assert_eq!(c.total(), None);
510    }
511}