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 let bytes = self.get_bytes("/api/protests/", &q).await?;
178 Page::decode(&bytes)
179 }
180
181 pub async fn get_protest(
186 &self,
187 case_id: &str,
188 opts: Option<GetProtestOptions>,
189 ) -> Result<ProtestRecord> {
190 if case_id.is_empty() {
191 return Err(Error::Validation {
192 message: "get_protest: case_id is required".into(),
193 response: None,
194 });
195 }
196 let q = opts.unwrap_or_default().to_query();
197 let path = format!("/api/protests/{}/", urlencoding(case_id));
198 self.get_json::<ProtestRecord>(&path, &q).await
199 }
200
201 pub fn iterate_protests(&self, opts: ListProtestsOptions) -> PageStream<Record> {
203 let opts = Arc::new(opts);
204 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
205 let mut next = (*opts).clone();
206 next.page = page;
207 next.cursor = cursor;
208 Box::pin(async move { client.list_protests(next).await })
209 });
210 PageStream::new(self.clone(), fetch)
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
219 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
220 }
221
222 #[test]
223 fn list_protests_all_filters_emit() {
224 let opts = ListProtestsOptions::builder()
225 .source_system("gao")
226 .outcome("Sustained")
227 .case_type("Bid Protest")
228 .agency("9700")
229 .case_number("B-12345.1")
230 .solicitation_number("SOL-001")
231 .protester("Acme Corp")
232 .search("infrastructure")
233 .filed_date_after("2024-01-01")
234 .filed_date_before("2024-12-31")
235 .decision_date_after("2024-02-01")
236 .decision_date_before("2024-12-31")
237 .build();
238 let q = opts.to_query();
239 assert_eq!(get_q(&q, "source_system").as_deref(), Some("gao"));
240 assert_eq!(get_q(&q, "outcome").as_deref(), Some("Sustained"));
241 assert_eq!(get_q(&q, "case_type").as_deref(), Some("Bid Protest"));
242 assert_eq!(get_q(&q, "agency").as_deref(), Some("9700"));
243 assert_eq!(get_q(&q, "case_number").as_deref(), Some("B-12345.1"));
244 assert_eq!(get_q(&q, "solicitation_number").as_deref(), Some("SOL-001"));
245 assert_eq!(get_q(&q, "protester").as_deref(), Some("Acme Corp"));
246 assert_eq!(get_q(&q, "search").as_deref(), Some("infrastructure"));
247 assert_eq!(get_q(&q, "filed_date_after").as_deref(), Some("2024-01-01"));
248 assert_eq!(
249 get_q(&q, "filed_date_before").as_deref(),
250 Some("2024-12-31")
251 );
252 assert_eq!(
253 get_q(&q, "decision_date_after").as_deref(),
254 Some("2024-02-01")
255 );
256 assert_eq!(
257 get_q(&q, "decision_date_before").as_deref(),
258 Some("2024-12-31")
259 );
260 }
261
262 #[test]
263 fn list_protests_naics_code_emits() {
264 let opts = ListProtestsOptions::builder().naics_code("541512").build();
265 let q = opts.to_query();
266 assert_eq!(get_q(&q, "naics_code").as_deref(), Some("541512"));
267 }
268
269 #[test]
270 fn list_protests_zero_value_omitted() {
271 let opts = ListProtestsOptions::builder().build();
272 let q = opts.to_query();
273 assert!(q.is_empty(), "expected empty query, got {q:?}");
274 }
275
276 #[test]
277 fn list_protests_cursor_wins_over_page() {
278 let opts = ListProtestsOptions::builder()
279 .page(2u32)
280 .cursor("xyz".to_string())
281 .build();
282 let q = opts.to_query();
283 assert_eq!(get_q(&q, "cursor").as_deref(), Some("xyz"));
284 assert_eq!(get_q(&q, "page"), None);
285 }
286
287 #[test]
288 fn list_protests_shape_emits() {
289 let opts = ListProtestsOptions::builder()
290 .shape(crate::SHAPE_PROTESTS_MINIMAL)
291 .flat(true)
292 .build();
293 let q = opts.to_query();
294 assert_eq!(
295 get_q(&q, "shape").as_deref(),
296 Some(crate::SHAPE_PROTESTS_MINIMAL)
297 );
298 assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
299 }
300
301 #[test]
302 fn list_protests_extra_emits() {
303 let mut extra = BTreeMap::new();
304 extra.insert("custom_x".to_string(), "xv".to_string());
305 let opts = ListProtestsOptions::builder().extra(extra).build();
306 let q = opts.to_query();
307 assert!(q.contains(&("custom_x".into(), "xv".into())));
308 }
309
310 #[test]
311 fn get_protest_options_emit() {
312 let opts = GetProtestOptions::builder()
313 .shape("docket(*)")
314 .flat(true)
315 .flat_lists(true)
316 .build();
317 let q = opts.to_query();
318 assert_eq!(get_q(&q, "shape").as_deref(), Some("docket(*)"));
319 assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
320 assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
321 }
322
323 #[test]
324 fn protest_record_decodes_from_sample_json() {
325 let value = serde_json::json!({
326 "case_id": "b-12345-1",
327 "case_number": "B-12345.1",
328 "title": "Acme Corp Protest",
329 "source_system": "gao",
330 "outcome": "Sustained",
331 "filed_date": "2024-01-15",
332 "decision_date": "2024-04-20",
333 "agency": {"code": "9700", "name": "DoD"},
334 "future_field": "still here"
335 });
336 let rec: ProtestRecord = serde_json::from_value(value).expect("decode");
337 assert_eq!(rec.case_id.as_deref(), Some("b-12345-1"));
338 assert_eq!(rec.case_number.as_deref(), Some("B-12345.1"));
339 assert_eq!(rec.title.as_deref(), Some("Acme Corp Protest"));
340 assert_eq!(rec.source_system.as_deref(), Some("gao"));
341 assert_eq!(rec.outcome.as_deref(), Some("Sustained"));
342 assert_eq!(rec.filed_date.as_deref(), Some("2024-01-15"));
343 assert_eq!(rec.decision_date.as_deref(), Some("2024-04-20"));
344 assert!(rec.extra.contains_key("agency"));
346 assert_eq!(
347 rec.extra.get("future_field").and_then(|v| v.as_str()),
348 Some("still here")
349 );
350 }
351
352 #[tokio::test]
353 async fn get_protest_validates_empty_case_id() {
354 let client = Client::builder().api_key("x").build().expect("client");
355 let err = client.get_protest("", None).await.unwrap_err();
356 match err {
357 Error::Validation { message, .. } => {
358 assert!(message.contains("case_id is required"));
359 }
360 other => panic!("expected Validation, got {other:?}"),
361 }
362 }
363}