1use crate::client::Client;
4use crate::error::{Error, Result};
5use crate::internal::{apply_pagination, push_opt};
6use crate::pagination::{FetchFn, Page, PageStream};
7use crate::resources::agencies::urlencoding;
8use crate::Record;
9use bon::Builder;
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13#[allow(clippy::upper_case_acronyms)]
18#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
19#[non_exhaustive]
20pub struct ListIDVsOptions {
21 #[builder(into)]
24 pub page: Option<u32>,
25 #[builder(into)]
27 pub limit: Option<u32>,
28 #[builder(into)]
31 pub cursor: Option<String>,
32 #[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 awarding_agency: Option<String>,
55 #[builder(into)]
57 pub funding_agency: Option<String>,
58 #[builder(into)]
60 pub expiring_gte: Option<String>,
61 #[builder(into)]
63 pub expiring_lte: Option<String>,
64 #[builder(into)]
66 pub fiscal_year: Option<String>,
67 #[builder(into)]
69 pub fiscal_year_gte: Option<String>,
70 #[builder(into)]
72 pub fiscal_year_lte: Option<String>,
73 #[builder(into)]
75 pub idv_type: Option<String>,
76 #[builder(into)]
78 pub last_date_to_order_gte: Option<String>,
79 #[builder(into)]
81 pub last_date_to_order_lte: Option<String>,
82 #[builder(into)]
84 pub naics: Option<String>,
85 #[builder(into)]
87 pub ordering: Option<String>,
88 #[builder(into)]
90 pub piid: Option<String>,
91 #[builder(into)]
93 pub pop_start_date_gte: Option<String>,
94 #[builder(into)]
96 pub pop_start_date_lte: Option<String>,
97 #[builder(into)]
99 pub psc: Option<String>,
100 #[builder(into)]
102 pub recipient: Option<String>,
103 #[builder(into)]
105 pub search: Option<String>,
106 #[builder(into)]
108 pub set_aside: Option<String>,
109 #[builder(into)]
111 pub solicitation_identifier: Option<String>,
112 #[builder(into)]
114 pub uei: Option<String>,
115 #[builder(into)]
117 pub commercial_item_acquisition_procedures: Option<String>,
118
119 #[builder(into)]
121 pub key: Option<String>,
122
123 #[builder(default)]
125 pub extra: BTreeMap<String, String>,
126}
127
128impl ListIDVsOptions {
129 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
130 let mut q = Vec::new();
131 apply_pagination(
132 &mut q,
133 self.page,
134 self.limit,
135 self.cursor.as_deref(),
136 self.shape.as_deref(),
137 self.flat,
138 self.flat_lists,
139 );
140
141 push_opt(&mut q, "award_date", self.award_date.as_deref());
142 push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
143 push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
144 push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
145 push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
146 push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
147 push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
148 push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
149 push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
150 push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
151 push_opt(&mut q, "idv_type", self.idv_type.as_deref());
152 push_opt(
153 &mut q,
154 "last_date_to_order_gte",
155 self.last_date_to_order_gte.as_deref(),
156 );
157 push_opt(
158 &mut q,
159 "last_date_to_order_lte",
160 self.last_date_to_order_lte.as_deref(),
161 );
162 push_opt(&mut q, "naics", self.naics.as_deref());
163 push_opt(&mut q, "ordering", self.ordering.as_deref());
164 push_opt(&mut q, "piid", self.piid.as_deref());
165 push_opt(&mut q, "key", self.key.as_deref());
166 push_opt(
167 &mut q,
168 "pop_start_date_gte",
169 self.pop_start_date_gte.as_deref(),
170 );
171 push_opt(
172 &mut q,
173 "pop_start_date_lte",
174 self.pop_start_date_lte.as_deref(),
175 );
176 push_opt(&mut q, "psc", self.psc.as_deref());
177 push_opt(&mut q, "recipient", self.recipient.as_deref());
178 push_opt(&mut q, "search", self.search.as_deref());
179 push_opt(&mut q, "set_aside", self.set_aside.as_deref());
180 push_opt(
181 &mut q,
182 "solicitation_identifier",
183 self.solicitation_identifier.as_deref(),
184 );
185 push_opt(&mut q, "uei", self.uei.as_deref());
186 push_opt(
187 &mut q,
188 "commercial_item_acquisition_procedures",
189 self.commercial_item_acquisition_procedures.as_deref(),
190 );
191
192 for (k, v) in &self.extra {
193 if !v.is_empty() {
194 q.push((k.clone(), v.clone()));
195 }
196 }
197 q
198 }
199}
200
201#[allow(clippy::upper_case_acronyms)]
203#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
204#[non_exhaustive]
205pub struct GetIDVOptions {
206 #[builder(into)]
208 pub shape: Option<String>,
209 #[builder(default)]
211 pub flat: bool,
212 #[builder(default)]
214 pub flat_lists: bool,
215}
216
217impl GetIDVOptions {
218 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
219 let mut q = Vec::new();
220 push_opt(&mut q, "shape", self.shape.as_deref());
221 if self.flat {
222 q.push(("flat".into(), "true".into()));
223 }
224 if self.flat_lists {
225 q.push(("flat_lists".into(), "true".into()));
226 }
227 q
228 }
229}
230
231impl Client {
232 pub async fn list_idvs(&self, opts: ListIDVsOptions) -> Result<Page<Record>> {
234 let q = opts.to_query();
235 self.get_page("/api/idvs/", &q).await
236 }
237
238 pub async fn get_idv(&self, key: &str, opts: Option<GetIDVOptions>) -> Result<Record> {
243 if key.is_empty() {
244 return Err(Error::Validation {
245 message: "get_idv: key is required".into(),
246 response: None,
247 });
248 }
249 let q = opts.unwrap_or_default().to_query();
250 let path = format!("/api/idvs/{}/", urlencoding(key));
251 self.get_json::<Record>(&path, &q).await
252 }
253
254 pub fn iterate_idvs(&self, opts: ListIDVsOptions) -> PageStream<Record> {
257 let opts = Arc::new(opts);
258 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
259 let mut next = (*opts).clone();
260 next.page = page;
261 next.cursor = cursor;
262 Box::pin(async move { client.list_idvs(next).await })
263 });
264 PageStream::new(self.clone(), fetch)
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
273 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
274 }
275
276 #[test]
277 fn list_idvs_all_filters_emit() {
278 let opts = ListIDVsOptions::builder()
279 .award_date("2024-01-01")
280 .awarding_agency("9700")
281 .funding_agency("9800")
282 .idv_type("A")
283 .naics("541512")
284 .piid("W15P7T19D0001")
285 .psc("D302")
286 .recipient("Acme")
287 .search("keyword")
288 .set_aside("8A")
289 .solicitation_identifier("SOL001")
290 .uei("UEI12345")
291 .ordering("-award_date")
292 .build();
293 let q = opts.to_query();
294 assert_eq!(get_q(&q, "award_date").as_deref(), Some("2024-01-01"));
295 assert_eq!(get_q(&q, "awarding_agency").as_deref(), Some("9700"));
296 assert_eq!(get_q(&q, "funding_agency").as_deref(), Some("9800"));
297 assert_eq!(get_q(&q, "idv_type").as_deref(), Some("A"));
298 assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
299 assert_eq!(get_q(&q, "piid").as_deref(), Some("W15P7T19D0001"));
300 assert_eq!(get_q(&q, "psc").as_deref(), Some("D302"));
301 assert_eq!(get_q(&q, "recipient").as_deref(), Some("Acme"));
302 assert_eq!(get_q(&q, "search").as_deref(), Some("keyword"));
303 assert_eq!(get_q(&q, "set_aside").as_deref(), Some("8A"));
304 assert_eq!(
305 get_q(&q, "solicitation_identifier").as_deref(),
306 Some("SOL001")
307 );
308 assert_eq!(get_q(&q, "uei").as_deref(), Some("UEI12345"));
309 assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
310 }
311
312 #[test]
313 fn list_idvs_zero_value_omitted() {
314 let opts = ListIDVsOptions::builder().build();
315 let q = opts.to_query();
316 assert!(q.is_empty(), "expected empty query, got {q:?}");
317 }
318
319 #[test]
320 fn list_idvs_extra_emits() {
321 let mut extra = BTreeMap::new();
322 extra.insert("custom_x".to_string(), "xval".to_string());
323 let opts = ListIDVsOptions::builder().extra(extra).build();
324 let q = opts.to_query();
325 assert!(q.contains(&("custom_x".into(), "xval".into())));
326 }
327
328 #[test]
329 fn list_idvs_cursor_wins_over_page() {
330 let opts = ListIDVsOptions::builder()
331 .page(3u32)
332 .cursor("abc".to_string())
333 .build();
334 let q = opts.to_query();
335 assert_eq!(get_q(&q, "cursor").as_deref(), Some("abc"));
336 assert_eq!(get_q(&q, "page"), None);
337 }
338
339 #[test]
340 fn get_idv_opts_emits_shape_and_flat() {
341 let opts = GetIDVOptions::builder()
342 .shape("idvs(minimal)")
343 .flat(true)
344 .flat_lists(true)
345 .build();
346 let q = opts.to_query();
347 assert!(q.contains(&("shape".into(), "idvs(minimal)".into())));
348 assert!(q.contains(&("flat".into(), "true".into())));
349 assert!(q.contains(&("flat_lists".into(), "true".into())));
350 }
351
352 #[tokio::test]
353 async fn get_idv_empty_key_returns_validation() {
354 let client = Client::builder().api_key("x").build().expect("build");
355 let err = client.get_idv("", None).await.expect_err("must error");
356 match err {
357 Error::Validation { message, .. } => {
358 assert!(message.contains("key"));
359 }
360 other => panic!("expected Validation, got {other:?}"),
361 }
362 }
363
364 #[test]
365 fn list_idvs_key_filter_emits() {
366 let q = ListIDVsOptions::builder().key("K1").build().to_query();
367 assert_eq!(get_q(&q, "key").as_deref(), Some("K1"));
368 }
369
370 #[test]
371 fn list_idvs_commercial_item_acquisition_procedures_emits() {
372 let q = ListIDVsOptions::builder()
373 .commercial_item_acquisition_procedures("A|E")
374 .build()
375 .to_query();
376 assert_eq!(
377 get_q(&q, "commercial_item_acquisition_procedures").as_deref(),
378 Some("A|E")
379 );
380 }
381}