moonlight-core 0.1.0

Shared comparison, diffing, classification, and JSONL storage primitives for Moonlight.
Documentation
use crate::{Classification, ComparisonRun, ComparisonRunListItem, LatencyStats, StatsSummary};
use std::{
    path::{Path, PathBuf},
    sync::Arc,
};
use tokio::{
    fs::{self, File, OpenOptions},
    io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter},
    sync::{Mutex, RwLock},
};
use uuid::Uuid;

#[derive(Clone)]
pub struct Storage {
    write_path: PathBuf,
    scan_dir: PathBuf,
    runs: Arc<RwLock<Vec<ComparisonRun>>>,
}

#[derive(Clone)]
pub struct RunWriter {
    file: Arc<Mutex<BufWriter<File>>>,
}

impl RunWriter {
    pub async fn open(write_path: PathBuf) -> anyhow::Result<Self> {
        if let Some(parent) = write_path.parent() {
            fs::create_dir_all(parent).await?;
        }
        let file = OpenOptions::new()
            .create(true)
            .append(true)
            .open(write_path)
            .await?;

        Ok(Self {
            file: Arc::new(Mutex::new(BufWriter::new(file))),
        })
    }

    pub async fn append(&self, run: &ComparisonRun) -> anyhow::Result<()> {
        let line = serde_json::to_string(run)?;
        let mut file = self.file.lock().await;
        file.write_all(line.as_bytes()).await?;
        file.write_all(b"\n").await?;
        Ok(())
    }

    pub async fn flush(&self) -> anyhow::Result<()> {
        self.file.lock().await.flush().await?;
        Ok(())
    }
}

impl Storage {
    pub async fn load(write_path: PathBuf) -> anyhow::Result<Self> {
        if let Some(parent) = write_path.parent() {
            fs::create_dir_all(parent).await?;
        }
        let scan_dir = write_path
            .parent()
            .map(Path::to_path_buf)
            .unwrap_or_else(|| PathBuf::from("."));
        let runs = load_runs_from_dir(&scan_dir).await?;

        Ok(Self {
            write_path,
            scan_dir,
            runs: Arc::new(RwLock::new(runs)),
        })
    }

    pub async fn insert(&self, run: ComparisonRun) -> anyhow::Result<()> {
        let writer = RunWriter::open(self.write_path.clone()).await?;
        writer.append(&run).await?;
        writer.flush().await?;
        self.runs.write().await.push(run);
        Ok(())
    }

    pub async fn refresh(&self) -> anyhow::Result<()> {
        let runs = load_runs_from_dir(&self.scan_dir).await?;
        *self.runs.write().await = runs;
        Ok(())
    }

    pub async fn list(&self) -> Vec<ComparisonRunListItem> {
        let runs = self.runs.read().await;
        runs.iter().rev().map(ComparisonRunListItem::from).collect()
    }

    pub async fn get(&self, id: Uuid) -> Option<ComparisonRun> {
        let runs = self.runs.read().await;
        runs.iter().find(|run| run.id == id).cloned()
    }

    pub async fn stats(&self) -> StatsSummary {
        let runs = self.runs.read().await;
        let mut matches = 0;
        let mut suspicious_differences = 0;
        let mut reference_noise = 0;
        let mut suspicious_with_noise = 0;
        let mut target_errors = 0;
        let mut primary_total = 0_u128;
        let mut candidate_total = 0_u128;
        let mut secondary_latencies = Vec::new();

        for run in runs.iter() {
            match run.comparison.classification {
                Classification::Match => matches += 1,
                Classification::SuspiciousDifference => suspicious_differences += 1,
                Classification::ReferenceNoise => reference_noise += 1,
                Classification::SuspiciousWithNoise => suspicious_with_noise += 1,
                Classification::TargetError => target_errors += 1,
            }
            primary_total += run.primary.latency_ms;
            candidate_total += run.candidate.latency_ms;
            if let Some(secondary) = &run.secondary {
                secondary_latencies.push(secondary.latency_ms);
            }
        }

        let total_runs = runs.len();
        StatsSummary {
            total_runs,
            matches,
            suspicious_differences,
            reference_noise,
            suspicious_with_noise,
            target_errors,
            latency: LatencyStats {
                primary_avg_ms: avg(total_runs, primary_total),
                candidate_avg_ms: avg(total_runs, candidate_total),
                secondary_avg_ms: avg_opt(&secondary_latencies),
            },
            latest_runs: runs
                .iter()
                .rev()
                .take(20)
                .map(ComparisonRunListItem::from)
                .collect(),
        }
    }
}

async fn load_runs_from_dir(scan_dir: &Path) -> anyhow::Result<Vec<ComparisonRun>> {
    let mut runs = Vec::new();
    if !fs::try_exists(scan_dir).await? {
        return Ok(runs);
    }

    let mut entries = fs::read_dir(scan_dir).await?;
    while let Some(entry) = entries.next_entry().await? {
        let path = entry.path();
        if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
            continue;
        }
        load_runs_from_file(&path, &mut runs).await?;
    }

    runs.sort_by_key(|run| run.timestamp);
    Ok(runs)
}

async fn load_runs_from_file(path: &Path, runs: &mut Vec<ComparisonRun>) -> anyhow::Result<()> {
    let file = fs::File::open(path).await?;
    let mut lines = BufReader::new(file).lines();
    while let Some(line) = lines.next_line().await? {
        if line.trim().is_empty() {
            continue;
        }
        match serde_json::from_str::<ComparisonRun>(&line) {
            Ok(run) => runs.push(run),
            Err(error) => eprintln!(
                "skipping corrupt moonlight JSONL run in {}: {error}",
                path.display()
            ),
        }
    }
    Ok(())
}

fn avg(count: usize, total: u128) -> f64 {
    if count == 0 {
        0.0
    } else {
        total as f64 / count as f64
    }
}

fn avg_opt(values: &[u128]) -> Option<f64> {
    if values.is_empty() {
        None
    } else {
        Some(values.iter().sum::<u128>() as f64 / values.len() as f64)
    }
}

#[cfg(test)]
mod tests;