1#![deny(clippy::all)]
4#![warn(clippy::pedantic)]
5
6use parlov_core::{OracleClass, OracleResult, OracleVerdict, ProbeSet, ResponseSummary, ResponseSurface};
7
8use crate::{Analyzer, SampleDecision};
9
10use super::classifier::classify;
11use super::diff::diff_headers;
12
13pub struct ExistenceAnalyzer;
19
20impl Analyzer for ExistenceAnalyzer {
21 fn evaluate(&self, data: &ProbeSet) -> SampleDecision {
22 let b0 = data.baseline[0].status;
23 let p0 = data.probe[0].status;
24
25 if b0 == p0 {
26 return SampleDecision::Complete(annotate(classify(b0, p0), &data.baseline[0], &data.probe[0]));
27 }
28
29 let samples = data.baseline.len();
30 if samples < 3 {
31 return SampleDecision::NeedMore;
32 }
33
34 let stable = is_consistent(&data.baseline) && is_consistent(&data.probe);
35 if stable {
36 SampleDecision::Complete(annotate(classify(b0, p0), &data.baseline[0], &data.probe[0]))
37 } else {
38 SampleDecision::Complete(unstable_result(&data.baseline, &data.probe))
39 }
40 }
41
42 fn oracle_class(&self) -> OracleClass {
43 OracleClass::Existence
44 }
45}
46
47fn is_consistent(surfaces: &[ResponseSurface]) -> bool {
49 surfaces.iter().all(|s| s.status == surfaces[0].status)
50}
51
52fn unstable_result(baseline: &[ResponseSurface], probe: &[ResponseSurface]) -> OracleResult {
53 let baseline_stable = is_consistent(baseline);
54 let probe_stable = is_consistent(probe);
55
56 let which = match (baseline_stable, probe_stable) {
57 (false, false) => "baseline and probe sides",
58 (false, true) => "baseline side",
59 (true, false) => "probe side",
60 (true, true) => unreachable!("unstable_result called when both sides are stable"),
61 };
62
63 let result = OracleResult {
64 class: OracleClass::Existence,
65 verdict: OracleVerdict::NotPresent,
66 severity: None,
67 evidence: vec![format!("status codes were inconsistent across samples on {which}")],
68 label: None,
69 leaks: None,
70 rfc_basis: None,
71 baseline_summary: None,
72 probe_summary: None,
73 header_diffs: vec![],
74 };
75 annotate(result, &baseline[0], &probe[0])
76}
77
78fn annotate(
83 mut result: OracleResult,
84 baseline: &ResponseSurface,
85 probe: &ResponseSurface,
86) -> OracleResult {
87 result.baseline_summary = Some(ResponseSummary { status: baseline.status.as_u16() });
88 result.probe_summary = Some(ResponseSummary { status: probe.status.as_u16() });
89 result.header_diffs = diff_headers(&baseline.headers, &probe.headers);
90 result
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96 use bytes::Bytes;
97 use http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
98 use parlov_core::{OracleVerdict, ProbeSet, ResponseSurface, ResponseSummary, Severity};
99
100 fn surface(status: u16) -> ResponseSurface {
101 ResponseSurface {
102 status: StatusCode::from_u16(status).expect("valid status code"),
103 headers: HeaderMap::new(),
104 body: Bytes::new(),
105 timing_ns: 0,
106 }
107 }
108
109 fn surface_with_header(status: u16, name: &str, value: &str) -> ResponseSurface {
110 let mut headers = HeaderMap::new();
111 let header_name = HeaderName::from_bytes(name.as_bytes()).expect("valid header name");
112 let header_value = HeaderValue::from_str(value).expect("valid header value");
113 headers.insert(header_name, header_value);
114 ResponseSurface {
115 status: StatusCode::from_u16(status).expect("valid status code"),
116 headers,
117 body: Bytes::new(),
118 timing_ns: 0,
119 }
120 }
121
122 fn probe_set_1(baseline_status: u16, probe_status: u16) -> ProbeSet {
123 ProbeSet {
124 baseline: vec![surface(baseline_status)],
125 probe: vec![surface(probe_status)],
126 }
127 }
128
129 fn probe_set_n(baseline_statuses: &[u16], probe_statuses: &[u16]) -> ProbeSet {
130 ProbeSet {
131 baseline: baseline_statuses.iter().map(|&s| surface(s)).collect(),
132 probe: probe_statuses.iter().map(|&s| surface(s)).collect(),
133 }
134 }
135
136 #[test]
139 fn evaluate_1_sample_diff_returns_need_more() {
140 let ps = probe_set_1(403, 404);
141 assert!(matches!(ExistenceAnalyzer.evaluate(&ps), SampleDecision::NeedMore));
142 }
143
144 #[test]
145 fn evaluate_2_samples_diff_returns_need_more() {
146 let ps = probe_set_n(&[403, 403], &[404, 404]);
147 assert!(matches!(ExistenceAnalyzer.evaluate(&ps), SampleDecision::NeedMore));
148 }
149
150 #[test]
151 fn evaluate_3_samples_stable_diff_confirmed() {
152 let ps = probe_set_n(&[403, 403, 403], &[404, 404, 404]);
153 let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
154 panic!("expected Complete");
155 };
156 assert_eq!(result.verdict, OracleVerdict::Confirmed);
157 assert_eq!(result.severity, Some(Severity::High));
158 }
159
160 #[test]
161 fn evaluate_3_samples_unstable_probe_not_present() {
162 let ps = probe_set_n(&[403, 403, 403], &[404, 200, 404]);
164 let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
165 panic!("expected Complete");
166 };
167 assert_eq!(result.verdict, OracleVerdict::NotPresent);
168 assert_eq!(result.severity, None);
169 }
170
171 #[test]
172 fn evaluate_3_samples_unstable_baseline_not_present() {
173 let ps = probe_set_n(&[403, 200, 403], &[404, 404, 404]);
175 let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
176 panic!("expected Complete");
177 };
178 assert_eq!(result.verdict, OracleVerdict::NotPresent);
179 assert_eq!(result.severity, None);
180 }
181
182 #[test]
185 fn evaluate_complete_not_present_on_same_status() {
186 let ps = probe_set_1(404, 404);
188 let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
189 panic!("expected Complete");
190 };
191 assert_eq!(result.verdict, OracleVerdict::NotPresent);
192 assert_eq!(result.severity, None);
193 }
194
195 #[test]
196 fn evaluate_complete_confirmed_high_on_403_vs_404() {
197 let ps = probe_set_n(&[403, 403, 403], &[404, 404, 404]);
198 let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
199 panic!("expected Complete");
200 };
201 assert_eq!(result.verdict, OracleVerdict::Confirmed);
202 assert_eq!(result.severity, Some(Severity::High));
203 }
204
205 #[test]
206 fn analyze_provided_method_delegates_to_evaluate() {
207 let ps = probe_set_n(&[200, 200, 200], &[404, 404, 404]);
209 let result = ExistenceAnalyzer.analyze(&ps);
210 assert_eq!(result.verdict, OracleVerdict::Confirmed);
211 assert_eq!(result.severity, Some(Severity::High));
212 }
213
214 #[test]
216 fn need_more_variant_is_constructible() {
217 let _decision: SampleDecision = SampleDecision::NeedMore;
218 }
219
220 #[test]
221 fn evaluate_populates_baseline_and_probe_summary() {
222 let ps = ProbeSet {
223 baseline: vec![surface(403), surface(403), surface(403)],
224 probe: vec![surface(404), surface(404), surface(404)],
225 };
226 let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
227 panic!("expected Complete");
228 };
229 assert_eq!(result.baseline_summary, Some(ResponseSummary { status: 403 }));
230 assert_eq!(result.probe_summary, Some(ResponseSummary { status: 404 }));
231 }
232
233 #[test]
234 fn evaluate_populates_header_diffs_when_headers_differ() {
235 let ps = ProbeSet {
236 baseline: vec![
237 surface_with_header(403, "x-error-code", "FORBIDDEN"),
238 surface_with_header(403, "x-error-code", "FORBIDDEN"),
239 surface_with_header(403, "x-error-code", "FORBIDDEN"),
240 ],
241 probe: vec![
242 surface_with_header(404, "x-error-code", "NOT_FOUND"),
243 surface_with_header(404, "x-error-code", "NOT_FOUND"),
244 surface_with_header(404, "x-error-code", "NOT_FOUND"),
245 ],
246 };
247 let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
248 panic!("expected Complete");
249 };
250 assert_eq!(result.header_diffs.len(), 1);
251 assert_eq!(result.header_diffs[0].name, "x-error-code");
252 assert_eq!(result.header_diffs[0].baseline, Some("FORBIDDEN".to_string()));
253 assert_eq!(result.header_diffs[0].probe, Some("NOT_FOUND".to_string()));
254 }
255
256 #[test]
257 fn evaluate_empty_header_diffs_when_headers_identical() {
258 let ps = ProbeSet {
259 baseline: vec![
260 surface_with_header(403, "x-request-id", "abc"),
261 surface_with_header(403, "x-request-id", "abc"),
262 surface_with_header(403, "x-request-id", "abc"),
263 ],
264 probe: vec![
265 surface_with_header(404, "x-request-id", "abc"),
266 surface_with_header(404, "x-request-id", "abc"),
267 surface_with_header(404, "x-request-id", "abc"),
268 ],
269 };
270 let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
271 panic!("expected Complete");
272 };
273 assert!(result.header_diffs.is_empty());
274 }
275
276 #[test]
277 fn evaluate_header_absent_on_probe_side() {
278 let ps = ProbeSet {
279 baseline: vec![
280 surface_with_header(403, "x-resource-id", "123"),
281 surface_with_header(403, "x-resource-id", "123"),
282 surface_with_header(403, "x-resource-id", "123"),
283 ],
284 probe: vec![surface(404), surface(404), surface(404)],
285 };
286 let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
287 panic!("expected Complete");
288 };
289 let diff = result
290 .header_diffs
291 .iter()
292 .find(|d| d.name == "x-resource-id")
293 .expect("expected x-resource-id diff");
294 assert_eq!(diff.baseline, Some("123".to_string()));
295 assert_eq!(diff.probe, None);
296 }
297
298 #[test]
299 fn unstable_result_populates_summaries() {
300 let ps = ProbeSet {
302 baseline: vec![surface(403), surface(403), surface(403)],
303 probe: vec![surface(404), surface(200), surface(404)],
304 };
305 let SampleDecision::Complete(result) = ExistenceAnalyzer.evaluate(&ps) else {
306 panic!("expected Complete");
307 };
308 assert_eq!(result.verdict, OracleVerdict::NotPresent);
309 assert_eq!(result.baseline_summary, Some(ResponseSummary { status: 403 }));
310 assert_eq!(result.probe_summary, Some(ResponseSummary { status: 404 }));
311 }
312}