netwatch-sdk 0.1.3

Shared wire-format types and collectors for NetWatch Cloud — the SDK consumed by netwatch-agent and the NetWatch Cloud server. Parses /proc, ss, lsof, nettop, and libpcap events into a common Snapshot payload.
Documentation
use std::collections::VecDeque;
use std::process::Command;

/// Rolling window size for gateway/DNS RTT history, in samples.
pub const RTT_HISTORY_LEN: usize = 60;

#[derive(Debug, Clone)]
pub struct PingResult {
    pub rtt_ms: Option<f64>,
    pub loss_pct: f64,
}

/// Tracks a rolling window of RTT samples (None when a probe failed or was
/// skipped). Consumers push one sample per collection cycle and read back the
/// full window for wire serialization.
#[derive(Debug, Default)]
pub struct RttHistory {
    samples: VecDeque<Option<f64>>,
}

impl RttHistory {
    pub fn new() -> Self {
        Self {
            samples: VecDeque::with_capacity(RTT_HISTORY_LEN),
        }
    }

    pub fn push(&mut self, rtt_ms: Option<f64>) {
        self.samples.push_back(rtt_ms);
        if self.samples.len() > RTT_HISTORY_LEN {
            self.samples.pop_front();
        }
    }

    pub fn snapshot(&self) -> Vec<Option<f64>> {
        self.samples.iter().copied().collect()
    }

    pub fn len(&self) -> usize {
        self.samples.len()
    }

    pub fn is_empty(&self) -> bool {
        self.samples.is_empty()
    }
}

pub fn run_ping(target: &str) -> PingResult {
    let output = match Command::new("ping")
        .args(["-c", "3", "-W", "1", target])
        .output()
    {
        Ok(o) => o,
        Err(_) => {
            return PingResult {
                rtt_ms: None,
                loss_pct: 100.0,
            }
        }
    };

    let text = String::from_utf8_lossy(&output.stdout);
    PingResult {
        rtt_ms: parse_avg_rtt(&text),
        loss_pct: parse_loss(&text),
    }
}

fn parse_loss(output: &str) -> f64 {
    for line in output.lines() {
        if line.contains("packet loss") || line.contains("% loss") {
            for part in line.split_whitespace() {
                if part.ends_with('%') {
                    if let Ok(val) = part.trim_end_matches('%').parse::<f64>() {
                        return val;
                    }
                }
            }
            for segment in line.split(',') {
                let trimmed = segment.trim();
                if trimmed.contains("% packet loss") || trimmed.contains("% loss") {
                    if let Some(pct_str) = trimmed.split('%').next() {
                        let pct_str = pct_str.trim();
                        if let Ok(val) = pct_str.parse::<f64>() {
                            return val;
                        }
                        if let Some(last_word) = pct_str.split_whitespace().last() {
                            let cleaned = last_word.trim_start_matches('(');
                            if let Ok(val) = cleaned.parse::<f64>() {
                                return val;
                            }
                        }
                    }
                }
            }
        }
    }
    100.0
}

fn parse_avg_rtt(output: &str) -> Option<f64> {
    for line in output.lines() {
        if line.contains("min/avg/max") || line.contains("rtt min/avg/max") {
            if let Some(stats) = line.split('=').nth(1) {
                let parts: Vec<&str> = stats.trim().split('/').collect();
                if parts.len() >= 2 {
                    return parts[1].trim().parse().ok();
                }
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_loss_zero() {
        let output = "3 packets transmitted, 3 received, 0% packet loss, time 2003ms";
        assert_eq!(parse_loss(output), 0.0);
    }

    #[test]
    fn parse_loss_partial() {
        let output = "3 packets transmitted, 1 received, 66.7% packet loss, time 2003ms";
        assert_eq!(parse_loss(output), 66.7);
    }

    #[test]
    fn parse_loss_full() {
        let output = "3 packets transmitted, 0 received, 100% packet loss, time 2003ms";
        assert_eq!(parse_loss(output), 100.0);
    }

    #[test]
    fn parse_loss_empty() {
        assert_eq!(parse_loss(""), 100.0);
    }

    #[test]
    fn parse_avg_rtt_linux() {
        let output = "rtt min/avg/max/mdev = 0.123/0.456/0.789/0.111 ms";
        assert_eq!(parse_avg_rtt(output), Some(0.456));
    }

    #[test]
    fn parse_avg_rtt_empty() {
        assert_eq!(parse_avg_rtt(""), None);
    }

    #[test]
    fn rtt_history_caps_at_window() {
        let mut h = RttHistory::new();
        for i in 0..(RTT_HISTORY_LEN + 5) {
            h.push(Some(i as f64));
        }
        assert_eq!(h.len(), RTT_HISTORY_LEN);
        let snap = h.snapshot();
        // Oldest sample should have been evicted.
        assert_eq!(snap.first(), Some(&Some(5.0)));
    }

    #[test]
    fn rtt_history_preserves_none_samples() {
        let mut h = RttHistory::new();
        h.push(Some(10.0));
        h.push(None);
        h.push(Some(20.0));
        assert_eq!(h.snapshot(), vec![Some(10.0), None, Some(20.0)]);
    }

    #[test]
    fn rtt_history_is_empty_on_construction() {
        let h = RttHistory::new();
        assert!(h.is_empty());
        assert_eq!(h.len(), 0);
        assert!(h.snapshot().is_empty());
    }

    #[test]
    fn parse_loss_falls_back_when_no_loss_line_present() {
        // No "packet loss" marker → safe default of 100% (assume probe
        // failed rather than silently reporting 0).
        assert_eq!(parse_loss("PING 1.1.1.1: 3 packets"), 100.0);
    }

    #[test]
    fn parse_avg_rtt_handles_macos_style_prefix() {
        // macOS omits the "rtt" prefix that Linux uses.
        let output = "round-trip min/avg/max/stddev = 1.2/3.4/5.6/0.1 ms";
        assert_eq!(parse_avg_rtt(output), Some(3.4));
    }

    // ── Live smoke test (requires `ping` + loopback) ────────────────────

    /// Exercise the end-to-end path against 127.0.0.1 so we know
    /// `run_ping` still talks to the system `ping` binary and parses its
    /// real output. Loopback ping is universally available on dev hosts
    /// and on GitHub-hosted runners; on rare sandboxed environments where
    /// `ping` is missing, the function itself falls back to
    /// `loss_pct = 100.0, rtt_ms = None`, so we assert only that one of
    /// those shapes is returned — never a panic.
    #[test]
    fn run_ping_smoke_test_against_loopback() {
        let result = run_ping("127.0.0.1");
        // Either ping succeeded (loss 0–50%, rtt present) or the binary
        // was missing / blocked (loss 100%, rtt None). Reject anything
        // in between, which would indicate a parsing regression.
        if result.loss_pct <= 50.0 {
            assert!(
                result.rtt_ms.is_some(),
                "loopback ping reported low loss but no rtt_ms: {result:?}"
            );
        } else {
            assert!(
                result.rtt_ms.is_none(),
                "high-loss ping should have rtt_ms = None: {result:?}"
            );
            assert!((result.loss_pct - 100.0).abs() < f64::EPSILON);
        }
    }
}