aprs-cli 1.7.0

Command-line APRS packet inspector
#![forbid(unsafe_code)]

use std::env;
use std::fs::File;
use std::io::{self, Write};
use std::process::ExitCode;

use libaprs_engine::{
    read_all_with_limit, support_matrix, DiagnosticLayer, Engine, EngineResult, LineTransport,
    Policy, SupportMatrix, MAX_PACKET_LEN,
};

#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct CliOptions {
    json: bool,
    permissive: bool,
    explain: bool,
    summary: bool,
    command: CommandMode,
    fail_on: FailOn,
    filter: Option<String>,
    input_path: Option<String>,
}

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum CommandMode {
    #[default]
    Parse,
    Validate,
    Stats,
    Explain,
    Replay,
    SupportMatrix,
}

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum FailOn {
    None,
    Malformed,
    #[default]
    Rejected,
}

fn main() -> ExitCode {
    match run() {
        Ok(code) => code,
        Err(message) => {
            eprintln!("{message}");
            ExitCode::from(2)
        }
    }
}

fn run() -> Result<ExitCode, String> {
    let options = parse_args(env::args().skip(1))?;
    if options.command == CommandMode::SupportMatrix {
        print_support_matrix(options.json);
        return Ok(ExitCode::SUCCESS);
    }

    let input = read_input(options.input_path.as_deref())?;
    let policy = if options.permissive {
        Policy::permissive()
    } else {
        Policy::default()
    };
    let mut engine = Engine::new(policy);
    let mut rejected = false;
    let mut malformed = false;

    for line in LineTransport::new(&input)
        .packets_with_limit(MAX_PACKET_LEN)
        .map_err(|err| format!("failed to split packet lines: {err}"))?
    {
        match engine.process(line) {
            EngineResult::Accepted { packet }
                if !matches_filter(&options, packet.aprs_data().kind_name()) => {}
            EngineResult::Accepted { packet } => match options.command {
                CommandMode::Validate | CommandMode::Stats | CommandMode::SupportMatrix => {}
                CommandMode::Replay => {
                    io::stdout()
                        .write_all(packet.raw().as_bytes())
                        .map_err(|err| format!("failed to write packet: {err}"))?;
                    io::stdout()
                        .write_all(b"\n")
                        .map_err(|err| format!("failed to write newline: {err}"))?;
                }
                CommandMode::Parse | CommandMode::Explain if options.json => {
                    println!("{}", packet.to_json());
                }
                CommandMode::Parse | CommandMode::Explain => println!(
                    "accepted source={} destination={} semantic={}",
                    lossy(packet.source()),
                    lossy(packet.destination()),
                    packet.aprs_data().kind_name()
                ),
            },
            EngineResult::Rejected { reason, .. } => {
                rejected = true;
                if options.command == CommandMode::Explain || options.explain {
                    println!("rejected reason={reason:?} code={}", reason.code());
                } else if options.command != CommandMode::Validate
                    && options.command != CommandMode::Stats
                    && options.command != CommandMode::Replay
                    && options.command != CommandMode::SupportMatrix
                {
                    println!("rejected reason={reason:?}");
                }
            }
            EngineResult::ParseError(error) => {
                malformed = true;
                if options.command == CommandMode::Explain || options.explain {
                    println!("malformed error={error:?} code={}", error.code());
                } else if options.command != CommandMode::Validate
                    && options.command != CommandMode::Stats
                    && options.command != CommandMode::Replay
                    && options.command != CommandMode::SupportMatrix
                {
                    println!("malformed error={error:?}");
                }
            }
        }
    }

    let counters = engine.counters();
    if options.command == CommandMode::Validate {
        if rejected || malformed {
            println!(
                "invalid accepted={} rejected={} malformed={}",
                counters.accepted, counters.rejected, counters.malformed
            );
        } else {
            println!(
                "valid accepted={} rejected={} malformed={}",
                counters.accepted, counters.rejected, counters.malformed
            );
        }
    }
    if options.summary || options.command == CommandMode::Stats {
        println!(
            "summary accepted={} rejected={} malformed={}",
            counters.accepted, counters.rejected, counters.malformed
        );
    }
    eprintln!(
        "accepted={} rejected={} malformed={}",
        counters.accepted, counters.rejected, counters.malformed
    );

    Ok(if should_fail(options.fail_on, rejected, malformed) {
        ExitCode::from(1)
    } else {
        ExitCode::SUCCESS
    })
}

fn parse_args(args: impl IntoIterator<Item = String>) -> Result<CliOptions, String> {
    let mut options = CliOptions::default();
    let mut args = args.into_iter();

    while let Some(arg) = args.next() {
        match arg.as_str() {
            "parse" => options.command = CommandMode::Parse,
            "validate" => options.command = CommandMode::Validate,
            "stats" => options.command = CommandMode::Stats,
            "explain" => {
                options.command = CommandMode::Explain;
                options.explain = true;
            }
            "replay" => options.command = CommandMode::Replay,
            "support-matrix" => options.command = CommandMode::SupportMatrix,
            "--json" => options.json = true,
            "--permissive" => options.permissive = true,
            "--explain" => options.explain = true,
            "--summary" => options.summary = true,
            "--filter" => {
                let filter = args
                    .next()
                    .ok_or_else(|| "--filter requires a semantic kind".to_string())?;
                options.filter = Some(filter);
            }
            "--fail-on" => {
                let value = args
                    .next()
                    .ok_or_else(|| "--fail-on requires none, malformed, or rejected".to_string())?;
                options.fail_on = parse_fail_on(&value)?;
            }
            "--help" | "-h" => return Err(usage()),
            _ if arg.starts_with('-') => return Err(format!("unknown option: {arg}\n{}", usage())),
            _ => {
                if options.input_path.replace(arg).is_some() {
                    return Err(format!("multiple input paths supplied\n{}", usage()));
                }
            }
        }
    }

    Ok(options)
}

fn parse_fail_on(value: &str) -> Result<FailOn, String> {
    match value {
        "none" => Ok(FailOn::None),
        "malformed" => Ok(FailOn::Malformed),
        "rejected" => Ok(FailOn::Rejected),
        _ => Err(format!("invalid --fail-on value: {value}\n{}", usage())),
    }
}

fn should_fail(fail_on: FailOn, rejected: bool, malformed: bool) -> bool {
    match fail_on {
        FailOn::None => false,
        FailOn::Malformed => malformed,
        FailOn::Rejected => rejected || malformed,
    }
}

fn matches_filter(options: &CliOptions, semantic: &str) -> bool {
    options
        .filter
        .as_deref()
        .map_or(true, |filter| filter == semantic)
}

fn read_input(path: Option<&str>) -> Result<Vec<u8>, String> {
    match path {
        Some(path) => read_all_with_limit(
            File::open(path).map_err(|err| format!("failed to open {path}: {err}"))?,
            libaprs_engine::DEFAULT_TRANSPORT_READ_LIMIT,
        )
        .map_err(|err| format!("failed to read {path}: {err}")),
        None => read_all_with_limit(io::stdin(), libaprs_engine::DEFAULT_TRANSPORT_READ_LIMIT)
            .map_err(|err| format!("failed to read stdin: {err}")),
    }
}

fn print_support_matrix(json: bool) {
    let matrix = support_matrix();
    if json {
        println!("{}", support_matrix_json(matrix));
    } else {
        println!("support-matrix schema_version={}", matrix.schema_version);
        println!("semantic_families:");
        for item in matrix.semantic_families {
            println!(
                "- kind={} status={} notes={}",
                item.kind,
                item.status.code(),
                item.notes
            );
        }
        println!("transport_adapters:");
        for item in matrix.transport_adapters {
            println!(
                "- crate={} status={} boundary={} notes={}",
                item.crate_name,
                item.status.code(),
                item.boundary,
                item.notes
            );
        }
        println!("diagnostic_layers:");
        for layer in matrix.diagnostic_layers {
            println!("- code={}", layer.code());
        }
    }
}

fn support_matrix_json(matrix: SupportMatrix) -> String {
    let mut json = String::new();
    json.push_str("{\"schema_version\":");
    json.push_str(&matrix.schema_version.to_string());
    json.push_str(",\"semantic_families\":[");
    for (index, item) in matrix.semantic_families.iter().enumerate() {
        if index > 0 {
            json.push(',');
        }
        json.push_str("{\"kind\":\"");
        json.push_str(&json_escape(item.kind));
        json.push_str("\",\"status\":\"");
        json.push_str(item.status.code());
        json.push_str("\",\"notes\":\"");
        json.push_str(&json_escape(item.notes));
        json.push_str("\"}");
    }
    json.push_str("],\"transport_adapters\":[");
    for (index, item) in matrix.transport_adapters.iter().enumerate() {
        if index > 0 {
            json.push(',');
        }
        json.push_str("{\"crate\":\"");
        json.push_str(&json_escape(item.crate_name));
        json.push_str("\",\"boundary\":\"");
        json.push_str(&json_escape(item.boundary));
        json.push_str("\",\"status\":\"");
        json.push_str(item.status.code());
        json.push_str("\",\"notes\":\"");
        json.push_str(&json_escape(item.notes));
        json.push_str("\"}");
    }
    json.push_str("],\"diagnostic_layers\":[");
    for (index, layer) in matrix.diagnostic_layers.iter().enumerate() {
        if index > 0 {
            json.push(',');
        }
        json.push_str("{\"code\":\"");
        json.push_str(match layer {
            DiagnosticLayer::Parse => "parse",
            DiagnosticLayer::Policy => "policy",
            DiagnosticLayer::Transport => "transport",
        });
        json.push_str("\"}");
    }
    json.push_str("]}");
    json
}

fn json_escape(value: &str) -> String {
    let mut escaped = String::new();
    for ch in value.chars() {
        match ch {
            '"' => escaped.push_str("\\\""),
            '\\' => escaped.push_str("\\\\"),
            '\n' => escaped.push_str("\\n"),
            '\r' => escaped.push_str("\\r"),
            '\t' => escaped.push_str("\\t"),
            '\u{08}' => escaped.push_str("\\b"),
            '\u{0c}' => escaped.push_str("\\f"),
            ch if ch.is_control() => {
                escaped.push_str("\\u");
                escaped.push_str(&format!("{:04x}", u32::from(ch)));
            }
            ch => escaped.push(ch),
        }
    }
    escaped
}

fn lossy(bytes: &[u8]) -> String {
    String::from_utf8_lossy(bytes).into_owned()
}

fn usage() -> String {
    "usage: aprs-cli [parse|validate|stats|explain|replay|support-matrix] [--json] [--permissive] [--explain] [--summary] [--filter SEMANTIC] [--fail-on none|malformed|rejected] [PATH]".to_string()
}