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}