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
116 #[builder(into)]
118 pub key: Option<String>,
119
120 #[builder(default)]
122 pub extra: BTreeMap<String, String>,
123}
124
125impl ListIDVsOptions {
126 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
127 let mut q = Vec::new();
128 apply_pagination(
129 &mut q,
130 self.page,
131 self.limit,
132 self.cursor.as_deref(),
133 self.shape.as_deref(),
134 self.flat,
135 self.flat_lists,
136 );
137
138 push_opt(&mut q, "award_date", self.award_date.as_deref());
139 push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
140 push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
141 push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
142 push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
143 push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
144 push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
145 push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
146 push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
147 push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
148 push_opt(&mut q, "idv_type", self.idv_type.as_deref());
149 push_opt(
150 &mut q,
151 "last_date_to_order_gte",
152 self.last_date_to_order_gte.as_deref(),
153 );
154 push_opt(
155 &mut q,
156 "last_date_to_order_lte",
157 self.last_date_to_order_lte.as_deref(),
158 );
159 push_opt(&mut q, "naics", self.naics.as_deref());
160 push_opt(&mut q, "ordering", self.ordering.as_deref());
161 push_opt(&mut q, "piid", self.piid.as_deref());
162 push_opt(&mut q, "key", self.key.as_deref());
163 push_opt(
164 &mut q,
165 "pop_start_date_gte",
166 self.pop_start_date_gte.as_deref(),
167 );
168 push_opt(
169 &mut q,
170 "pop_start_date_lte",
171 self.pop_start_date_lte.as_deref(),
172 );
173 push_opt(&mut q, "psc", self.psc.as_deref());
174 push_opt(&mut q, "recipient", self.recipient.as_deref());
175 push_opt(&mut q, "search", self.search.as_deref());
176 push_opt(&mut q, "set_aside", self.set_aside.as_deref());
177 push_opt(
178 &mut q,
179 "solicitation_identifier",
180 self.solicitation_identifier.as_deref(),
181 );
182 push_opt(&mut q, "uei", self.uei.as_deref());
183
184 for (k, v) in &self.extra {
185 if !v.is_empty() {
186 q.push((k.clone(), v.clone()));
187 }
188 }
189 q
190 }
191}
192
193#[allow(clippy::upper_case_acronyms)]
195#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
196#[non_exhaustive]
197pub struct GetIDVOptions {
198 #[builder(into)]
200 pub shape: Option<String>,
201 #[builder(default)]
203 pub flat: bool,
204 #[builder(default)]
206 pub flat_lists: bool,
207}
208
209impl GetIDVOptions {
210 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
211 let mut q = Vec::new();
212 push_opt(&mut q, "shape", self.shape.as_deref());
213 if self.flat {
214 q.push(("flat".into(), "true".into()));
215 }
216 if self.flat_lists {
217 q.push(("flat_lists".into(), "true".into()));
218 }
219 q
220 }
221}
222
223impl Client {
224 pub async fn list_idvs(&self, opts: ListIDVsOptions) -> Result<Page<Record>> {
226 let q = opts.to_query();
227 let bytes = self.get_bytes("/api/idvs/", &q).await?;
228 Page::decode(&bytes)
229 }
230
231 pub async fn get_idv(&self, key: &str, opts: Option<GetIDVOptions>) -> Result<Record> {
236 if key.is_empty() {
237 return Err(Error::Validation {
238 message: "get_idv: key is required".into(),
239 response: None,
240 });
241 }
242 let q = opts.unwrap_or_default().to_query();
243 let path = format!("/api/idvs/{}/", urlencoding(key));
244 self.get_json::<Record>(&path, &q).await
245 }
246
247 pub fn iterate_idvs(&self, opts: ListIDVsOptions) -> PageStream<Record> {
250 let opts = Arc::new(opts);
251 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
252 let mut next = (*opts).clone();
253 next.page = page;
254 next.cursor = cursor;
255 Box::pin(async move { client.list_idvs(next).await })
256 });
257 PageStream::new(self.clone(), fetch)
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
266 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
267 }
268
269 #[test]
270 fn list_idvs_all_filters_emit() {
271 let opts = ListIDVsOptions::builder()
272 .award_date("2024-01-01")
273 .awarding_agency("9700")
274 .funding_agency("9800")
275 .idv_type("A")
276 .naics("541512")
277 .piid("W15P7T19D0001")
278 .psc("D302")
279 .recipient("Acme")
280 .search("keyword")
281 .set_aside("8A")
282 .solicitation_identifier("SOL001")
283 .uei("UEI12345")
284 .ordering("-award_date")
285 .build();
286 let q = opts.to_query();
287 assert_eq!(get_q(&q, "award_date").as_deref(), Some("2024-01-01"));
288 assert_eq!(get_q(&q, "awarding_agency").as_deref(), Some("9700"));
289 assert_eq!(get_q(&q, "funding_agency").as_deref(), Some("9800"));
290 assert_eq!(get_q(&q, "idv_type").as_deref(), Some("A"));
291 assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
292 assert_eq!(get_q(&q, "piid").as_deref(), Some("W15P7T19D0001"));
293 assert_eq!(get_q(&q, "psc").as_deref(), Some("D302"));
294 assert_eq!(get_q(&q, "recipient").as_deref(), Some("Acme"));
295 assert_eq!(get_q(&q, "search").as_deref(), Some("keyword"));
296 assert_eq!(get_q(&q, "set_aside").as_deref(), Some("8A"));
297 assert_eq!(
298 get_q(&q, "solicitation_identifier").as_deref(),
299 Some("SOL001")
300 );
301 assert_eq!(get_q(&q, "uei").as_deref(), Some("UEI12345"));
302 assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
303 }
304
305 #[test]
306 fn list_idvs_zero_value_omitted() {
307 let opts = ListIDVsOptions::builder().build();
308 let q = opts.to_query();
309 assert!(q.is_empty(), "expected empty query, got {q:?}");
310 }
311
312 #[test]
313 fn list_idvs_extra_emits() {
314 let mut extra = BTreeMap::new();
315 extra.insert("custom_x".to_string(), "xval".to_string());
316 let opts = ListIDVsOptions::builder().extra(extra).build();
317 let q = opts.to_query();
318 assert!(q.contains(&("custom_x".into(), "xval".into())));
319 }
320
321 #[test]
322 fn list_idvs_cursor_wins_over_page() {
323 let opts = ListIDVsOptions::builder()
324 .page(3u32)
325 .cursor("abc".to_string())
326 .build();
327 let q = opts.to_query();
328 assert_eq!(get_q(&q, "cursor").as_deref(), Some("abc"));
329 assert_eq!(get_q(&q, "page"), None);
330 }
331
332 #[test]
333 fn get_idv_opts_emits_shape_and_flat() {
334 let opts = GetIDVOptions::builder()
335 .shape("idvs(minimal)")
336 .flat(true)
337 .flat_lists(true)
338 .build();
339 let q = opts.to_query();
340 assert!(q.contains(&("shape".into(), "idvs(minimal)".into())));
341 assert!(q.contains(&("flat".into(), "true".into())));
342 assert!(q.contains(&("flat_lists".into(), "true".into())));
343 }
344
345 #[tokio::test]
346 async fn get_idv_empty_key_returns_validation() {
347 let client = Client::builder().api_key("x").build().expect("build");
348 let err = client.get_idv("", None).await.expect_err("must error");
349 match err {
350 Error::Validation { message, .. } => {
351 assert!(message.contains("key"));
352 }
353 other => panic!("expected Validation, got {other:?}"),
354 }
355 }
356
357 #[test]
358 fn list_idvs_key_filter_emits() {
359 let q = ListIDVsOptions::builder().key("K1").build().to_query();
360 assert_eq!(get_q(&q, "key").as_deref(), Some("K1"));
361 }
362}