moonlight-core 0.1.0

Shared comparison, diffing, classification, and JSONL storage primitives for Moonlight.
Documentation
use super::*;
use crate::{
    Adapter, BodyCapture, Classification, ComparisonRun, ComparisonSummary, RunInput,
    TargetObservation,
};
use chrono::{TimeZone, Utc};
use std::collections::BTreeMap;
use tempfile::tempdir;

fn body() -> BodyCapture {
    BodyCapture {
        size_bytes: 0,
        sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
        preview: String::new(),
        truncated: false,
    }
}

fn target(latency_ms: u128) -> TargetObservation {
    TargetObservation {
        status: Some(0),
        headers: BTreeMap::new(),
        body: body(),
        stderr: None,
        latency_ms,
        error: None,
    }
}

fn run(
    path: impl Into<String>,
    timestamp_seconds: i64,
    classification: Classification,
    secondary: bool,
) -> ComparisonRun {
    let path = path.into();
    ComparisonRun {
        id: Uuid::new_v4(),
        timestamp: Utc.timestamp_opt(timestamp_seconds, 0).unwrap(),
        adapter: Adapter::Http,
        input: RunInput::Http {
            method: "GET".to_string(),
            path,
            query: None,
        },
        request_headers: BTreeMap::new(),
        request_body: body(),
        primary: target(10),
        candidate: target(20),
        secondary: secondary.then(|| target(30)),
        comparison: ComparisonSummary {
            classification,
            ..Default::default()
        },
    }
}

#[tokio::test]
async fn load_creates_parent_directory() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("nested").join("http-runs.jsonl");

    let _storage = Storage::load(path.clone()).await.unwrap();

    assert!(path.parent().unwrap().exists());
}

#[tokio::test]
async fn run_writer_creates_parent_directory() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("nested").join("cli-runs.jsonl");

    let writer = RunWriter::open(path.clone()).await.unwrap();
    writer
        .append(&run("writer", 1, Classification::Match, false))
        .await
        .unwrap();
    writer.flush().await.unwrap();

    assert!(path.exists());
}

#[tokio::test]
async fn run_writer_appends_without_loading_existing_files() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("cli-runs.jsonl");
    std::fs::write(dir.path().join("corrupt.jsonl"), "not-json\n").unwrap();

    let writer = RunWriter::open(path.clone()).await.unwrap();
    writer
        .append(&run("writer", 1, Classification::Match, false))
        .await
        .unwrap();
    writer.flush().await.unwrap();

    let lines = std::fs::read_to_string(path).unwrap();
    assert_eq!(lines.lines().count(), 1);
}

#[tokio::test]
async fn load_skips_empty_and_corrupt_jsonl_lines() {
    let dir = tempdir().unwrap();
    let path = dir.path().join("http-runs.jsonl");
    let valid = serde_json::to_string(&run("valid", 1, Classification::Match, false)).unwrap();
    std::fs::write(&path, format!("\n{valid}\nnot-json\n\n")).unwrap();

    let storage = Storage::load(path).await.unwrap();
    let runs = storage.list().await;

    assert_eq!(runs.len(), 1);
    assert!(matches!(
        runs[0].input,
        RunInput::Http { ref path, .. } if path == "valid"
    ));
}

#[tokio::test]
async fn list_returns_newest_first() {
    let dir = tempdir().unwrap();
    let storage = Storage::load(dir.path().join("http-runs.jsonl"))
        .await
        .unwrap();
    let first = run("first", 1, Classification::Match, false);
    let second = run("second", 2, Classification::SuspiciousDifference, false);
    storage.insert(first).await.unwrap();
    storage.insert(second).await.unwrap();

    let runs = storage.list().await;

    assert!(matches!(
        runs[0].input,
        RunInput::Http { ref path, .. } if path == "second"
    ));
    assert!(matches!(
        runs[1].input,
        RunInput::Http { ref path, .. } if path == "first"
    ));
}

#[tokio::test]
async fn load_merges_jsonl_files_in_same_directory() {
    let dir = tempdir().unwrap();
    let http_path = dir.path().join("http-runs.jsonl");
    let cli_path = dir.path().join("cli-runs.jsonl");
    std::fs::write(
        &http_path,
        format!(
            "{}\n",
            serde_json::to_string(&run("http", 1, Classification::Match, false)).unwrap()
        ),
    )
    .unwrap();
    std::fs::write(
        &cli_path,
        format!(
            "{}\n",
            serde_json::to_string(&run("cli", 2, Classification::ReferenceNoise, true)).unwrap()
        ),
    )
    .unwrap();

    let storage = Storage::load(http_path).await.unwrap();
    let stats = storage.stats().await;

    assert_eq!(stats.total_runs, 2);
    assert_eq!(stats.matches, 1);
    assert_eq!(stats.reference_noise, 1);
}

#[tokio::test]
async fn storage_load_still_scans_directory_for_admin_views() {
    let dir = tempdir().unwrap();
    let http_path = dir.path().join("http-runs.jsonl");
    let cli_path = dir.path().join("cli-runs.jsonl");
    std::fs::write(
        &http_path,
        format!(
            "{}\n",
            serde_json::to_string(&run("http", 1, Classification::Match, false)).unwrap()
        ),
    )
    .unwrap();
    std::fs::write(
        &cli_path,
        format!(
            "{}\n",
            serde_json::to_string(&run("cli", 2, Classification::SuspiciousDifference, false))
                .unwrap()
        ),
    )
    .unwrap();

    let storage = Storage::load(http_path).await.unwrap();
    let stats = storage.stats().await;

    assert_eq!(stats.total_runs, 2);
    assert_eq!(stats.matches, 1);
    assert_eq!(stats.suspicious_differences, 1);
}

#[tokio::test]
async fn stats_limits_latest_runs_to_20() {
    let dir = tempdir().unwrap();
    let storage = Storage::load(dir.path().join("http-runs.jsonl"))
        .await
        .unwrap();
    for index in 0..25 {
        storage
            .insert(run(
                format!("run-{index}"),
                index,
                Classification::Match,
                false,
            ))
            .await
            .unwrap();
    }

    let stats = storage.stats().await;

    assert_eq!(stats.total_runs, 25);
    assert_eq!(stats.latest_runs.len(), 20);
    assert!(matches!(
        stats.latest_runs[0].input,
        RunInput::Http { ref path, .. } if path == "run-24"
    ));
    assert!(matches!(
        stats.latest_runs[19].input,
        RunInput::Http { ref path, .. } if path == "run-5"
    ));
}

#[tokio::test]
async fn stats_handles_missing_secondary_latencies() {
    let dir = tempdir().unwrap();
    let storage = Storage::load(dir.path().join("http-runs.jsonl"))
        .await
        .unwrap();
    storage
        .insert(run("primary-candidate", 1, Classification::Match, false))
        .await
        .unwrap();

    let stats = storage.stats().await;

    assert_eq!(stats.total_runs, 1);
    assert_eq!(stats.latency.primary_avg_ms, 10.0);
    assert_eq!(stats.latency.candidate_avg_ms, 20.0);
    assert_eq!(stats.latency.secondary_avg_ms, None);
}