Skip to main content

moonlight_core/
run.rs

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/// Metadata supplied by an adapter when handing captured targets to core.
11#[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/// Captured target results for the required and optional comparison roles.
22#[derive(Debug, Clone)]
23pub struct CapturedTargets {
24    pub primary: CapturedTarget,
25    pub candidate: CapturedTarget,
26    pub secondary: Option<CapturedTarget>,
27}
28
29/// Build a persisted comparison run from adapter metadata and captured targets.
30///
31/// Adapters own target invocation and capture. Core owns the transition from
32/// those captures into the stable `ComparisonRun` shape and its classification.
33pub 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}