use std::io::{self, BufWriter, Write};
use zond_engine::export::Redaction;
use zond_engine::{Host, ScanReport};
use crate::diagnostics::Verbosity;
use crate::render::narrate::Narrator;
use crate::render::{Phase, Renderer, field};
const SEPARATOR: char = '\t';
pub(crate) const FIELDS: usize = 13;
pub(crate) struct PipeRenderer {
records: Box<dyn Write>,
narrator: Narrator,
reader: field::Reader,
}
impl PipeRenderer {
#[must_use]
pub(crate) fn to_terminal(verbosity: Verbosity) -> Self {
Self::new(
Box::new(BufWriter::new(io::stdout())),
Box::new(io::stderr()),
verbosity,
)
}
#[must_use]
pub(crate) fn new(
records: Box<dyn Write>,
narration: Box<dyn Write>,
verbosity: Verbosity,
) -> Self {
Self {
records,
narrator: Narrator::new(narration, verbosity),
reader: field::Reader::default(),
}
}
}
fn record(reader: field::Reader, host: &Host) -> [String; FIELDS] {
[
reader.addresses(host),
field::status(host),
field::rtt_millis(host).unwrap_or_else(field::unknown),
field::evidence(host).unwrap_or_else(field::unknown),
reader.macs(host).unwrap_or_else(field::unknown),
reader.hostname(host).unwrap_or_else(field::unknown),
field::os(host).unwrap_or_else(field::unknown),
field::vendor(host).map_or_else(field::unknown, ToOwned::to_owned),
field::rtt_min_millis(host).unwrap_or_else(field::unknown),
field::rtt_mean_millis(host).unwrap_or_else(field::unknown),
field::rtt_max_millis(host).unwrap_or_else(field::unknown),
field::packed_ports(host).unwrap_or_else(field::unknown),
field::closed_ports(host).unwrap_or_else(field::unknown),
]
}
impl Renderer for PipeRenderer {
fn started(&mut self, phase: Phase<'_>, redaction: Redaction) -> io::Result<()> {
self.reader = field::Reader::new(redaction);
self.narrator.started(phase, redaction)
}
fn host_found(&mut self, host: &Host) -> io::Result<()> {
self.narrator.found(host)
}
fn interrupted(&mut self) -> io::Result<()> {
self.narrator.interrupted()
}
fn finished(&mut self, report: &ScanReport) -> io::Result<()> {
for host in field::sorted_hosts(report) {
let fields = record(self.reader, host);
writeln!(self.records, "{}", fields.join(&SEPARATOR.to_string()))?;
}
self.records.flush()?;
self.narrator.summary(report)
}
}
#[cfg(test)]
impl PipeRenderer {
fn write_all_for_test(&mut self, hosts: &[Host]) -> io::Result<()> {
for host in hosts {
writeln!(
self.records,
"{}",
record(self.reader, host).join(&SEPARATOR.to_string())
)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::test_support::{Capture, host};
use std::time::Duration;
use zond_engine::model::host::status::{StatusProtocol, StatusReason};
use zond_engine::model::ip::scoped::Zone;
fn renderer() -> (PipeRenderer, Capture, Capture) {
let records = Capture::default();
let narration = Capture::default();
let renderer = PipeRenderer::new(
Box::new(records.clone()),
Box::new(narration.clone()),
Verbosity::default(),
);
(renderer, records, narration)
}
fn furnished() -> Host {
let mut host = host(1);
host.add_ip("fe80::1".parse().expect("a valid address"));
host.set_zone(Zone::new(4, "en0"));
host.add_rtt(Duration::from_micros(1_420));
host.add_reason(StatusReason::new(StatusProtocol::Arp, "reply"));
host.add_reason(StatusReason::new(StatusProtocol::Ndp, "advertisement"));
host.record_mac("00:00:5e:00:53:01".parse().expect("a valid address"));
host.set_hostname(Some("router.example".to_owned()));
host
}
#[test]
fn a_record_carries_every_field_in_the_documented_order() {
let fields = record(field::Reader::default(), &furnished());
assert_eq!(fields.len(), FIELDS);
assert_eq!(fields[0], "192.0.2.1,fe80::1%en0");
assert_eq!(fields[1], "Up");
assert_eq!(fields[2], "1.420");
assert_eq!(fields[3], "arp,ndp");
assert_eq!(fields[4], "00:00:5e:00:53:01");
assert_eq!(fields[5], "router.example");
assert_eq!(fields[6], "-", "no OS was established");
assert_eq!(fields[7], "Icann, Iana Department");
assert_eq!(fields[8], "1.420");
assert_eq!(fields[9], "1.420");
assert_eq!(fields[10], "1.420");
assert_eq!(fields[11], "-");
assert_eq!(fields[12], "-");
}
#[test]
fn the_round_trip_time_stays_in_milliseconds_however_large_it_gets() {
let mut quick = host(1);
quick.add_rtt(Duration::from_micros(412));
assert_eq!(record(field::Reader::default(), &quick)[2], "0.412");
let mut slow = host(2);
slow.add_rtt(Duration::from_millis(1_240));
assert_eq!(record(field::Reader::default(), &slow)[2], "1240.000");
}
#[test]
fn no_field_contains_a_tab() {
for field in record(field::Reader::default(), &furnished()) {
assert!(!field.contains(SEPARATOR), "{field:?} carries a separator");
}
}
#[test]
fn a_value_with_spaces_stays_one_field() {
let (mut renderer, records, _narration) = renderer();
let mut host = host(1);
host.set_hostname(Some("router.example".to_owned()));
renderer
.write_all_for_test(&[host])
.expect("capture cannot fail");
let text = records.text();
let line = text.lines().next().expect("one host, one line");
assert_eq!(
line.split(SEPARATOR).count(),
FIELDS,
"field count changed: {line:?}"
);
}
#[test]
fn there_is_no_heading_line() {
let (mut renderer, records, _narration) = renderer();
renderer
.write_all_for_test(&[furnished()])
.expect("capture cannot fail");
let text = records.text();
let first = text.lines().next().expect("one host, one line");
assert!(first.starts_with("192.0.2.1"), "got {first:?}");
}
}