sshping 0.3.1

SSH-based ping that measures interactive character echo latency and file transfer throughput. Pronounced "shipping".
use std::time::Duration;

use serde::{Deserialize, Serialize};
use tabled::Tabled;

use crate::util::Formatter;

#[derive(Deserialize, Serialize)]
pub struct EchoTestSummary {
    pub char_sent: usize,
    pub avg_latency: String,
    pub std_latency: String,
    pub med_latency: String,
    pub min_latency: String,
    pub max_latency: String,
}

impl EchoTestSummary {
    pub fn from_latencies(latencies: &[u128], formatter: &Formatter) -> Result<Self, String> {
        let char_sent = latencies.len();
        if char_sent == 0 {
            return Err("Unable to get any echos in given time".to_string());
        }
        let avg_latency = latencies.iter().sum::<u128>() / (char_sent as u128);
        let std_latency = formatter.format_duration(Duration::from_nanos(
            ((latencies
                .iter()
                .map(|&latency| ((latency as i128) - (avg_latency as i128)).pow(2))
                .sum::<i128>() as f64)
                / (char_sent as f64))
                .sqrt() as u64,
        ));
        let avg_latency = formatter.format_duration(Duration::from_nanos(avg_latency as u64));
        let med_latency = formatter.format_duration(Duration::from_nanos(
            (match char_sent % 2 {
                0 => (latencies[char_sent / 2 - 1] + latencies[char_sent / 2]) / 2,
                _ => latencies[char_sent / 2],
            }) as u64,
        ));
        let min = *latencies
            .first()
            .expect("latencies validated as non-empty above");
        let max = *latencies
            .last()
            .expect("latencies validated as non-empty above");
        let min_latency = formatter.format_duration(Duration::from_nanos(min as u64));
        let max_latency = formatter.format_duration(Duration::from_nanos(max as u64));
        Ok(Self {
            char_sent,
            avg_latency,
            std_latency,
            med_latency,
            min_latency,
            max_latency,
        })
    }
    pub fn to_formatted_frame(&self) -> Vec<Record> {
        vec![
            Record::new("Latency", "Average", self.avg_latency.clone()),
            Record::new("Latency", "Std deviation", self.std_latency.clone()),
            Record::new("Latency", "Median", self.med_latency.clone()),
            Record::new("Latency", "Minimum", self.min_latency.clone()),
            Record::new("Latency", "Maximum", self.max_latency.clone()),
        ]
    }
}

#[derive(Deserialize, Serialize)]
pub struct SpeedTestResult {
    pub size: String,
    pub time: String,
    pub speed: String,
}

impl SpeedTestResult {
    pub fn new(size: u64, time: Duration, formatter: &Formatter) -> Self {
        let time_nanos = time.as_nanos();
        let speed = if time_nanos == 0 {
            0
        } else {
            ((size as u128) * 1_000_000_000 / time_nanos) as u64
        };
        Self {
            size: formatter.format_size(size),
            time: formatter.format_duration(time),
            speed: formatter.format_size(speed) + "/s",
        }
    }
}

#[derive(Deserialize, Serialize)]
pub struct SpeedTestSummary {
    pub upload: SpeedTestResult,
    pub download: SpeedTestResult,
}

impl SpeedTestSummary {
    pub fn to_formatted_frame(&self) -> Vec<Record> {
        vec![
            Record::new("Speed", "Upload", self.upload.speed.clone()),
            Record::new("Speed", "Download", self.download.speed.clone()),
        ]
    }
}

#[derive(Tabled)]
pub struct Record {
    #[tabled(rename = "Test")]
    pub test: &'static str,
    #[tabled(rename = "Metric")]
    pub metric: &'static str,
    #[tabled(rename = "Result")]
    pub result: String,
}

impl Record {
    pub const fn new(test: &'static str, metric: &'static str, result: String) -> Self {
        Self {
            test,
            metric,
            result,
        }
    }
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use super::{EchoTestSummary, SpeedTestResult};
    use crate::util::Formatter;

    #[test]
    fn echo_summary_rejects_empty_latencies() {
        let formatter = Formatter::new(false, Some(','));
        let result = EchoTestSummary::from_latencies(&[], &formatter);
        assert!(result.is_err());
    }

    #[test]
    fn speed_result_handles_zero_duration() {
        let formatter = Formatter::new(false, Some(','));
        let result = SpeedTestResult::new(1024, Duration::from_nanos(0), &formatter);
        assert_eq!(result.speed, "0 B/s");
    }

    #[test]
    fn speed_result_handles_small_non_zero_duration() {
        let formatter = Formatter::new(false, Some(','));
        let result = SpeedTestResult::new(1024, Duration::from_nanos(1), &formatter);
        assert_eq!(result.speed, "1,024,000,000,000 B/s");
    }
}