netpulse-cli 0.1.1

A zero-config, single-binary network quality monitor with percentile stats, jitter, and MTR-style traceroute
Documentation
// src/export/csv.rs — CSV Exporter
//
// Appends StatsSnapshot rows to a CSV file on disk.
// Creates the file (with header) if it doesn't exist.
// Each row is one stats report for one target at one point in time.

use crate::stats::StatsSnapshot;
use anyhow::{Context, Result};
use std::{
    fs::OpenOptions,
    io::{BufWriter, Write},
    path::{Path, PathBuf},
};

const CSV_HEADER: &str = "timestamp,target,sample_count,loss_pct,rtt_min_us,rtt_mean_us,\
     rtt_p50_us,rtt_p90_us,rtt_p95_us,rtt_p99_us,jitter_us,max_burst_loss,reorder_count\n";

/// Appends StatsSnapshots as CSV rows to a file.
pub struct CsvExporter {
    path: PathBuf,
}

impl CsvExporter {
    /// Create a new CsvExporter targeting the given file.
    /// The file and its header are created on first write if not present.
    pub fn new(path: impl AsRef<Path>) -> Self {
        Self {
            path: path.as_ref().to_path_buf(),
        }
    }

    /// Append a single StatsSnapshot row to the CSV file.
    /// Writes the CSV header if the file is newly created.
    pub fn emit(&self, snapshot: &StatsSnapshot) -> Result<()> {
        let is_new = !self.path.exists();

        let file = OpenOptions::new()
            .create(true)
            .append(true)
            .open(&self.path)
            .with_context(|| format!("cannot open CSV file {:?}", self.path))?;

        let mut writer = BufWriter::new(file);

        if is_new {
            writer
                .write_all(CSV_HEADER.as_bytes())
                .context("failed to write CSV header")?;
        }

        let now = chrono::Utc::now().to_rfc3339();

        writeln!(
            writer,
            "{},{},{},{:.2},{},{:.2},{},{},{},{},{:.2},{},{}",
            now,
            snapshot.target,
            snapshot.sample_count,
            snapshot.loss_pct,
            snapshot.rtt_min_us.unwrap_or(0),
            snapshot.rtt_mean_us.unwrap_or(0.0),
            snapshot.rtt_p50_us.unwrap_or(0),
            snapshot.rtt_p90_us.unwrap_or(0),
            snapshot.rtt_p95_us.unwrap_or(0),
            snapshot.rtt_p99_us.unwrap_or(0),
            snapshot.jitter_us.unwrap_or(0.0),
            snapshot.max_burst_loss,
            snapshot.reorder_count,
        )
        .context("failed to write CSV row")?;

        writer.flush().context("failed to flush CSV writer")
    }
}