1use crate::client::Client;
4use crate::error::{Error, Result};
5use crate::internal::{apply_pagination, push_opt};
6use crate::models::ProtestRecord;
7use crate::pagination::{FetchFn, Page, PageStream};
8use crate::resources::agencies::urlencoding;
9use crate::Record;
10use bon::Builder;
11use std::collections::BTreeMap;
12use std::sync::Arc;
13
14#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct ListProtestsOptions {
23 #[builder(into)]
25 pub page: Option<u32>,
26 #[builder(into)]
28 pub limit: Option<u32>,
29 #[builder(into)]
31 pub cursor: Option<String>,
32 #[builder(into)]
35 pub shape: Option<String>,
36 #[builder(default)]
38 pub flat: bool,
39 #[builder(default)]
41 pub flat_lists: bool,
42
43 #[builder(into)]
47 pub source_system: Option<String>,
48 #[builder(into)]
52 pub outcome: Option<String>,
53 #[builder(into)]
55 pub case_type: Option<String>,
56 #[builder(into)]
58 pub agency: Option<String>,
59 #[builder(into)]
61 pub case_number: Option<String>,
62 #[builder(into)]
64 pub solicitation_number: Option<String>,
65 #[builder(into)]
67 pub naics_code: Option<String>,
68 #[builder(into)]
70 pub protester: Option<String>,
71 #[builder(into)]
73 pub search: Option<String>,
74
75 #[builder(into)]
77 pub filed_date_after: Option<String>,
78 #[builder(into)]
80 pub filed_date_before: Option<String>,
81 #[builder(into)]
83 pub decision_date_after: Option<String>,
84 #[builder(into)]
86 pub decision_date_before: Option<String>,
87
88 #[builder(default)]
90 pub extra: BTreeMap<String, String>,
91}
92
93impl ListProtestsOptions {
94 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
95 let mut q = Vec::new();
96 apply_pagination(
97 &mut q,
98 self.page,
99 self.limit,
100 self.cursor.as_deref(),
101 self.shape.as_deref(),
102 self.flat,
103 self.flat_lists,
104 );
105 push_opt(&mut q, "source_system", self.source_system.as_deref());
106 push_opt(&mut q, "outcome", self.outcome.as_deref());
107 push_opt(&mut q, "case_type", self.case_type.as_deref());
108 push_opt(&mut q, "agency", self.agency.as_deref());
109 push_opt(&mut q, "case_number", self.case_number.as_deref());
110 push_opt(
111 &mut q,
112 "solicitation_number",
113 self.solicitation_number.as_deref(),
114 );
115 push_opt(&mut q, "naics_code", self.naics_code.as_deref());
116 push_opt(&mut q, "protester", self.protester.as_deref());
117 push_opt(&mut q, "search", self.search.as_deref());
118 push_opt(&mut q, "filed_date_after", self.filed_date_after.as_deref());
119 push_opt(
120 &mut q,
121 "filed_date_before",
122 self.filed_date_before.as_deref(),
123 );
124 push_opt(
125 &mut q,
126 "decision_date_after",
127 self.decision_date_after.as_deref(),
128 );
129 push_opt(
130 &mut q,
131 "decision_date_before",
132 self.decision_date_before.as_deref(),
133 );
134 for (k, v) in &self.extra {
135 if !v.is_empty() {
136 q.push((k.clone(), v.clone()));
137 }
138 }
139 q
140 }
141}
142
143#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
146#[non_exhaustive]
147pub struct GetProtestOptions {
148 #[builder(into)]
150 pub shape: Option<String>,
151 #[builder(default)]
153 pub flat: bool,
154 #[builder(default)]
156 pub flat_lists: bool,
157}
158
159impl GetProtestOptions {
160 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
161 let mut q = Vec::new();
162 push_opt(&mut q, "shape", self.shape.as_deref());
163 if self.flat {
164 q.push(("flat".into(), "true".into()));
165 }
166 if self.flat_lists {
167 q.push(("flat_lists".into(), "true".into()));
168 }
169 q
170 }
171}
172
173impl Client {
174 pub async fn list_protests(&self, opts: ListProtestsOptions) -> Result<Page<Record>> {
176 let q = opts.to_query();
177 self.get_page("/api/protests/", &q).await
178 }
179
180 pub async fn get_protest(
185 &self,
186 case_id: &str,
187 opts: Option<GetProtestOptions>,
188 ) -> Result<ProtestRecord> {
189 if case_id.is_empty() {
190 return Err(Error::Validation {
191 message: "get_protest: case_id is required".into(),
192 response: None,
193 });
194 }
195 let q = opts.unwrap_or_default().to_query();
196 let path = format!("/api/protests/{}/", urlencoding(case_id));
197 self.get_json::<ProtestRecord>(&path, &q).await
198 }
199
200 pub fn iterate_protests(&self, opts: ListProtestsOptions) -> PageStream<Record> {
202 let opts = Arc::new(opts);
203 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
204 let mut next = (*opts).clone();
205 next.page = page;
206 next.cursor = cursor;
207 Box::pin(async move { client.list_protests(next).await })
208 });
209 PageStream::new(self.clone(), fetch)
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
218 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
219 }
220
221 #[test]
222 fn list_protests_all_filters_emit() {
223 let opts = ListProtestsOptions::builder()
224 .source_system("gao")
225 .outcome("Sustained")
226 .case_type("Bid Protest")
227 .agency("9700")
228 .case_number("B-12345.1")
229 .solicitation_number("SOL-001")
230 .protester("Acme Corp")
231 .search("infrastructure")
232 .filed_date_after("2024-01-01")
233 .filed_date_before("2024-12-31")
234 .decision_date_after("2024-02-01")
235 .decision_date_before("2024-12-31")
236 .build();
237 let q = opts.to_query();
238 assert_eq!(get_q(&q, "source_system").as_deref(), Some("gao"));
239 assert_eq!(get_q(&q, "outcome").as_deref(), Some("Sustained"));
240 assert_eq!(get_q(&q, "case_type").as_deref(), Some("Bid Protest"));
241 assert_eq!(get_q(&q, "agency").as_deref(), Some("9700"));
242 assert_eq!(get_q(&q, "case_number").as_deref(), Some("B-12345.1"));
243 assert_eq!(get_q(&q, "solicitation_number").as_deref(), Some("SOL-001"));
244 assert_eq!(get_q(&q, "protester").as_deref(), Some("Acme Corp"));
245 assert_eq!(get_q(&q, "search").as_deref(), Some("infrastructure"));
246 assert_eq!(get_q(&q, "filed_date_after").as_deref(), Some("2024-01-01"));
247 assert_eq!(
248 get_q(&q, "filed_date_before").as_deref(),
249 Some("2024-12-31")
250 );
251 assert_eq!(
252 get_q(&q, "decision_date_after").as_deref(),
253 Some("2024-02-01")
254 );
255 assert_eq!(
256 get_q(&q, "decision_date_before").as_deref(),
257 Some("2024-12-31")
258 );
259 }
260
261 #[test]
262 fn list_protests_naics_code_emits() {
263 let opts = ListProtestsOptions::builder().naics_code("541512").build();
264 let q = opts.to_query();
265 assert_eq!(get_q(&q, "naics_code").as_deref(), Some("541512"));
266 }
267
268 #[test]
269 fn list_protests_zero_value_omitted() {
270 let opts = ListProtestsOptions::builder().build();
271 let q = opts.to_query();
272 assert!(q.is_empty(), "expected empty query, got {q:?}");
273 }
274
275 #[test]
276 fn list_protests_cursor_wins_over_page() {
277 let opts = ListProtestsOptions::builder()
278 .page(2u32)
279 .cursor("xyz".to_string())
280 .build();
281 let q = opts.to_query();
282 assert_eq!(get_q(&q, "cursor").as_deref(), Some("xyz"));
283 assert_eq!(get_q(&q, "page"), None);
284 }
285
286 #[test]
287 fn list_protests_shape_emits() {
288 let opts = ListProtestsOptions::builder()
289 .shape(crate::SHAPE_PROTESTS_MINIMAL)
290 .flat(true)
291 .build();
292 let q = opts.to_query();
293 assert_eq!(
294 get_q(&q, "shape").as_deref(),
295 Some(crate::SHAPE_PROTESTS_MINIMAL)
296 );
297 assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
298 }
299
300 #[test]
301 fn list_protests_extra_emits() {
302 let mut extra = BTreeMap::new();
303 extra.insert("custom_x".to_string(), "xv".to_string());
304 let opts = ListProtestsOptions::builder().extra(extra).build();
305 let q = opts.to_query();
306 assert!(q.contains(&("custom_x".into(), "xv".into())));
307 }
308
309 #[test]
310 fn get_protest_options_emit() {
311 let opts = GetProtestOptions::builder()
312 .shape("docket(*)")
313 .flat(true)
314 .flat_lists(true)
315 .build();
316 let q = opts.to_query();
317 assert_eq!(get_q(&q, "shape").as_deref(), Some("docket(*)"));
318 assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
319 assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
320 }
321
322 #[test]
323 fn protest_record_decodes_from_sample_json() {
324 let value = serde_json::json!({
325 "case_id": "b-12345-1",
326 "case_number": "B-12345.1",
327 "title": "Acme Corp Protest",
328 "source_system": "gao",
329 "outcome": "Sustained",
330 "filed_date": "2024-01-15",
331 "decision_date": "2024-04-20",
332 "agency": {"code": "9700", "name": "DoD"},
333 "future_field": "still here"
334 });
335 let rec: ProtestRecord = serde_json::from_value(value).expect("decode");
336 assert_eq!(rec.case_id.as_deref(), Some("b-12345-1"));
337 assert_eq!(rec.case_number.as_deref(), Some("B-12345.1"));
338 assert_eq!(rec.title.as_deref(), Some("Acme Corp Protest"));
339 assert_eq!(rec.source_system.as_deref(), Some("gao"));
340 assert_eq!(rec.outcome.as_deref(), Some("Sustained"));
341 assert_eq!(rec.filed_date.as_deref(), Some("2024-01-15"));
342 assert_eq!(rec.decision_date.as_deref(), Some("2024-04-20"));
343 assert!(rec.extra.contains_key("agency"));
345 assert_eq!(
346 rec.extra.get("future_field").and_then(|v| v.as_str()),
347 Some("still here")
348 );
349 }
350
351 #[tokio::test]
352 async fn get_protest_validates_empty_case_id() {
353 let client = Client::builder().api_key("x").build().expect("client");
354 let err = client.get_protest("", None).await.unwrap_err();
355 match err {
356 Error::Validation { message, .. } => {
357 assert!(message.contains("case_id is required"));
358 }
359 other => panic!("expected Validation, got {other:?}"),
360 }
361 }
362}