moonlight-core 0.1.2

Shared comparison, diffing, classification, and JSONL storage primitives for Moonlight.
Documentation
use crate::ComparisonRun;
use std::path::Path;
use tokio::{
    fs::{self, OpenOptions},
    io::AsyncWriteExt,
};
use uuid::Uuid;

#[derive(Debug, Clone, Copy, Default)]
pub struct StorageOptions {
    pub retention_max_runs: Option<usize>,
    pub retention_max_bytes: Option<u64>,
}

impl StorageOptions {
    pub(super) fn is_configured(self) -> bool {
        self.retention_max_runs.is_some() || self.retention_max_bytes.is_some()
    }
}

pub(super) fn retain_runs(
    mut runs: Vec<ComparisonRun>,
    options: StorageOptions,
) -> anyhow::Result<Vec<ComparisonRun>> {
    if let Some(max_runs) = options.retention_max_runs {
        runs = retain_by_max_runs(runs, max_runs);
    }
    if let Some(max_bytes) = options.retention_max_bytes {
        runs = retain_by_max_bytes(runs, max_bytes)?;
    }
    Ok(runs)
}

fn retain_by_max_runs(runs: Vec<ComparisonRun>, max_runs: usize) -> Vec<ComparisonRun> {
    if runs.len() <= max_runs {
        return runs;
    }

    let mut retained = runs.into_iter().rev().take(max_runs).collect::<Vec<_>>();
    retained.reverse();
    retained
}

fn retain_by_max_bytes(
    runs: Vec<ComparisonRun>,
    max_bytes: u64,
) -> anyhow::Result<Vec<ComparisonRun>> {
    let mut retained = Vec::new();
    let mut total_bytes = 0_u64;
    for run in runs.into_iter().rev() {
        let line = serde_json::to_string(&run)?;
        let line_bytes = line.len() as u64 + 1;
        if total_bytes + line_bytes <= max_bytes || retained.is_empty() {
            total_bytes += line_bytes;
            retained.push(run);
        } else {
            break;
        }
    }
    retained.reverse();
    Ok(retained)
}

pub(super) fn serialize_runs_jsonl(runs: &[ComparisonRun]) -> anyhow::Result<String> {
    let mut content = String::new();
    for run in runs {
        content.push_str(&serde_json::to_string(run)?);
        content.push('\n');
    }
    Ok(content)
}

pub(super) async fn atomic_write(path: &Path, content: String) -> anyhow::Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).await?;
    }
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    let file_name = path
        .file_name()
        .and_then(|value| value.to_str())
        .unwrap_or("moonlight-runs.jsonl");
    let temp_path = parent.join(format!(
        ".{file_name}.{}.{}.tmp",
        std::process::id(),
        Uuid::new_v4()
    ));

    let mut file = OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(&temp_path)
        .await?;
    file.write_all(content.as_bytes()).await?;
    file.flush().await?;
    file.sync_data().await?;
    drop(file);
    fs::rename(&temp_path, path).await?;
    Ok(())
}