1use crate::client::Client;
4use crate::error::Result;
5use crate::internal::{apply_pagination, first_non_empty, push_opt};
6use crate::pagination::{FetchFn, Page, PageStream};
7use crate::Record;
8use bon::Builder;
9use std::collections::BTreeMap;
10use std::sync::Arc;
11
12#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
18#[non_exhaustive]
19pub struct ListContractsOptions {
20 #[builder(into)]
23 pub page: Option<u32>,
24 #[builder(into)]
26 pub limit: Option<u32>,
27 #[builder(into)]
30 pub cursor: Option<String>,
31 #[builder(into)]
34 pub shape: Option<String>,
35 #[builder(default)]
37 pub flat: bool,
38 #[builder(default)]
40 pub flat_lists: bool,
41
42 #[builder(into)]
45 pub award_date: Option<String>,
46 #[builder(into)]
48 pub award_date_gte: Option<String>,
49 #[builder(into)]
51 pub award_date_lte: Option<String>,
52 #[builder(into)]
54 pub award_type: Option<String>,
55
56 #[builder(into)]
58 pub fiscal_year: Option<String>,
59 #[builder(into)]
61 pub fiscal_year_gte: Option<String>,
62 #[builder(into)]
64 pub fiscal_year_lte: Option<String>,
65
66 #[builder(into)]
68 pub obligated_gte: Option<String>,
69 #[builder(into)]
71 pub obligated_lte: Option<String>,
72
73 #[builder(into)]
75 pub pop_start_date_gte: Option<String>,
76 #[builder(into)]
78 pub pop_start_date_lte: Option<String>,
79 #[builder(into)]
81 pub pop_end_date_gte: Option<String>,
82 #[builder(into)]
84 pub pop_end_date_lte: Option<String>,
85 #[builder(into)]
87 pub expiring_gte: Option<String>,
88 #[builder(into)]
90 pub expiring_lte: Option<String>,
91
92 #[builder(into)]
94 pub awarding_agency: Option<String>,
95 #[builder(into)]
97 pub funding_agency: Option<String>,
98 #[builder(into)]
100 pub piid: Option<String>,
101 #[builder(into)]
103 pub solicitation_identifier: Option<String>,
104 #[builder(into)]
106 pub naics: Option<String>,
107 #[builder(into)]
109 pub psc: Option<String>,
110 #[builder(into)]
112 pub recipient: Option<String>,
113 #[builder(into)]
115 pub uei: Option<String>,
116 #[builder(into)]
118 pub set_aside: Option<String>,
119
120 #[builder(into)]
123 pub naics_code: Option<String>,
124 #[builder(into)]
126 pub psc_code: Option<String>,
127 #[builder(into)]
129 pub recipient_name: Option<String>,
130 #[builder(into)]
132 pub recipient_uei: Option<String>,
133 #[builder(into)]
135 pub set_aside_type: Option<String>,
136
137 #[builder(into)]
139 pub search: Option<String>,
140 #[builder(into)]
142 pub keyword: Option<String>,
143 #[builder(into)]
145 pub ordering: Option<String>,
146 #[builder(into)]
148 pub sort: Option<String>,
149 #[builder(into)]
151 pub order: Option<String>,
152
153 #[builder(default)]
155 pub extra: BTreeMap<String, String>,
156}
157
158impl ListContractsOptions {
159 fn to_query(&self) -> Vec<(String, String)> {
160 let mut q = Vec::new();
161 apply_pagination(
162 &mut q,
163 self.page,
164 self.limit,
165 self.cursor.as_deref(),
166 self.shape.as_deref(),
167 self.flat,
168 self.flat_lists,
169 );
170
171 push_opt(&mut q, "award_date", self.award_date.as_deref());
172 push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
173 push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
174 push_opt(&mut q, "award_type", self.award_type.as_deref());
175 push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
176 push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
177 push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
178 push_opt(&mut q, "obligated_gte", self.obligated_gte.as_deref());
179 push_opt(&mut q, "obligated_lte", self.obligated_lte.as_deref());
180 push_opt(
181 &mut q,
182 "pop_start_date_gte",
183 self.pop_start_date_gte.as_deref(),
184 );
185 push_opt(
186 &mut q,
187 "pop_start_date_lte",
188 self.pop_start_date_lte.as_deref(),
189 );
190 push_opt(&mut q, "pop_end_date_gte", self.pop_end_date_gte.as_deref());
191 push_opt(&mut q, "pop_end_date_lte", self.pop_end_date_lte.as_deref());
192 push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
193 push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
194 push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
195 push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
196 push_opt(&mut q, "piid", self.piid.as_deref());
197 push_opt(
198 &mut q,
199 "solicitation_identifier",
200 self.solicitation_identifier.as_deref(),
201 );
202
203 push_opt(
205 &mut q,
206 "naics",
207 first_non_empty(&[self.naics_code.as_deref(), self.naics.as_deref()]),
208 );
209 push_opt(
210 &mut q,
211 "psc",
212 first_non_empty(&[self.psc_code.as_deref(), self.psc.as_deref()]),
213 );
214 push_opt(
215 &mut q,
216 "recipient",
217 first_non_empty(&[self.recipient_name.as_deref(), self.recipient.as_deref()]),
218 );
219 push_opt(
220 &mut q,
221 "uei",
222 first_non_empty(&[self.recipient_uei.as_deref(), self.uei.as_deref()]),
223 );
224 push_opt(
225 &mut q,
226 "set_aside",
227 first_non_empty(&[self.set_aside_type.as_deref(), self.set_aside.as_deref()]),
228 );
229 push_opt(
230 &mut q,
231 "search",
232 first_non_empty(&[self.keyword.as_deref(), self.search.as_deref()]),
233 );
234
235 if let Some(sort) = self.sort.as_deref().filter(|s| !s.is_empty()) {
236 let order = self.order.as_deref().unwrap_or("");
237 let prefix = if order == "desc" { "-" } else { "" };
238 q.push(("ordering".into(), format!("{prefix}{sort}")));
239 } else {
240 push_opt(&mut q, "ordering", self.ordering.as_deref());
241 }
242
243 for (k, v) in &self.extra {
244 if !v.is_empty() {
245 q.push((k.clone(), v.clone()));
246 }
247 }
248 q
249 }
250}
251
252impl Client {
253 pub async fn list_contracts(&self, opts: ListContractsOptions) -> Result<Page<Record>> {
255 let q = opts.to_query();
256 let bytes = self.get_bytes("/api/contracts/", &q).await?;
257 Page::decode(&bytes)
258 }
259
260 pub fn iterate_contracts(&self, opts: ListContractsOptions) -> PageStream<Record> {
276 let opts = Arc::new(opts);
277 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
278 let mut next = (*opts).clone();
279 next.page = page;
280 next.cursor = cursor;
281 Box::pin(async move { client.list_contracts(next).await })
282 });
283 PageStream::new(self.clone(), fetch)
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
292 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
293 }
294
295 #[test]
296 fn alias_prefers_sdk_name_over_canonical() {
297 let opts = ListContractsOptions::builder()
298 .naics_code("541512")
299 .naics("999999")
300 .build();
301 let q = opts.to_query();
302 assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
303 }
304
305 #[test]
306 fn alias_falls_through_to_canonical() {
307 let opts = ListContractsOptions::builder().naics("541512").build();
308 let q = opts.to_query();
309 assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
310 }
311
312 #[test]
313 fn sort_plus_order_builds_ordering() {
314 let opts = ListContractsOptions::builder()
315 .sort("obligated")
316 .order("desc")
317 .build();
318 let q = opts.to_query();
319 assert_eq!(get_q(&q, "ordering").as_deref(), Some("-obligated"));
320 }
321
322 #[test]
323 fn sort_without_order_defaults_ascending() {
324 let opts = ListContractsOptions::builder().sort("award_date").build();
325 let q = opts.to_query();
326 assert_eq!(get_q(&q, "ordering").as_deref(), Some("award_date"));
327 }
328
329 #[test]
330 fn cursor_wins_over_page() {
331 let opts = ListContractsOptions::builder()
332 .page(3u32)
333 .cursor("abc".to_string())
334 .build();
335 let q = opts.to_query();
336 assert_eq!(get_q(&q, "cursor").as_deref(), Some("abc"));
337 assert_eq!(get_q(&q, "page"), None);
338 }
339
340 #[test]
341 fn shape_emits() {
342 let opts = ListContractsOptions::builder()
343 .shape(crate::SHAPE_CONTRACTS_MINIMAL)
344 .build();
345 let q = opts.to_query();
346 assert_eq!(
347 get_q(&q, "shape").as_deref(),
348 Some(crate::SHAPE_CONTRACTS_MINIMAL)
349 );
350 }
351}