use std::fmt::Write as _;
use std::io::Write;
use anstyle::{AnsiColor, Style};
use serde::Serialize;
use crate::caida::Family;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Source {
Caida,
Local,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SnapshotInfo {
pub family: Family,
pub source: Source,
pub date: Option<String>,
pub file: String,
pub location: String,
pub prefixes: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Record {
pub ip: String,
pub prefix: Option<String>,
pub asn: Option<u32>,
pub origins: Option<String>,
pub snapshot: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub struct Summary {
pub total: usize,
pub found: usize,
pub not_found: usize,
pub invalid: usize,
}
impl Summary {
pub fn of(records: &[Record]) -> Self {
let mut summary = Self::default();
for record in records {
summary.total += 1;
if record.error.is_some() {
summary.invalid += 1;
} else if record.asn.is_some() {
summary.found += 1;
} else {
summary.not_found += 1;
}
}
summary
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Report {
pub tool: Tool,
pub generated_at: String,
pub snapshots: Vec<SnapshotInfo>,
pub summary: Summary,
pub results: Vec<Record>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Tool {
pub name: &'static str,
pub version: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum Format {
Table,
Json,
Jsonl,
Csv,
Tsv,
#[cfg(feature = "parquet")]
Parquet,
}
impl Format {
pub fn is_binary(self) -> bool {
#[cfg(feature = "parquet")]
{
self == Format::Parquet
}
#[cfg(not(feature = "parquet"))]
{
false
}
}
}
pub fn write(format: Format, report: &Report, writer: &mut dyn Write) -> anyhow::Result<()> {
match format {
Format::Table => write_table(report, writer, &Theme::plain())?,
Format::Json => {
serde_json::to_writer_pretty(&mut *writer, report)?;
writer.write_all(b"\n")?;
}
Format::Jsonl => {
for record in &report.results {
serde_json::to_writer(&mut *writer, record)?;
writer.write_all(b"\n")?;
}
}
Format::Csv => write_delimited(b',', report, writer)?,
Format::Tsv => write_delimited(b'\t', report, writer)?,
#[cfg(feature = "parquet")]
Format::Parquet => parquet_output::write(report, writer)?,
}
writer.flush()?;
Ok(())
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Theme {
pub header: Style,
pub ip: Style,
pub prefix: Style,
pub asn: Style,
pub multiple_origins: Style,
pub snapshot: Style,
pub missing: Style,
pub error: Style,
}
impl Theme {
pub fn plain() -> Self {
Self::default()
}
pub fn colored() -> Self {
Self {
header: Style::new().bold(),
ip: Style::new(),
prefix: AnsiColor::Cyan.on_default(),
asn: AnsiColor::Green.on_default().bold(),
multiple_origins: AnsiColor::Yellow.on_default(),
snapshot: Style::new().dimmed(),
missing: Style::new().dimmed(),
error: AnsiColor::Red.on_default(),
}
}
}
pub fn display_origins(raw: &str) -> (String, bool) {
let parts: Vec<String> = raw
.split('_')
.map(|part| {
if part.contains(',') {
format!("{{{}}}", part.replace(',', ", "))
} else {
part.to_owned()
}
})
.collect();
let multiple = parts.len() > 1 || raw.contains(',');
(parts.join(", "), multiple)
}
pub fn write_table(report: &Report, writer: &mut dyn Write, theme: &Theme) -> anyhow::Result<()> {
const HEADER: [&str; 5] = ["ip", "prefix", "asn", "snapshot", "error"];
const ASN: usize = 2;
let present = |value: Option<&str>, style: Style| match value {
Some(value) => (value.to_owned(), style),
None => ("-".to_owned(), theme.missing),
};
let rows: Vec<[(String, Style); 5]> = report
.results
.iter()
.map(|record| {
let invalid = record.error.is_some();
let asn = match &record.origins {
Some(origins) => {
let (text, multiple) = display_origins(origins);
(
text,
if multiple {
theme.multiple_origins
} else {
theme.asn
},
)
}
None => ("-".to_owned(), theme.missing),
};
[
(record.ip.clone(), if invalid { theme.error } else { theme.ip }),
present(record.prefix.as_deref(), theme.prefix),
asn,
present(record.snapshot.as_deref(), theme.snapshot),
(record.error.clone().unwrap_or_default(), theme.error),
]
})
.collect();
let mut widths = HEADER.map(str::len);
for row in &rows {
for (width, (cell, _)) in widths.iter_mut().zip(row) {
*width = (*width).max(cell.chars().count());
}
}
let header = HEADER.map(|name| (name.to_owned(), theme.header));
for row in std::iter::once(&header).chain(&rows) {
let mut line = String::new();
for (index, ((cell, style), width)) in row.iter().zip(widths).enumerate() {
if index > 0 {
line.push_str(" ");
}
let padding = width - cell.chars().count();
if index == ASN {
line.extend(std::iter::repeat_n(' ', padding));
}
if !cell.is_empty() {
write!(line, "{style}{cell}{style:#}")?;
}
if index != ASN && index + 1 < row.len() {
line.extend(std::iter::repeat_n(' ', padding));
}
}
writeln!(writer, "{}", line.trim_end())?;
}
Ok(())
}
fn write_delimited(delimiter: u8, report: &Report, writer: &mut dyn Write) -> anyhow::Result<()> {
let mut csv = csv::WriterBuilder::new().delimiter(delimiter).from_writer(writer);
for record in &report.results {
csv.serialize(record)?;
}
csv.flush()?;
Ok(())
}
#[cfg(feature = "parquet")]
mod parquet_output {
use std::io::Write;
use std::sync::Arc;
use arrow::array::{ArrayRef, RecordBatch, StringArray, UInt32Array};
use arrow::datatypes::{DataType, Field, Schema};
use parquet::arrow::ArrowWriter;
use parquet::basic::Compression;
use parquet::file::properties::WriterProperties;
use super::Report;
pub fn write(report: &Report, writer: &mut dyn Write) -> anyhow::Result<()> {
let schema = Arc::new(Schema::new(vec![
Field::new("ip", DataType::Utf8, false),
Field::new("prefix", DataType::Utf8, true),
Field::new("asn", DataType::UInt32, true),
Field::new("origins", DataType::Utf8, true),
Field::new("snapshot", DataType::Utf8, true),
Field::new("error", DataType::Utf8, true),
]));
let results = &report.results;
let columns: Vec<ArrayRef> = vec![
Arc::new(StringArray::from(
results.iter().map(|r| r.ip.as_str()).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
results.iter().map(|r| r.prefix.as_deref()).collect::<Vec<_>>(),
)),
Arc::new(UInt32Array::from(
results.iter().map(|r| r.asn).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
results.iter().map(|r| r.origins.as_deref()).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
results.iter().map(|r| r.snapshot.as_deref()).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
results.iter().map(|r| r.error.as_deref()).collect::<Vec<_>>(),
)),
];
let batch = RecordBatch::try_new(schema.clone(), columns)?;
let properties = WriterProperties::builder()
.set_compression(Compression::SNAPPY)
.set_created_by(format!("originet {}", crate::VERSION))
.build();
let mut buffer = Vec::new();
let mut parquet = ArrowWriter::try_new(&mut buffer, schema, Some(properties))?;
parquet.write(&batch)?;
parquet.close()?;
writer.write_all(&buffer)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn report() -> Report {
Report {
tool: Tool {
name: "originet",
version: "0.0.0",
},
generated_at: "2026-10-22T00:00:00Z".into(),
snapshots: vec![],
summary: Summary::of(&records()),
results: records(),
}
}
fn records() -> Vec<Record> {
vec![
Record {
ip: "8.8.8.8".into(),
prefix: Some("8.8.8.0/24".into()),
asn: Some(15169),
origins: Some("15169".into()),
snapshot: Some("2026-10-21".into()),
error: None,
},
Record {
ip: "10.0.0.1".into(),
prefix: None,
asn: None,
origins: None,
snapshot: Some("2026-10-21".into()),
error: None,
},
Record {
ip: "bogus".into(),
prefix: None,
asn: None,
origins: None,
snapshot: None,
error: Some("invalid IP address syntax".into()),
},
]
}
#[test]
fn summary_counts() {
let summary = Summary::of(&records());
assert_eq!(
summary,
Summary {
total: 3,
found: 1,
not_found: 1,
invalid: 1
}
);
}
#[test]
fn csv_and_tsv_have_a_header_and_empty_nulls() {
let mut out = Vec::new();
write(Format::Csv, &report(), &mut out).unwrap();
let text = String::from_utf8(out).unwrap();
let lines: Vec<_> = text.lines().collect();
assert_eq!(lines[0], "ip,prefix,asn,origins,snapshot,error");
assert_eq!(lines[1], "8.8.8.8,8.8.8.0/24,15169,15169,2026-10-21,");
assert_eq!(lines[2], "10.0.0.1,,,,2026-10-21,");
assert_eq!(lines[3], "bogus,,,,,invalid IP address syntax");
let mut out = Vec::new();
write(Format::Tsv, &report(), &mut out).unwrap();
assert!(
String::from_utf8(out)
.unwrap()
.starts_with("ip\tprefix\tasn\torigins\tsnapshot\terror\n")
);
}
#[test]
fn table_is_aligned_with_dashes_for_nulls() {
let mut out = Vec::new();
write(Format::Table, &report(), &mut out).unwrap();
let text = String::from_utf8(out).unwrap();
assert_eq!(
text,
"\
ip prefix asn snapshot error
8.8.8.8 8.8.8.0/24 15169 2026-10-21
10.0.0.1 - - 2026-10-21
bogus - - - invalid IP address syntax
"
);
assert!(!Format::Table.is_binary());
let mut colored = Vec::new();
write_table(&report(), &mut colored, &Theme::colored()).unwrap();
let colored = String::from_utf8(colored).unwrap();
assert!(colored.starts_with("\x1b[1mip\x1b[0m"), "{colored:?}");
assert!(colored.contains("\x1b[1m\x1b[32m15169\x1b[0m"), "{colored:?}");
assert!(colored.contains("\x1b[31mbogus\x1b[0m"), "{colored:?}");
assert_eq!(anstream::adapter::strip_str(&colored).to_string(), text);
}
#[test]
fn table_lists_every_origin() {
assert_eq!(display_origins("15169"), ("15169".into(), false));
assert_eq!(display_origins("149511_138421"), ("149511, 138421".into(), true));
assert_eq!(display_origins("8035,13979"), ("{8035, 13979}".into(), true));
assert_eq!(
display_origins("1_2,3"),
("1, {2, 3}".into(), true),
"an AS set inside a MOAS keeps its braces"
);
let mut report = report();
report.results[0].origins = Some("149511_138421".into());
let mut out = Vec::new();
write_table(&report, &mut out, &Theme::colored()).unwrap();
let text = String::from_utf8(out).unwrap();
assert!(text.contains("\x1b[33m149511, 138421\x1b[0m"), "{text:?}");
}
#[test]
fn json_and_jsonl() {
let mut out = Vec::new();
write(Format::Json, &report(), &mut out).unwrap();
let value: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(value["summary"]["found"], 1);
assert_eq!(value["results"][0]["asn"], 15169);
assert!(value["results"][1]["asn"].is_null());
let mut out = Vec::new();
write(Format::Jsonl, &report(), &mut out).unwrap();
let text = String::from_utf8(out).unwrap();
assert_eq!(text.lines().count(), 3);
let first: serde_json::Value = serde_json::from_str(text.lines().next().unwrap()).unwrap();
assert_eq!(first["prefix"], "8.8.8.0/24");
}
#[cfg(feature = "parquet")]
#[test]
fn parquet_round_trips() {
use arrow::array::RecordBatch;
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
let mut out = Vec::new();
write(Format::Parquet, &report(), &mut out).unwrap();
let reader = ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(out))
.unwrap()
.build()
.unwrap();
let batches: Vec<_> = reader.map(Result::unwrap).collect();
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 3);
assert_eq!(batches[0].schema().fields().len(), 6);
}
}