Skip to content

Commit 81a01f0

Browse files
committed
cabrillo sink
1 parent a059d2a commit 81a01f0

File tree

5 files changed

+370
-3
lines changed

5 files changed

+370
-3
lines changed

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ every single transformation an application might desire. The user can,
8484
however, write additional normalizers to implement additional
8585
transformations not heretofore envisioned by the author.
8686

87+
As a convenience tool, the crate also contains a [CabrilloSink] to output
88+
records as a contest log in [Cabrillo][cabrillo] format. Reading Cabrillo
89+
format is not supported.
90+
91+
[cabrillo]: https://wwrof.org/cabrillo/cabrillo-v3-header/
92+
8793
## Testing
8894

8995
Testing is expected to be extreme. Code coverage is expected to be in
@@ -96,11 +102,11 @@ in the entire crate is executed by at least one test. This fact is
96102
verified by both `cargo llvm-cov` and codecov.io (badge at top).
97103

98104
The report from `cargo llvm-cov` contains a few exceptions in its summary
99-
data only (5 out of over 3900 regions), but it does not positively
105+
data only (5 out of over 4300 regions), but it does not positively
100106
identify any expression in this crate that is not executed by a test.
101107
These anomalies may be small bits of code from the standard library
102108
that are inlined by the compiler and that are thus out of the control
103-
of this crate.
109+
of this crate. See file NOTES.md for details about their locations.
104110

105111
> I reviewed your flight plan. Not one error in a million keystrokes.
106112
> Phenomenal. [[Gattaca, 1997][gattaca]]

src/cabrillo/mod.rs

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
//! Writing Cabrillo contest log format
2+
3+
use crate::{Error, Record};
4+
use bytes::{BufMut, BytesMut};
5+
use futures::sink::Sink;
6+
use std::pin::Pin;
7+
use std::task::{Context, Poll};
8+
use tokio::io::AsyncWrite;
9+
use tokio_util::codec::{Encoder, FramedWrite};
10+
11+
#[cfg(test)]
12+
mod test;
13+
14+
enum Item {
15+
Record(Record),
16+
Eof,
17+
}
18+
19+
struct CabrilloEncoder {
20+
fields: Vec<String>,
21+
started: bool,
22+
}
23+
24+
impl CabrilloEncoder {
25+
fn new(fields: Vec<String>) -> Self {
26+
Self {
27+
fields,
28+
started: false,
29+
}
30+
}
31+
32+
fn encode_header(
33+
&mut self, r: Record, dst: &mut BytesMut,
34+
) -> Result<(), Error> {
35+
if self.started {
36+
return Err(Error::DuplicateHeader);
37+
}
38+
39+
self.started = true;
40+
dst.put_slice(b"START-OF-LOG: 3.0\n");
41+
42+
for (name, value) in r.fields() {
43+
let key = name.to_uppercase();
44+
let val = value.to_cabrillo();
45+
46+
dst.put_slice(key.as_bytes());
47+
dst.put_slice(b": ");
48+
dst.put_slice(val.as_bytes());
49+
dst.put_slice(b"\n");
50+
}
51+
Ok(())
52+
}
53+
54+
fn encode_qso(&self, r: Record, dst: &mut BytesMut) -> Result<(), Error> {
55+
dst.put_slice(b"QSO: ");
56+
57+
for (i, f) in self.fields.iter().enumerate() {
58+
let d = r.get(f).ok_or_else(|| Error::MissingField {
59+
field: f.clone(),
60+
record: r.clone(),
61+
})?;
62+
63+
if i > 0 {
64+
dst.put_slice(b" ");
65+
}
66+
let v = d.to_cabrillo();
67+
dst.put_slice(v.as_bytes());
68+
}
69+
70+
dst.put_slice(b"\n");
71+
Ok(())
72+
}
73+
}
74+
75+
impl Encoder<Item> for CabrilloEncoder {
76+
type Error = Error;
77+
78+
fn encode(
79+
&mut self, item: Item, dst: &mut BytesMut,
80+
) -> Result<(), Self::Error> {
81+
match item {
82+
Item::Record(r) => {
83+
if r.is_header() {
84+
self.encode_header(r, dst)
85+
} else if !self.started {
86+
Err(Error::MissingHeader)
87+
} else {
88+
self.encode_qso(r, dst)
89+
}
90+
}
91+
Item::Eof => {
92+
if !self.started {
93+
return Err(Error::MissingHeader);
94+
}
95+
dst.put_slice(b"END-OF-LOG:\n");
96+
Ok(())
97+
}
98+
}
99+
}
100+
}
101+
102+
/// Sink for writing Cabrillo format records
103+
pub struct CabrilloSink<W> {
104+
inner: FramedWrite<W, CabrilloEncoder>,
105+
}
106+
107+
impl<W> CabrilloSink<W>
108+
where
109+
W: AsyncWrite,
110+
{
111+
/// Create a new CabrilloSink that writes the specified fields from
112+
/// each record in Cabrillo format.
113+
///
114+
/// Header field names are output in uppercase. All values are output
115+
/// verbatim with no transformation, although the encoder does output
116+
/// typed data according to the format.
117+
///
118+
/// ```
119+
/// use adif::{CabrilloSink, Record};
120+
/// use futures::SinkExt;
121+
///
122+
/// # #[tokio::main(flavor = "current_thread")]
123+
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
124+
/// let mut buf = Vec::new();
125+
/// let fields = vec!["freq", "mode", "time_on", "call"];
126+
/// let mut sink = CabrilloSink::new(&mut buf, fields);
127+
///
128+
/// let mut header = Record::new_header();
129+
/// header.insert("contest", "ARRL-SS-CW")?;
130+
/// sink.send(header).await?;
131+
///
132+
/// let mut qso = Record::new();
133+
/// qso.insert("freq", "14000")?;
134+
/// qso.insert("mode", "CW")?;
135+
/// qso.insert("time_on", "CW")?;
136+
/// qso.insert("call", "W1AW")?;
137+
/// sink.send(qso).await?;
138+
///
139+
/// sink.close().await?;
140+
/// # Ok(())
141+
/// # }
142+
/// ```
143+
pub fn new(w: W, fields: Vec<&str>) -> Self {
144+
let fields = fields.into_iter().map(|s| s.to_string()).collect();
145+
Self {
146+
inner: FramedWrite::new(w, CabrilloEncoder::new(fields)),
147+
}
148+
}
149+
}
150+
151+
impl<W> Sink<Record> for CabrilloSink<W>
152+
where
153+
W: AsyncWrite + Unpin,
154+
{
155+
type Error = Error;
156+
157+
fn poll_ready(
158+
mut self: Pin<&mut Self>, cx: &mut Context<'_>,
159+
) -> Poll<Result<(), Error>> {
160+
Pin::new(&mut self.inner)
161+
.poll_ready(cx)
162+
.map_err(Error::from)
163+
}
164+
165+
fn start_send(mut self: Pin<&mut Self>, r: Record) -> Result<(), Error> {
166+
Pin::new(&mut self.inner).start_send(Item::Record(r))
167+
}
168+
169+
fn poll_flush(
170+
mut self: Pin<&mut Self>, cx: &mut Context<'_>,
171+
) -> Poll<Result<(), Error>> {
172+
Pin::new(&mut self.inner)
173+
.poll_flush(cx)
174+
.map_err(Error::from)
175+
}
176+
177+
fn poll_close(
178+
mut self: Pin<&mut Self>, cx: &mut Context<'_>,
179+
) -> Poll<Result<(), Error>> {
180+
Pin::new(&mut self.inner).start_send(Item::Eof)?;
181+
Pin::new(&mut self.inner)
182+
.poll_close(cx)
183+
.map_err(Error::from)
184+
}
185+
}

src/cabrillo/test.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
use chrono::{NaiveDate, NaiveTime};
2+
use futures::SinkExt;
3+
4+
use crate::{CabrilloSink, Error, Record};
5+
6+
#[tokio::test]
7+
async fn basic() {
8+
let mut buf = Vec::new();
9+
let fields = vec!["freq", "mode", "qso_date", "time_on", "call"];
10+
let mut sink = CabrilloSink::new(&mut buf, fields);
11+
12+
let mut header = Record::new_header();
13+
header.insert("contest", "ARRL-SS-CW").unwrap();
14+
header.insert("callsign", "W1AW").unwrap();
15+
sink.send(header).await.unwrap();
16+
17+
let mut qso = Record::new();
18+
qso.insert("freq", "14000").unwrap();
19+
qso.insert("mode", "CW").unwrap();
20+
qso.insert("qso_date", NaiveDate::from_ymd_opt(2020, 1, 1).unwrap())
21+
.unwrap();
22+
qso.insert("time_on", NaiveTime::from_hms_opt(12, 34, 0).unwrap())
23+
.unwrap();
24+
qso.insert("call", "AB9BH").unwrap();
25+
sink.send(qso).await.unwrap();
26+
27+
sink.close().await.unwrap();
28+
29+
let output = String::from_utf8(buf).unwrap();
30+
let expected = "\
31+
START-OF-LOG: 3.0
32+
CONTEST: ARRL-SS-CW
33+
CALLSIGN: W1AW
34+
QSO: 14000 CW 2020-01-01 1234 AB9BH
35+
END-OF-LOG:
36+
";
37+
assert_eq!(output, expected);
38+
}
39+
40+
#[tokio::test]
41+
async fn missing_header() {
42+
let mut buf = Vec::new();
43+
let fields = vec!["call"];
44+
let mut sink = CabrilloSink::new(&mut buf, fields);
45+
46+
let mut qso = Record::new();
47+
qso.insert("call", "W1AW").unwrap();
48+
let result = sink.send(qso).await;
49+
50+
assert_eq!(result.unwrap_err(), Error::MissingHeader);
51+
}
52+
53+
#[tokio::test]
54+
async fn missing_field() {
55+
let mut buf = Vec::new();
56+
let fields = vec!["call", "freq"];
57+
let mut sink = CabrilloSink::new(&mut buf, fields);
58+
59+
let mut header = Record::new_header();
60+
header.insert("contest", "TEST").unwrap();
61+
sink.send(header).await.unwrap();
62+
63+
let mut qso = Record::new();
64+
qso.insert("call", "W1AW").unwrap();
65+
let qso_clone = qso.clone();
66+
let result = sink.send(qso).await;
67+
68+
assert_eq!(
69+
result.unwrap_err(),
70+
Error::MissingField {
71+
field: "freq".to_string(),
72+
record: qso_clone
73+
}
74+
);
75+
}
76+
77+
#[tokio::test]
78+
async fn duplicate_header() {
79+
let mut buf = Vec::new();
80+
let fields = vec!["call"];
81+
let mut sink = CabrilloSink::new(&mut buf, fields);
82+
83+
let mut header1 = Record::new_header();
84+
header1.insert("contest", "TEST1").unwrap();
85+
sink.send(header1).await.unwrap();
86+
87+
let mut header2 = Record::new_header();
88+
header2.insert("contest", "TEST2").unwrap();
89+
let result = sink.send(header2).await;
90+
91+
assert_eq!(result.unwrap_err(), Error::DuplicateHeader);
92+
}
93+
94+
#[tokio::test]
95+
async fn close_error() {
96+
let mut buf = Vec::new();
97+
let fields = vec!["call"];
98+
let mut sink = CabrilloSink::new(&mut buf, fields);
99+
100+
let result = sink.close().await;
101+
assert_eq!(result.unwrap_err(), Error::MissingHeader);
102+
}

0 commit comments

Comments
 (0)