moonlight-core 0.1.2

Shared comparison, diffing, classification, and JSONL storage primitives for Moonlight.
Documentation
use crate::{config::AppConfig, ComparisonRun, DiffEntry, TargetObservation};
use serde::Serialize;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReportFormat {
    Markdown,
    Json,
}

impl std::str::FromStr for ReportFormat {
    type Err = anyhow::Error;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value {
            "markdown" | "md" => Ok(Self::Markdown),
            "json" => Ok(Self::Json),
            other => anyhow::bail!("invalid report format {other:?}; use markdown or json"),
        }
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct RunReport<'a> {
    pub run: &'a ComparisonRun,
    pub config: Option<&'a AppConfig>,
}

pub fn render_report(
    run: &ComparisonRun,
    config: Option<&AppConfig>,
    format: ReportFormat,
) -> anyhow::Result<String> {
    match format {
        ReportFormat::Json => Ok(serde_json::to_string_pretty(&RunReport { run, config })?),
        ReportFormat::Markdown => Ok(render_markdown(run, config)),
    }
}

fn render_markdown(run: &ComparisonRun, config: Option<&AppConfig>) -> String {
    let mut output = String::new();
    output.push_str(&format!("# Moonlight Report {}\n\n", run.id));
    output.push_str(&format!(
        "- Classification: `{:?}`\n",
        run.comparison.classification
    ));
    output.push_str(&format!("- Adapter: `{:?}`\n", run.adapter));
    output.push_str(&format!("- Timestamp: `{}`\n", run.timestamp.to_rfc3339()));
    output.push_str(&format!("- Input: `{}`\n\n", input_label(run)));

    output.push_str("## Targets\n\n");
    push_target(&mut output, "Primary", &run.primary);
    push_target(&mut output, "Candidate", &run.candidate);
    if let Some(secondary) = &run.secondary {
        push_target(&mut output, "Secondary", secondary);
    }

    push_diffs(
        &mut output,
        "Noise-filtered Diffs",
        &run.comparison.noise_filtered_diffs,
    );
    push_diffs(
        &mut output,
        "Raw Candidate Diffs",
        &run.comparison.raw_candidate_diffs,
    );
    push_diffs(
        &mut output,
        "Reference Noise",
        &run.comparison.reference_noise,
    );

    if let Some(config) = config {
        output.push_str("## Relevant Config\n\n");
        output.push_str(&format!(
            "- Return target: `{:?}`\n- Response timing: `{:?}`\n- Max body capture bytes: `{}`\n- Target timeout ms: `{}`\n- Ignore JSON paths: `{}`\n- Ignore JSON path patterns: `{}`\n- Ignore headers: `{}`\n\n",
            config.return_target,
            config.response_timing,
            config.max_body_capture_bytes,
            config.target_timeout_ms,
            config.ignore_json_paths.join(", "),
            config.ignore_json_path_patterns.join(", "),
            config.ignore_headers.join(", "),
        ));
    }

    output
}

fn input_label(run: &ComparisonRun) -> String {
    match &run.input {
        crate::RunInput::Http {
            method,
            path,
            query,
        } => match query {
            Some(query) => format!("{method} {path}?{query}"),
            None => format!("{method} {path}"),
        },
        crate::RunInput::Cli {
            primary_command,
            candidate_command,
            ..
        } => format!("{primary_command} vs {candidate_command}"),
        crate::RunInput::Project {
            project, check_id, ..
        } => format!("{project} / {check_id}"),
    }
}

fn push_target(output: &mut String, label: &str, target: &TargetObservation) {
    output.push_str(&format!("### {label}\n\n"));
    output.push_str(&format!(
        "- Status: `{}`\n- Latency: `{} ms`\n- Body bytes: `{}`\n- Body SHA-256: `{}`\n- Truncated: `{}`\n",
        target
            .status
            .map(|status| status.to_string())
            .unwrap_or_else(|| "ERR".to_string()),
        target.latency_ms,
        target.body.size_bytes,
        target.body.sha256,
        target.body.truncated,
    ));
    if let Some(error) = &target.error {
        output.push_str(&format!("- Error: `{}`\n", escape_inline(error)));
    }
    if !target.body.preview.is_empty() {
        output.push_str("\n```text\n");
        output.push_str(&target.body.preview);
        output.push_str("\n```\n");
    }
    output.push('\n');
}

fn push_diffs(output: &mut String, title: &str, diffs: &[DiffEntry]) {
    output.push_str(&format!("## {title}\n\n"));
    if diffs.is_empty() {
        output.push_str("No diffs.\n\n");
        return;
    }
    for diff in diffs {
        output.push_str(&format!(
            "- `{}` `{}`: {}\n",
            format!("{:?}", diff.kind).to_ascii_lowercase(),
            diff.path,
            diff.message
        ));
        output.push_str(&format!(
            "  - primary: `{}`\n  - candidate: `{}`\n  - secondary: `{}`\n",
            diff.primary.as_deref().unwrap_or("null"),
            diff.candidate.as_deref().unwrap_or("null"),
            diff.secondary.as_deref().unwrap_or("null"),
        ));
    }
    output.push('\n');
}

fn escape_inline(value: &str) -> String {
    value.replace('`', "'")
}