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