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