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