rkik 2.2.1

Rusty Klock Inspection Kit - NTP Query and Compare Tool
Documentation
use crate::domain::ntp::ProbeResult;
use crate::error::RkikError;
use std::fmt::Write as FmtWrite;

fn escape_csv(s: &str) -> String {
    if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') {
        format!("\"{}\"", s.replace('"', "\"\""))
    } else {
        s.to_string()
    }
}

pub const HEADER: &str = "target,stratum,offset_ms,delay_ms,timestamp";

pub fn rows(results: &[ProbeResult]) -> Result<String, RkikError> {
    let mut out = String::new();
    for r in results {
        let target = escape_csv(&r.target.name);
        writeln!(
            &mut out,
            "{},{},{:.3},{:.3},{}",
            target, r.stratum, r.offset_ms, r.rtt_ms, r.timestamp
        )
        .map_err(|e| RkikError::Other(e.to_string()))?;
    }
    Ok(out)
}

pub fn to_csv(results: &[ProbeResult]) -> Result<String, RkikError> {
    let mut out = format!("{}\n", HEADER);
    out.push_str(&rows(results)?);
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::ntp::Target;
    use std::net::IpAddr;

    fn sample_probe(
        name: &str,
        stratum: u8,
        offset_ms: f64,
        rtt_ms: f64,
        timestamp: i64,
    ) -> ProbeResult {
        let utc = chrono::Utc::now();
        let local = chrono::DateTime::from(utc);
        ProbeResult {
            target: Target {
                name: name.into(),
                ip: "127.0.0.1".parse::<IpAddr>().unwrap(),
                port: 123,
            },
            offset_ms,
            rtt_ms,
            stratum,
            ref_id: "LOCL".into(),
            utc,
            local,
            timestamp,
            authenticated: false,
            #[cfg(feature = "nts")]
            nts_ke_data: None,
            #[cfg(feature = "nts")]
            nts_validation: None,
        }
    }

    #[test]
    fn single_target_produces_header_and_one_row() {
        let p = sample_probe("time.google.com", 1, 1.234, 15.678, 1680000000);
        let csv = to_csv(&[p]).unwrap();
        let lines: Vec<&str> = csv.trim_end().split('\n').collect();
        assert_eq!(lines.len(), 2);
        assert_eq!(lines[0], "target,stratum,offset_ms,delay_ms,timestamp");
        assert_eq!(lines[1], "time.google.com,1,1.234,15.678,1680000000");
    }

    #[test]
    fn multiple_targets_produces_header_and_multiple_rows() {
        let p1 = sample_probe("time.google.com", 1, 1.234, 15.678, 1680000000);
        let p2 = sample_probe("pool.ntp.org", 2, -2.500, 20.000, 1680000001);
        let csv = to_csv(&[p1, p2]).unwrap();
        let lines: Vec<&str> = csv.trim_end().split('\n').collect();
        assert_eq!(lines.len(), 3);
        assert_eq!(lines[0], "target,stratum,offset_ms,delay_ms,timestamp");
        assert_eq!(lines[1], "time.google.com,1,1.234,15.678,1680000000");
        assert_eq!(lines[2], "pool.ntp.org,2,-2.500,20.000,1680000001");
    }

    #[test]
    fn fields_with_special_characters_are_escaped() {
        let p = sample_probe("server,with\"quotes\nand,commas", 3, 0.0, 0.0, 0);
        let csv = to_csv(&[p]).unwrap();
        assert_eq!(
            csv,
            "target,stratum,offset_ms,delay_ms,timestamp\n\"server,with\"\"quotes\nand,commas\",3,0.000,0.000,0\n"
        );
    }
}