1use crate::{
2 compare::{compare_targets, CompareConfig},
3 target::CapturedTarget,
4 Adapter, BodyCapture, ComparisonRun, RunInput,
5};
6use chrono::{DateTime, Utc};
7use std::collections::BTreeMap;
8use uuid::Uuid;
9
10#[derive(Debug, Clone)]
12pub struct RunMetadata {
13 pub id: Uuid,
14 pub timestamp: DateTime<Utc>,
15 pub adapter: Adapter,
16 pub input: RunInput,
17 pub request_headers: BTreeMap<String, String>,
18 pub request_body: BodyCapture,
19}
20
21#[derive(Debug, Clone)]
23pub struct CapturedTargets {
24 pub primary: CapturedTarget,
25 pub candidate: CapturedTarget,
26 pub secondary: Option<CapturedTarget>,
27}
28
29pub fn build_comparison_run(
34 metadata: RunMetadata,
35 targets: CapturedTargets,
36 compare_config: &CompareConfig,
37) -> ComparisonRun {
38 let comparison = compare_targets(
39 &targets.primary,
40 &targets.candidate,
41 targets.secondary.as_ref(),
42 compare_config,
43 );
44
45 ComparisonRun {
46 id: metadata.id,
47 timestamp: metadata.timestamp,
48 adapter: metadata.adapter,
49 input: metadata.input,
50 request_headers: metadata.request_headers,
51 request_body: metadata.request_body,
52 primary: targets.primary.observation,
53 candidate: targets.candidate.observation,
54 secondary: targets.secondary.map(|target| target.observation),
55 comparison,
56 }
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62 use crate::{compare::capture_body, Classification, DiffKind, TargetObservation};
63 use bytes::Bytes;
64 use std::collections::BTreeMap;
65
66 fn config() -> CompareConfig {
67 CompareConfig::new(&[], &[], false)
68 }
69
70 fn metadata(adapter: Adapter, input: RunInput) -> RunMetadata {
71 RunMetadata {
72 id: Uuid::nil(),
73 timestamp: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
74 adapter,
75 input,
76 request_headers: BTreeMap::new(),
77 request_body: capture_body(&[], 1024),
78 }
79 }
80
81 fn target(status: u16, body: &str) -> CapturedTarget {
82 let bytes = Bytes::from(body.to_string());
83 CapturedTarget {
84 observation: TargetObservation {
85 status: Some(status),
86 headers: BTreeMap::new(),
87 body: capture_body(&bytes, 1024),
88 stderr: Some(capture_body(&[], 1024)),
89 latency_ms: 7,
90 error: None,
91 },
92 body_bytes: bytes,
93 stderr_bytes: Bytes::new(),
94 }
95 }
96
97 fn target_error(message: &str) -> CapturedTarget {
98 CapturedTarget {
99 observation: TargetObservation {
100 status: None,
101 headers: BTreeMap::new(),
102 body: capture_body(&[], 1024),
103 stderr: Some(capture_body(&[], 1024)),
104 latency_ms: 0,
105 error: Some(message.to_string()),
106 },
107 body_bytes: Bytes::new(),
108 stderr_bytes: Bytes::new(),
109 }
110 }
111
112 #[test]
113 fn cli_metadata_builds_classified_run() {
114 let run = build_comparison_run(
115 metadata(
116 Adapter::Cli,
117 RunInput::Cli {
118 primary_command: "printf one".to_string(),
119 candidate_command: "printf two".to_string(),
120 secondary_command: None,
121 },
122 ),
123 CapturedTargets {
124 primary: target(0, "one"),
125 candidate: target(0, "two"),
126 secondary: None,
127 },
128 &config(),
129 );
130
131 assert_eq!(run.adapter, Adapter::Cli);
132 assert!(matches!(run.input, RunInput::Cli { .. }));
133 assert_eq!(
134 run.comparison.classification,
135 Classification::SuspiciousDifference
136 );
137 }
138
139 #[test]
140 fn http_metadata_preserves_request_fields() {
141 let mut request_headers = BTreeMap::new();
142 request_headers.insert("x-request".to_string(), "abc".to_string());
143 let request_body = capture_body(b"{\"request\":true}", 1024);
144
145 let run = build_comparison_run(
146 RunMetadata {
147 id: Uuid::nil(),
148 timestamp: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
149 adapter: Adapter::Http,
150 input: RunInput::Http {
151 method: "POST".to_string(),
152 path: "/submit".to_string(),
153 query: Some("token=[redacted]".to_string()),
154 },
155 request_headers: request_headers.clone(),
156 request_body: request_body.clone(),
157 },
158 CapturedTargets {
159 primary: target(200, "{\"ok\":true}"),
160 candidate: target(200, "{\"ok\":true}"),
161 secondary: None,
162 },
163 &config(),
164 );
165
166 assert_eq!(run.request_headers, request_headers);
167 assert_eq!(run.request_body.sha256, request_body.sha256);
168 assert!(matches!(
169 run.input,
170 RunInput::Http {
171 ref method,
172 ref path,
173 ref query
174 } if method == "POST" && path == "/submit" && query.as_deref() == Some("token=[redacted]")
175 ));
176 assert_eq!(run.comparison.classification, Classification::Match);
177 }
178
179 #[test]
180 fn project_metadata_builds_project_run() {
181 let run = build_comparison_run(
182 metadata(
183 Adapter::Project,
184 RunInput::Project {
185 eval_id: Uuid::nil(),
186 project: "moonlight".to_string(),
187 check_id: "test".to_string(),
188 check_name: Some("cargo test".to_string()),
189 repo: "/repo".to_string(),
190 baseline_ref: "main".to_string(),
191 candidate_source: "patch".to_string(),
192 primary_command: "cargo test".to_string(),
193 candidate_command: "cargo test".to_string(),
194 secondary_command: None,
195 },
196 ),
197 CapturedTargets {
198 primary: target(0, "ok"),
199 candidate: target(0, "ok"),
200 secondary: None,
201 },
202 &config(),
203 );
204
205 assert_eq!(run.adapter, Adapter::Project);
206 assert!(matches!(run.input, RunInput::Project { .. }));
207 assert_eq!(run.comparison.classification, Classification::Match);
208 }
209
210 #[test]
211 fn secondary_reference_noise_filters_candidate_diff() {
212 let run = build_comparison_run(
213 metadata(
214 Adapter::Cli,
215 RunInput::Cli {
216 primary_command: "primary".to_string(),
217 candidate_command: "candidate".to_string(),
218 secondary_command: Some("secondary".to_string()),
219 },
220 ),
221 CapturedTargets {
222 primary: target(0, "stable"),
223 candidate: target(0, "noisy"),
224 secondary: Some(target(0, "noisy")),
225 },
226 &config(),
227 );
228
229 assert_eq!(
230 run.comparison.classification,
231 Classification::ReferenceNoise
232 );
233 assert!(run.comparison.noise_filtered_diffs.is_empty());
234 }
235
236 #[test]
237 fn target_errors_are_classified_as_target_error() {
238 let run = build_comparison_run(
239 metadata(
240 Adapter::Cli,
241 RunInput::Cli {
242 primary_command: "primary".to_string(),
243 candidate_command: "candidate".to_string(),
244 secondary_command: None,
245 },
246 ),
247 CapturedTargets {
248 primary: target(0, "ok"),
249 candidate: target_error("candidate failed"),
250 secondary: None,
251 },
252 &config(),
253 );
254
255 assert_eq!(run.comparison.classification, Classification::TargetError);
256 }
257
258 #[test]
259 fn compare_config_controls_diffing() {
260 let compare_config = CompareConfig::new_with_patterns(
261 &["$.ignored".to_string()],
262 &[],
263 &["$.secret".to_string()],
264 &[],
265 &[],
266 false,
267 );
268 let run = build_comparison_run(
269 metadata(
270 Adapter::Cli,
271 RunInput::Cli {
272 primary_command: "primary".to_string(),
273 candidate_command: "candidate".to_string(),
274 secondary_command: None,
275 },
276 ),
277 CapturedTargets {
278 primary: target(0, r#"{"ignored":1,"secret":"a","kept":1}"#),
279 candidate: target(0, r#"{"ignored":2,"secret":"b","kept":2}"#),
280 secondary: None,
281 },
282 &compare_config,
283 );
284
285 let paths = run
286 .comparison
287 .raw_candidate_diffs
288 .iter()
289 .map(|diff| (diff.kind.clone(), diff.path.as_str()))
290 .collect::<Vec<_>>();
291 assert!(paths.contains(&(DiffKind::Body, "$.secret")));
292 assert!(paths.contains(&(DiffKind::Body, "$.kept")));
293 assert!(!paths.iter().any(|(_, path)| *path == "$.ignored"));
294 }
295}