Skip to main content

moonlight_core/
report.rs

1use crate::{config::AppConfig, ComparisonRun, DiffEntry, TargetObservation};
2use serde::Serialize;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ReportFormat {
6    Markdown,
7    Json,
8}
9
10impl std::str::FromStr for ReportFormat {
11    type Err = anyhow::Error;
12
13    fn from_str(value: &str) -> Result<Self, Self::Err> {
14        match value {
15            "markdown" | "md" => Ok(Self::Markdown),
16            "json" => Ok(Self::Json),
17            other => anyhow::bail!("invalid report format {other:?}; use markdown or json"),
18        }
19    }
20}
21
22#[derive(Debug, Clone, Serialize)]
23pub struct RunReport<'a> {
24    pub run: &'a ComparisonRun,
25    pub config: Option<&'a AppConfig>,
26}
27
28pub fn render_report(
29    run: &ComparisonRun,
30    config: Option<&AppConfig>,
31    format: ReportFormat,
32) -> anyhow::Result<String> {
33    match format {
34        ReportFormat::Json => Ok(serde_json::to_string_pretty(&RunReport { run, config })?),
35        ReportFormat::Markdown => Ok(render_markdown(run, config)),
36    }
37}
38
39fn render_markdown(run: &ComparisonRun, config: Option<&AppConfig>) -> String {
40    let mut output = String::new();
41    output.push_str(&format!("# Moonlight Report {}\n\n", run.id));
42    output.push_str(&format!(
43        "- Classification: `{:?}`\n",
44        run.comparison.classification
45    ));
46    output.push_str(&format!("- Adapter: `{:?}`\n", run.adapter));
47    output.push_str(&format!("- Timestamp: `{}`\n", run.timestamp.to_rfc3339()));
48    output.push_str(&format!("- Input: `{}`\n\n", input_label(run)));
49
50    output.push_str("## Targets\n\n");
51    push_target(&mut output, "Primary", &run.primary);
52    push_target(&mut output, "Candidate", &run.candidate);
53    if let Some(secondary) = &run.secondary {
54        push_target(&mut output, "Secondary", secondary);
55    }
56
57    push_diffs(
58        &mut output,
59        "Noise-filtered Diffs",
60        &run.comparison.noise_filtered_diffs,
61    );
62    push_diffs(
63        &mut output,
64        "Raw Candidate Diffs",
65        &run.comparison.raw_candidate_diffs,
66    );
67    push_diffs(
68        &mut output,
69        "Reference Noise",
70        &run.comparison.reference_noise,
71    );
72
73    if let Some(config) = config {
74        output.push_str("## Relevant Config\n\n");
75        output.push_str(&format!(
76            "- 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",
77            config.return_target,
78            config.response_timing,
79            config.max_body_capture_bytes,
80            config.target_timeout_ms,
81            config.ignore_json_paths.join(", "),
82            config.ignore_json_path_patterns.join(", "),
83            config.ignore_headers.join(", "),
84        ));
85    }
86
87    output
88}
89
90fn input_label(run: &ComparisonRun) -> String {
91    match &run.input {
92        crate::RunInput::Http {
93            method,
94            path,
95            query,
96        } => match query {
97            Some(query) => format!("{method} {path}?{query}"),
98            None => format!("{method} {path}"),
99        },
100        crate::RunInput::Cli {
101            primary_command,
102            candidate_command,
103            ..
104        } => format!("{primary_command} vs {candidate_command}"),
105        crate::RunInput::Project {
106            project, check_id, ..
107        } => format!("{project} / {check_id}"),
108    }
109}
110
111fn push_target(output: &mut String, label: &str, target: &TargetObservation) {
112    output.push_str(&format!("### {label}\n\n"));
113    output.push_str(&format!(
114        "- Status: `{}`\n- Latency: `{} ms`\n- Body bytes: `{}`\n- Body SHA-256: `{}`\n- Truncated: `{}`\n",
115        target
116            .status
117            .map(|status| status.to_string())
118            .unwrap_or_else(|| "ERR".to_string()),
119        target.latency_ms,
120        target.body.size_bytes,
121        target.body.sha256,
122        target.body.truncated,
123    ));
124    if let Some(error) = &target.error {
125        output.push_str(&format!("- Error: `{}`\n", escape_inline(error)));
126    }
127    if !target.body.preview.is_empty() {
128        output.push_str("\n```text\n");
129        output.push_str(&target.body.preview);
130        output.push_str("\n```\n");
131    }
132    output.push('\n');
133}
134
135fn push_diffs(output: &mut String, title: &str, diffs: &[DiffEntry]) {
136    output.push_str(&format!("## {title}\n\n"));
137    if diffs.is_empty() {
138        output.push_str("No diffs.\n\n");
139        return;
140    }
141    for diff in diffs {
142        output.push_str(&format!(
143            "- `{}` `{}`: {}\n",
144            format!("{:?}", diff.kind).to_ascii_lowercase(),
145            diff.path,
146            diff.message
147        ));
148        output.push_str(&format!(
149            "  - primary: `{}`\n  - candidate: `{}`\n  - secondary: `{}`\n",
150            diff.primary.as_deref().unwrap_or("null"),
151            diff.candidate.as_deref().unwrap_or("null"),
152            diff.secondary.as_deref().unwrap_or("null"),
153        ));
154    }
155    output.push('\n');
156}
157
158fn escape_inline(value: &str) -> String {
159    value.replace('`', "'")
160}