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}