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}