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}