Skip to content

Commit 1615002

Browse files
committed
add a filter to trim whitespace
1 parent 187f0f0 commit 1615002

File tree

4 files changed

+187
-2
lines changed

4 files changed

+187
-2
lines changed

src/filter/mod.rs

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Optional ADIF data transformations
22
3-
use crate::{Error, Record};
3+
use crate::{Datum, Error, Record};
44
use chrono::{Days, NaiveDateTime};
55
use futures::stream::Stream;
66
use std::collections::HashSet;
@@ -99,6 +99,48 @@ pub trait FilterExt: Stream {
9999

100100
impl<S> FilterExt for S where S: Stream {}
101101

102+
/// Stream adapter that transforms each record by consuming and rebuilding it.
103+
pub struct Map<S, F> {
104+
stream: S,
105+
f: F,
106+
}
107+
108+
impl<S, F> Stream for Map<S, F>
109+
where
110+
S: Stream<Item = Result<Record, Error>> + Unpin,
111+
F: FnMut(Record) -> Result<Record, Error> + Unpin,
112+
{
113+
type Item = Result<Record, Error>;
114+
115+
fn poll_next(
116+
self: Pin<&mut Self>, cx: &mut Context<'_>,
117+
) -> Poll<Option<Self::Item>> {
118+
let this = self.get_mut();
119+
match Pin::new(&mut this.stream).poll_next(cx) {
120+
Poll::Ready(Some(Ok(record))) => {
121+
Poll::Ready(Some((this.f)(record)))
122+
}
123+
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
124+
Poll::Ready(None) => Poll::Ready(None),
125+
Poll::Pending => Poll::Pending,
126+
}
127+
}
128+
}
129+
130+
/// Extension trait providing the `map` method on streams.
131+
pub trait MapExt: Stream {
132+
/// Transform each record by consuming and rebuilding it.
133+
fn map<F>(self, f: F) -> Map<Self, F>
134+
where
135+
Self: Sized,
136+
F: FnMut(Record) -> Result<Record, Error>,
137+
{
138+
Map { stream: self, f }
139+
}
140+
}
141+
142+
impl<S> MapExt for S where S: Stream {}
143+
102144
/// Normalize date and time fields from multiple possible source fields into
103145
/// combined datetime values.
104146
///
@@ -282,3 +324,35 @@ where
282324
{
283325
stream.filter(|record| !record.is_header())
284326
}
327+
328+
/// Trim leading and trailing whitespace from string field values.
329+
///
330+
/// Non-string datum variants pass through unchanged.
331+
///
332+
/// ```
333+
/// use difa::{
334+
/// Record, RecordStreamExt, TagDecoder, filter::trim_whitespace,
335+
/// };
336+
/// use futures::StreamExt;
337+
///
338+
/// # tokio_test::block_on(async {
339+
/// let data = b"<call:6> W1AW <eor>";
340+
/// let stream = TagDecoder::new_stream(&data[..], true).records();
341+
/// let mut stream = trim_whitespace(stream);
342+
/// let record = stream.next().await.unwrap().unwrap();
343+
/// assert_eq!(record.get("call").unwrap().as_str(), "W1AW");
344+
/// # });
345+
/// ```
346+
pub fn trim_whitespace<S>(
347+
stream: S,
348+
) -> Map<S, impl FnMut(Record) -> Result<Record, Error>>
349+
where
350+
S: Stream<Item = Result<Record, Error>>,
351+
{
352+
stream.map(|r| {
353+
Ok(r.map_fields(|_, v| match v {
354+
Datum::String(s) => Datum::String(s.trim().to_string()),
355+
other => other,
356+
}))
357+
})
358+
}

src/filter/test.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,3 +456,65 @@ async fn filter_end_of_stream() {
456456
assert_eq!(rec.get("call").unwrap().as_str(), "W1AW");
457457
no_record(&mut s).await;
458458
}
459+
460+
#[tokio::test]
461+
async fn trim_whitespace_trims_strings() {
462+
let rec = parse_one("<call:6> W1AW <eor>", trim_whitespace).await;
463+
assert_eq!(rec.get("call").unwrap().as_str(), "W1AW");
464+
}
465+
466+
#[tokio::test]
467+
async fn trim_whitespace_no_change() {
468+
let rec = parse_one("<call:4>W1AW<eor>", trim_whitespace).await;
469+
assert_eq!(rec.get("call").unwrap().as_str(), "W1AW");
470+
}
471+
472+
#[tokio::test]
473+
async fn trim_whitespace_preserves_typed_values() {
474+
let rec =
475+
parse_one("<n:5:n> 1.0 <b:3:b> Y <call:6> W1AW <eor>", trim_whitespace)
476+
.await;
477+
assert_eq!(
478+
rec.get("n").unwrap().as_number().unwrap().to_string(),
479+
"1.0"
480+
);
481+
assert!(rec.get("b").unwrap().as_bool().unwrap());
482+
assert_eq!(rec.get("call").unwrap().as_str(), "W1AW");
483+
}
484+
485+
#[tokio::test]
486+
async fn trim_whitespace_preserves_header() {
487+
let mut s = parse_many(
488+
"<adifver:7> 3.1.4 <eoh><call:6> W1AW <eor>",
489+
trim_whitespace,
490+
);
491+
let rec = next(&mut s).await;
492+
assert!(rec.is_header());
493+
assert_eq!(rec.get("adifver").unwrap().as_str(), "3.1.4");
494+
let rec = next(&mut s).await;
495+
assert!(!rec.is_header());
496+
assert_eq!(rec.get("call").unwrap().as_str(), "W1AW");
497+
no_record(&mut s).await;
498+
}
499+
500+
#[tokio::test]
501+
async fn trim_whitespace_multiple_fields() {
502+
let rec = parse_one(
503+
"<call:6> W1AW <band:5> 20m <mode:5> SSB <eor>",
504+
trim_whitespace,
505+
)
506+
.await;
507+
assert_eq!(rec.get("call").unwrap().as_str(), "W1AW");
508+
assert_eq!(rec.get("band").unwrap().as_str(), "20m");
509+
assert_eq!(rec.get("mode").unwrap().as_str(), "SSB");
510+
}
511+
512+
#[tokio::test]
513+
async fn trim_whitespace_error_passthrough() {
514+
let mut s =
515+
parse_many_ignore("<call:6> W1AW <eor><bad", false, trim_whitespace);
516+
let rec = next(&mut s).await;
517+
assert_eq!(rec.get("call").unwrap().as_str(), "W1AW");
518+
next_err(&mut s, partial_data(1, 20, 19)).await;
519+
no_record(&mut s).await;
520+
}

src/lib.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ mod test;
2828

2929
pub use cabrillo::CabrilloSink;
3030
pub use cistring::{CiStr, CiString};
31-
pub use filter::{FilterExt, NormalizeExt};
31+
pub use filter::{FilterExt, MapExt, NormalizeExt};
3232
pub use parse::{RecordStream, RecordStreamExt, TagDecoder, TagStream};
3333
pub use write::{OutputTypes, RecordSink, TagEncoder, TagSink, TagSinkExt};
3434

@@ -509,6 +509,25 @@ impl Record {
509509
self.fields.into_iter().map(|(k, v)| (k.into_string(), v))
510510
}
511511

512+
/// Rebuild a record by applying a transformation to each field.
513+
pub fn map_fields<F>(self, mut f: F) -> Self
514+
where
515+
F: FnMut(&str, Datum) -> Datum,
516+
{
517+
let fields = self
518+
.fields
519+
.into_iter()
520+
.map(|(k, v)| {
521+
let v = f(k.as_str(), v);
522+
(k, v)
523+
})
524+
.collect();
525+
Self {
526+
header: self.header,
527+
fields,
528+
}
529+
}
530+
512531
/// Return an iterator over all fields in this record.
513532
pub fn fields(&self) -> impl Iterator<Item = (&str, &Datum)> {
514533
self.fields.iter().map(|(k, v)| (k.as_str(), v))

src/test.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,36 @@ fn into_fields() {
194194
assert_eq!(fields[1].1.as_number().unwrap(), Decimal::from(14));
195195
}
196196

197+
#[test]
198+
fn map_fields() {
199+
let mut r = Record::new();
200+
r.insert("call", " W1AW ").unwrap();
201+
r.insert("freq", Decimal::from(14)).unwrap();
202+
203+
let r = r.map_fields(|_, v| match v {
204+
Datum::String(s) => Datum::String(s.trim().to_string()),
205+
other => other,
206+
});
207+
assert_eq!(r.get("call").unwrap().as_str(), "W1AW");
208+
assert_eq!(
209+
r.get("freq").unwrap().as_number().unwrap(),
210+
Decimal::from(14)
211+
);
212+
}
213+
214+
#[test]
215+
fn map_fields_header() {
216+
let mut r = Record::new_header();
217+
r.insert("adifver", " 3.1.4 ").unwrap();
218+
r.insert("foo", true).unwrap();
219+
let r = r.map_fields(|_, v| match v {
220+
Datum::String(s) => Datum::String(s.trim().to_string()),
221+
other => other,
222+
});
223+
assert!(r.is_header());
224+
assert_eq!(r.get("adifver").unwrap().as_str(), "3.1.4");
225+
}
226+
197227
#[test]
198228
fn to_cabrillo() {
199229
let s = Datum::String("test".to_string());

0 commit comments

Comments
 (0)