1use bytes::Bytes;
2use http::{HeaderMap, StatusCode};
3use serde::{Deserialize, Serialize};
4
5use crate::{ProbeDefinition, ResponseClass, ResponseSurface, Technique};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ProbeExchange {
10 pub request: ProbeDefinition,
12 pub response: ResponseSurface,
14}
15
16#[derive(Debug, Clone)]
18pub struct DifferentialSet {
19 pub baseline: Vec<ProbeExchange>,
21 pub probe: Vec<ProbeExchange>,
23 pub canonical: Option<ProbeExchange>,
30 pub technique: Technique,
32}
33
34impl DifferentialSet {
35 #[must_use]
39 pub fn new(
40 baseline: Vec<ProbeExchange>,
41 probe: Vec<ProbeExchange>,
42 technique: Technique,
43 ) -> Self {
44 Self {
45 baseline,
46 probe,
47 canonical: None,
48 technique,
49 }
50 }
51
52 #[must_use]
57 pub fn first_harvest_exchange(&self) -> Option<(StatusCode, HeaderMap)> {
58 self.first_harvest_exchange_with_body()
59 .map(|(s, h, _)| (s, h))
60 }
61
62 #[must_use]
70 pub fn first_harvest_exchange_with_body(&self) -> Option<(StatusCode, HeaderMap, Bytes)> {
71 if let Some(ex) = self
72 .baseline
73 .iter()
74 .find(|ex| ex.response.status.is_success())
75 {
76 return Some((
77 ex.response.status,
78 ex.response.headers.clone(),
79 ex.response.body.clone(),
80 ));
81 }
82 if let Some(ex) = self.baseline.iter().find(|ex| {
83 ex.response.status.is_redirection()
84 && ex.response.headers.contains_key(http::header::LOCATION)
85 }) {
86 return Some((
87 ex.response.status,
88 ex.response.headers.clone(),
89 ex.response.body.clone(),
90 ));
91 }
92 self.baseline
93 .iter()
94 .find(|ex| {
95 matches!(
96 ResponseClass::classify(ex.response.status, &ex.response.headers),
97 ResponseClass::AuthChallenge
98 | ResponseClass::RateLimited
99 | ResponseClass::StructuredError
100 )
101 })
102 .map(|ex| {
103 (
104 ex.response.status,
105 ex.response.headers.clone(),
106 ex.response.body.clone(),
107 )
108 })
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use bytes::Bytes;
115 use http::{header, HeaderMap, HeaderValue, StatusCode};
116
117 use super::*;
118 use crate::{
119 always_applicable, NormativeStrength, OracleClass, ProbeDefinition, ResponseSurface,
120 SignalSurface, Vector,
121 };
122
123 fn technique() -> Technique {
124 Technique {
125 id: "test",
126 name: "Test",
127 oracle_class: OracleClass::Existence,
128 vector: Vector::StatusCodeDiff,
129 strength: NormativeStrength::Must,
130 normalization_weight: None,
131 inverted_signal_weight: None,
132 method_relevant: false,
133 parser_relevant: false,
134 applicability: always_applicable,
135 contradiction_surface: SignalSurface::Status,
136 }
137 }
138
139 fn make_exchange(status: StatusCode, headers: HeaderMap) -> ProbeExchange {
140 ProbeExchange {
141 request: ProbeDefinition {
142 url: "https://example.com/r/1".into(),
143 method: http::Method::GET,
144 headers: HeaderMap::new(),
145 body: None,
146 },
147 response: ResponseSurface {
148 status,
149 headers,
150 body: Bytes::new(),
151 timing_ns: 0,
152 },
153 }
154 }
155
156 fn location_headers() -> HeaderMap {
157 let mut h = HeaderMap::new();
158 h.insert(
159 header::LOCATION,
160 HeaderValue::from_static("https://example.com/r/2"),
161 );
162 h
163 }
164
165 #[test]
166 fn empty_baseline_returns_none() {
167 let ds = DifferentialSet {
168 baseline: vec![],
169 probe: vec![],
170 canonical: None,
171 technique: technique(),
172 };
173 assert!(ds.first_harvest_exchange().is_none());
174 }
175
176 #[test]
177 fn single_200_returns_its_status_and_headers() {
178 let mut headers = HeaderMap::new();
179 headers.insert(
180 header::CONTENT_TYPE,
181 HeaderValue::from_static("application/json"),
182 );
183 let ds = DifferentialSet {
184 baseline: vec![make_exchange(StatusCode::OK, headers.clone())],
185 probe: vec![],
186 canonical: None,
187 technique: technique(),
188 };
189 let (status, h) = ds.first_harvest_exchange().expect("should return Some");
190 assert_eq!(status, StatusCode::OK);
191 assert_eq!(
192 h.get(header::CONTENT_TYPE),
193 headers.get(header::CONTENT_TYPE)
194 );
195 }
196
197 #[test]
198 fn single_301_with_location_returns_it_when_no_2xx() {
199 let ds = DifferentialSet {
200 baseline: vec![make_exchange(
201 StatusCode::MOVED_PERMANENTLY,
202 location_headers(),
203 )],
204 probe: vec![],
205 canonical: None,
206 technique: technique(),
207 };
208 let (status, h) = ds.first_harvest_exchange().expect("should return Some");
209 assert_eq!(status, StatusCode::MOVED_PERMANENTLY);
210 assert!(h.contains_key(header::LOCATION));
211 }
212
213 #[test]
214 fn single_301_without_location_returns_none() {
215 let ds = DifferentialSet {
216 baseline: vec![make_exchange(
217 StatusCode::MOVED_PERMANENTLY,
218 HeaderMap::new(),
219 )],
220 probe: vec![],
221 canonical: None,
222 technique: technique(),
223 };
224 assert!(ds.first_harvest_exchange().is_none());
225 }
226
227 #[test]
228 fn mixed_301_with_location_then_200_returns_200() {
229 let ds = DifferentialSet {
230 baseline: vec![
231 make_exchange(StatusCode::MOVED_PERMANENTLY, location_headers()),
232 make_exchange(StatusCode::OK, HeaderMap::new()),
233 ],
234 probe: vec![],
235 canonical: None,
236 technique: technique(),
237 };
238 let (status, _) = ds.first_harvest_exchange().expect("should return Some");
239 assert_eq!(status, StatusCode::OK);
240 }
241
242 #[test]
243 fn mixed_200_then_301_with_location_returns_200() {
244 let ds = DifferentialSet {
245 baseline: vec![
246 make_exchange(StatusCode::OK, HeaderMap::new()),
247 make_exchange(StatusCode::MOVED_PERMANENTLY, location_headers()),
248 ],
249 probe: vec![],
250 canonical: None,
251 technique: technique(),
252 };
253 let (status, _) = ds.first_harvest_exchange().expect("should return Some");
254 assert_eq!(status, StatusCode::OK);
255 }
256
257 #[test]
258 fn multiple_2xx_returns_first() {
259 let mut h1 = HeaderMap::new();
260 h1.insert(header::ETAG, HeaderValue::from_static("\"abc\""));
261 let ds = DifferentialSet {
262 baseline: vec![
263 make_exchange(StatusCode::OK, h1.clone()),
264 make_exchange(StatusCode::CREATED, HeaderMap::new()),
265 ],
266 probe: vec![],
267 canonical: None,
268 technique: technique(),
269 };
270 let (status, h) = ds.first_harvest_exchange().expect("should return Some");
271 assert_eq!(status, StatusCode::OK);
272 assert_eq!(h.get(header::ETAG), h1.get(header::ETAG));
273 }
274}