Skip to main content

tango/resources/
otas.rs

1//! `GET /api/otas/` and `GET /api/otidvs/` — Other Transaction Authority
2//! award actions and their IDV parents.
3
4use crate::client::Client;
5use crate::error::{Error, Result};
6use crate::internal::{apply_pagination, push_opt};
7use crate::pagination::{FetchFn, Page, PageStream};
8use crate::resources::agencies::urlencoding;
9use crate::Record;
10use bon::Builder;
11use std::collections::BTreeMap;
12use std::sync::Arc;
13
14// ============================================================================
15// OTAs (/api/otas/)
16// ============================================================================
17
18/// Options for [`Client::list_otas`] and [`Client::iterate_otas`].
19///
20/// Mirrors the Go SDK's `ListOTAsOptions`. All filters are sent as query
21/// parameters; empty/None values are omitted.
22#[allow(clippy::upper_case_acronyms)]
23#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
24#[non_exhaustive]
25pub struct ListOTAsOptions {
26    // ----- Pagination + shape (shared across every list endpoint) -----
27    /// 1-based page number. Mutually exclusive with [`cursor`](Self::cursor).
28    #[builder(into)]
29    pub page: Option<u32>,
30    /// Page size (server caps at 100 on most endpoints).
31    #[builder(into)]
32    pub limit: Option<u32>,
33    /// Keyset cursor for cursor-paginated endpoints. Pass the `cursor` field
34    /// from the previous [`Page`](crate::Page).
35    #[builder(into)]
36    pub cursor: Option<String>,
37    /// Comma-separated field selector. Use [`SHAPE_OTAS_MINIMAL`](crate::SHAPE_OTAS_MINIMAL)
38    /// or roll your own.
39    #[builder(into)]
40    pub shape: Option<String>,
41    /// Collapse nested objects into dot-separated keys.
42    #[builder(default)]
43    pub flat: bool,
44    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
45    #[builder(default)]
46    pub flat_lists: bool,
47
48    /// Joiner used between flattened keys when `flat=true`. Defaults to `.` server-side.
49    #[builder(into)]
50    pub joiner: Option<String>,
51
52    // ----- Identifiers + agencies -----
53    /// Awarding agency CGAC code.
54    #[builder(into)]
55    pub awarding_agency: Option<String>,
56    /// Funding agency CGAC code.
57    #[builder(into)]
58    pub funding_agency: Option<String>,
59    /// Procurement Instrument Identifier.
60    #[builder(into)]
61    pub piid: Option<String>,
62    /// Recipient name filter.
63    #[builder(into)]
64    pub recipient: Option<String>,
65    /// Recipient UEI.
66    #[builder(into)]
67    pub uei: Option<String>,
68
69    // ----- Fiscal-year bounds -----
70    /// `fiscal_year` filter.
71    #[builder(into)]
72    pub fiscal_year: Option<String>,
73    /// Lower bound for `fiscal_year`.
74    #[builder(into)]
75    pub fiscal_year_gte: Option<String>,
76    /// Upper bound for `fiscal_year`.
77    #[builder(into)]
78    pub fiscal_year_lte: Option<String>,
79
80    // ----- Date bounds -----
81    /// Single-day `award_date` filter (ISO `YYYY-MM-DD`).
82    #[builder(into)]
83    pub award_date: Option<String>,
84    /// Lower bound for `award_date` (inclusive).
85    #[builder(into)]
86    pub award_date_gte: Option<String>,
87    /// Upper bound for `award_date` (inclusive).
88    #[builder(into)]
89    pub award_date_lte: Option<String>,
90    /// Lower bound for the OTA's expiration date.
91    #[builder(into)]
92    pub expiring_gte: Option<String>,
93    /// Upper bound for the OTA's expiration date.
94    #[builder(into)]
95    pub expiring_lte: Option<String>,
96    /// Lower bound for period-of-performance start date.
97    #[builder(into)]
98    pub pop_start_date_gte: Option<String>,
99    /// Upper bound for period-of-performance start date.
100    #[builder(into)]
101    pub pop_start_date_lte: Option<String>,
102    /// Lower bound for period-of-performance end date.
103    #[builder(into)]
104    pub pop_end_date_gte: Option<String>,
105    /// Upper bound for period-of-performance end date.
106    #[builder(into)]
107    pub pop_end_date_lte: Option<String>,
108
109    /// PSC code.
110    #[builder(into)]
111    pub psc: Option<String>,
112
113    /// Free-text search filter.
114    #[builder(into)]
115    pub search: Option<String>,
116    /// Server-side sort spec (prefix `-` for descending).
117    #[builder(into)]
118    pub ordering: Option<String>,
119
120    /// Escape hatch for filter keys not yet first-classed on this struct.
121    #[builder(default)]
122    pub extra: BTreeMap<String, String>,
123}
124
125impl ListOTAsOptions {
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        push_opt(&mut q, "joiner", self.joiner.as_deref());
138        push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
139        push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
140        push_opt(&mut q, "piid", self.piid.as_deref());
141        push_opt(&mut q, "recipient", self.recipient.as_deref());
142        push_opt(&mut q, "uei", self.uei.as_deref());
143        push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
144        push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
145        push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
146        push_opt(&mut q, "award_date", self.award_date.as_deref());
147        push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
148        push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
149        push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
150        push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
151        push_opt(
152            &mut q,
153            "pop_start_date_gte",
154            self.pop_start_date_gte.as_deref(),
155        );
156        push_opt(
157            &mut q,
158            "pop_start_date_lte",
159            self.pop_start_date_lte.as_deref(),
160        );
161        push_opt(&mut q, "pop_end_date_gte", self.pop_end_date_gte.as_deref());
162        push_opt(&mut q, "pop_end_date_lte", self.pop_end_date_lte.as_deref());
163        push_opt(&mut q, "psc", self.psc.as_deref());
164        push_opt(&mut q, "search", self.search.as_deref());
165        push_opt(&mut q, "ordering", self.ordering.as_deref());
166        for (k, v) in &self.extra {
167            if !v.is_empty() {
168                q.push((k.clone(), v.clone()));
169            }
170        }
171        q
172    }
173}
174
175/// Options for [`Client::get_ota`].
176#[allow(clippy::upper_case_acronyms)]
177#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
178#[non_exhaustive]
179pub struct GetOTAOptions {
180    /// Shape selector. When empty, the server returns its comprehensive default.
181    #[builder(into)]
182    pub shape: Option<String>,
183    /// Flatten nested objects into dot-separated keys.
184    #[builder(default)]
185    pub flat: bool,
186    /// When `flat=true`, also flatten list-valued nested fields.
187    #[builder(default)]
188    pub flat_lists: bool,
189}
190
191impl GetOTAOptions {
192    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
193        let mut q = Vec::new();
194        push_opt(&mut q, "shape", self.shape.as_deref());
195        if self.flat {
196            q.push(("flat".into(), "true".into()));
197        }
198        if self.flat_lists {
199            q.push(("flat_lists".into(), "true".into()));
200        }
201        q
202    }
203}
204
205// ============================================================================
206// OTIDVs (/api/otidvs/)
207// ============================================================================
208
209/// Options for [`Client::list_otidvs`] and [`Client::iterate_otidvs`].
210///
211/// Filter shape is identical to [`ListOTAsOptions`] per the sibling SDKs.
212#[allow(clippy::upper_case_acronyms)]
213#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
214#[non_exhaustive]
215pub struct ListOTIDVsOptions {
216    /// 1-based page number.
217    #[builder(into)]
218    pub page: Option<u32>,
219    /// Page size.
220    #[builder(into)]
221    pub limit: Option<u32>,
222    /// Keyset cursor.
223    #[builder(into)]
224    pub cursor: Option<String>,
225    /// Comma-separated field selector. Use
226    /// [`SHAPE_OTIDVS_MINIMAL`](crate::SHAPE_OTIDVS_MINIMAL) or roll your own.
227    #[builder(into)]
228    pub shape: Option<String>,
229    /// Collapse nested objects into dot-separated keys.
230    #[builder(default)]
231    pub flat: bool,
232    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
233    #[builder(default)]
234    pub flat_lists: bool,
235
236    /// Joiner used between flattened keys when `flat=true`.
237    #[builder(into)]
238    pub joiner: Option<String>,
239
240    /// Awarding agency CGAC code.
241    #[builder(into)]
242    pub awarding_agency: Option<String>,
243    /// Funding agency CGAC code.
244    #[builder(into)]
245    pub funding_agency: Option<String>,
246    /// Procurement Instrument Identifier.
247    #[builder(into)]
248    pub piid: Option<String>,
249    /// Recipient name filter.
250    #[builder(into)]
251    pub recipient: Option<String>,
252    /// Recipient UEI.
253    #[builder(into)]
254    pub uei: Option<String>,
255
256    /// `fiscal_year` filter.
257    #[builder(into)]
258    pub fiscal_year: Option<String>,
259    /// Lower bound for `fiscal_year`.
260    #[builder(into)]
261    pub fiscal_year_gte: Option<String>,
262    /// Upper bound for `fiscal_year`.
263    #[builder(into)]
264    pub fiscal_year_lte: Option<String>,
265
266    /// Single-day `award_date` filter (ISO `YYYY-MM-DD`).
267    #[builder(into)]
268    pub award_date: Option<String>,
269    /// Lower bound for `award_date`.
270    #[builder(into)]
271    pub award_date_gte: Option<String>,
272    /// Upper bound for `award_date`.
273    #[builder(into)]
274    pub award_date_lte: Option<String>,
275    /// Lower bound for the OTIDV's expiration date.
276    #[builder(into)]
277    pub expiring_gte: Option<String>,
278    /// Upper bound for the OTIDV's expiration date.
279    #[builder(into)]
280    pub expiring_lte: Option<String>,
281    /// Lower bound for period-of-performance start date.
282    #[builder(into)]
283    pub pop_start_date_gte: Option<String>,
284    /// Upper bound for period-of-performance start date.
285    #[builder(into)]
286    pub pop_start_date_lte: Option<String>,
287    /// Lower bound for period-of-performance end date.
288    #[builder(into)]
289    pub pop_end_date_gte: Option<String>,
290    /// Upper bound for period-of-performance end date.
291    #[builder(into)]
292    pub pop_end_date_lte: Option<String>,
293
294    /// PSC code.
295    #[builder(into)]
296    pub psc: Option<String>,
297
298    /// Free-text search filter.
299    #[builder(into)]
300    pub search: Option<String>,
301    /// Server-side sort spec (prefix `-` for descending).
302    #[builder(into)]
303    pub ordering: Option<String>,
304
305    /// Escape hatch for filter keys not yet first-classed.
306    #[builder(default)]
307    pub extra: BTreeMap<String, String>,
308}
309
310impl ListOTIDVsOptions {
311    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
312        let mut q = Vec::new();
313        apply_pagination(
314            &mut q,
315            self.page,
316            self.limit,
317            self.cursor.as_deref(),
318            self.shape.as_deref(),
319            self.flat,
320            self.flat_lists,
321        );
322        push_opt(&mut q, "joiner", self.joiner.as_deref());
323        push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
324        push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
325        push_opt(&mut q, "piid", self.piid.as_deref());
326        push_opt(&mut q, "recipient", self.recipient.as_deref());
327        push_opt(&mut q, "uei", self.uei.as_deref());
328        push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
329        push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
330        push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
331        push_opt(&mut q, "award_date", self.award_date.as_deref());
332        push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
333        push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
334        push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
335        push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
336        push_opt(
337            &mut q,
338            "pop_start_date_gte",
339            self.pop_start_date_gte.as_deref(),
340        );
341        push_opt(
342            &mut q,
343            "pop_start_date_lte",
344            self.pop_start_date_lte.as_deref(),
345        );
346        push_opt(&mut q, "pop_end_date_gte", self.pop_end_date_gte.as_deref());
347        push_opt(&mut q, "pop_end_date_lte", self.pop_end_date_lte.as_deref());
348        push_opt(&mut q, "psc", self.psc.as_deref());
349        push_opt(&mut q, "search", self.search.as_deref());
350        push_opt(&mut q, "ordering", self.ordering.as_deref());
351        for (k, v) in &self.extra {
352            if !v.is_empty() {
353                q.push((k.clone(), v.clone()));
354            }
355        }
356        q
357    }
358}
359
360/// Options for [`Client::get_otidv`].
361#[allow(clippy::upper_case_acronyms)]
362#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
363#[non_exhaustive]
364pub struct GetOTIDVOptions {
365    /// Shape selector. When empty, the server returns its comprehensive default.
366    #[builder(into)]
367    pub shape: Option<String>,
368    /// Flatten nested objects into dot-separated keys.
369    #[builder(default)]
370    pub flat: bool,
371    /// When `flat=true`, also flatten list-valued nested fields.
372    #[builder(default)]
373    pub flat_lists: bool,
374}
375
376impl GetOTIDVOptions {
377    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
378        let mut q = Vec::new();
379        push_opt(&mut q, "shape", self.shape.as_deref());
380        if self.flat {
381            q.push(("flat".into(), "true".into()));
382        }
383        if self.flat_lists {
384            q.push(("flat_lists".into(), "true".into()));
385        }
386        q
387    }
388}
389
390// ============================================================================
391// OTIDV awards (/api/otidvs/{key}/awards/)
392// ============================================================================
393
394/// Options for [`Client::list_otidv_awards`] and [`Client::iterate_otidv_awards`].
395///
396/// Same filter shape as [`ListOTAsOptions`].
397#[allow(clippy::upper_case_acronyms)]
398#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
399#[non_exhaustive]
400pub struct ListOTIDVAwardsOptions {
401    /// 1-based page number.
402    #[builder(into)]
403    pub page: Option<u32>,
404    /// Page size.
405    #[builder(into)]
406    pub limit: Option<u32>,
407    /// Keyset cursor.
408    #[builder(into)]
409    pub cursor: Option<String>,
410    /// Comma-separated field selector.
411    #[builder(into)]
412    pub shape: Option<String>,
413    /// Collapse nested objects into dot-separated keys.
414    #[builder(default)]
415    pub flat: bool,
416    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
417    #[builder(default)]
418    pub flat_lists: bool,
419
420    /// Joiner used between flattened keys when `flat=true`.
421    #[builder(into)]
422    pub joiner: Option<String>,
423
424    /// Awarding agency CGAC code.
425    #[builder(into)]
426    pub awarding_agency: Option<String>,
427    /// Funding agency CGAC code.
428    #[builder(into)]
429    pub funding_agency: Option<String>,
430    /// Procurement Instrument Identifier.
431    #[builder(into)]
432    pub piid: Option<String>,
433    /// Recipient name filter.
434    #[builder(into)]
435    pub recipient: Option<String>,
436    /// Recipient UEI.
437    #[builder(into)]
438    pub uei: Option<String>,
439
440    /// `fiscal_year` filter.
441    #[builder(into)]
442    pub fiscal_year: Option<String>,
443    /// Lower bound for `fiscal_year`.
444    #[builder(into)]
445    pub fiscal_year_gte: Option<String>,
446    /// Upper bound for `fiscal_year`.
447    #[builder(into)]
448    pub fiscal_year_lte: Option<String>,
449
450    /// Single-day `award_date` filter.
451    #[builder(into)]
452    pub award_date: Option<String>,
453    /// Lower bound for `award_date`.
454    #[builder(into)]
455    pub award_date_gte: Option<String>,
456    /// Upper bound for `award_date`.
457    #[builder(into)]
458    pub award_date_lte: Option<String>,
459    /// Lower bound for the award's expiration date.
460    #[builder(into)]
461    pub expiring_gte: Option<String>,
462    /// Upper bound for the award's expiration date.
463    #[builder(into)]
464    pub expiring_lte: Option<String>,
465    /// Lower bound for period-of-performance start date.
466    #[builder(into)]
467    pub pop_start_date_gte: Option<String>,
468    /// Upper bound for period-of-performance start date.
469    #[builder(into)]
470    pub pop_start_date_lte: Option<String>,
471    /// Lower bound for period-of-performance end date.
472    #[builder(into)]
473    pub pop_end_date_gte: Option<String>,
474    /// Upper bound for period-of-performance end date.
475    #[builder(into)]
476    pub pop_end_date_lte: Option<String>,
477
478    /// PSC code.
479    #[builder(into)]
480    pub psc: Option<String>,
481
482    /// Free-text search filter.
483    #[builder(into)]
484    pub search: Option<String>,
485    /// Server-side sort spec.
486    #[builder(into)]
487    pub ordering: Option<String>,
488
489    /// Escape hatch for filter keys not yet first-classed.
490    #[builder(default)]
491    pub extra: BTreeMap<String, String>,
492}
493
494impl ListOTIDVAwardsOptions {
495    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
496        let mut q = Vec::new();
497        apply_pagination(
498            &mut q,
499            self.page,
500            self.limit,
501            self.cursor.as_deref(),
502            self.shape.as_deref(),
503            self.flat,
504            self.flat_lists,
505        );
506        push_opt(&mut q, "joiner", self.joiner.as_deref());
507        push_opt(&mut q, "awarding_agency", self.awarding_agency.as_deref());
508        push_opt(&mut q, "funding_agency", self.funding_agency.as_deref());
509        push_opt(&mut q, "piid", self.piid.as_deref());
510        push_opt(&mut q, "recipient", self.recipient.as_deref());
511        push_opt(&mut q, "uei", self.uei.as_deref());
512        push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
513        push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
514        push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
515        push_opt(&mut q, "award_date", self.award_date.as_deref());
516        push_opt(&mut q, "award_date_gte", self.award_date_gte.as_deref());
517        push_opt(&mut q, "award_date_lte", self.award_date_lte.as_deref());
518        push_opt(&mut q, "expiring_gte", self.expiring_gte.as_deref());
519        push_opt(&mut q, "expiring_lte", self.expiring_lte.as_deref());
520        push_opt(
521            &mut q,
522            "pop_start_date_gte",
523            self.pop_start_date_gte.as_deref(),
524        );
525        push_opt(
526            &mut q,
527            "pop_start_date_lte",
528            self.pop_start_date_lte.as_deref(),
529        );
530        push_opt(&mut q, "pop_end_date_gte", self.pop_end_date_gte.as_deref());
531        push_opt(&mut q, "pop_end_date_lte", self.pop_end_date_lte.as_deref());
532        push_opt(&mut q, "psc", self.psc.as_deref());
533        push_opt(&mut q, "search", self.search.as_deref());
534        push_opt(&mut q, "ordering", self.ordering.as_deref());
535        for (k, v) in &self.extra {
536            if !v.is_empty() {
537                q.push((k.clone(), v.clone()));
538            }
539        }
540        q
541    }
542}
543
544// ============================================================================
545// Client methods
546// ============================================================================
547
548impl Client {
549    /// `GET /api/otas/` — one page of Other Transaction Authority award actions.
550    pub async fn list_otas(&self, opts: ListOTAsOptions) -> Result<Page<Record>> {
551        let q = opts.to_query();
552        let bytes = self.get_bytes("/api/otas/", &q).await?;
553        Page::decode(&bytes)
554    }
555
556    /// `GET /api/otas/{key}/` — fetch a single OTA by key.
557    pub async fn get_ota(&self, key: &str, opts: Option<GetOTAOptions>) -> Result<Record> {
558        if key.is_empty() {
559            return Err(Error::Validation {
560                message: "get_ota: key is required".into(),
561                response: None,
562            });
563        }
564        let q = opts.unwrap_or_default().to_query();
565        let path = format!("/api/otas/{}/", urlencoding(key));
566        self.get_json::<Record>(&path, &q).await
567    }
568
569    /// Stream every OTA matching `opts`.
570    pub fn iterate_otas(&self, opts: ListOTAsOptions) -> PageStream<Record> {
571        let opts = Arc::new(opts);
572        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
573            let mut next = (*opts).clone();
574            next.page = page;
575            next.cursor = cursor;
576            Box::pin(async move { client.list_otas(next).await })
577        });
578        PageStream::new(self.clone(), fetch)
579    }
580
581    /// `GET /api/otidvs/` — one page of Other Transaction IDV parent records.
582    pub async fn list_otidvs(&self, opts: ListOTIDVsOptions) -> Result<Page<Record>> {
583        let q = opts.to_query();
584        let bytes = self.get_bytes("/api/otidvs/", &q).await?;
585        Page::decode(&bytes)
586    }
587
588    /// `GET /api/otidvs/{key}/` — fetch a single OTIDV by key.
589    pub async fn get_otidv(&self, key: &str, opts: Option<GetOTIDVOptions>) -> Result<Record> {
590        if key.is_empty() {
591            return Err(Error::Validation {
592                message: "get_otidv: key is required".into(),
593                response: None,
594            });
595        }
596        let q = opts.unwrap_or_default().to_query();
597        let path = format!("/api/otidvs/{}/", urlencoding(key));
598        self.get_json::<Record>(&path, &q).await
599    }
600
601    /// Stream every OTIDV matching `opts`.
602    pub fn iterate_otidvs(&self, opts: ListOTIDVsOptions) -> PageStream<Record> {
603        let opts = Arc::new(opts);
604        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
605            let mut next = (*opts).clone();
606            next.page = page;
607            next.cursor = cursor;
608            Box::pin(async move { client.list_otidvs(next).await })
609        });
610        PageStream::new(self.clone(), fetch)
611    }
612
613    /// `GET /api/otidvs/{key}/awards/` — child awards under an OTIDV parent.
614    pub async fn list_otidv_awards(
615        &self,
616        key: &str,
617        opts: ListOTIDVAwardsOptions,
618    ) -> Result<Page<Record>> {
619        if key.is_empty() {
620            return Err(Error::Validation {
621                message: "list_otidv_awards: key is required".into(),
622                response: None,
623            });
624        }
625        let q = opts.to_query();
626        let path = format!("/api/otidvs/{}/awards/", urlencoding(key));
627        let bytes = self.get_bytes(&path, &q).await?;
628        Page::decode(&bytes)
629    }
630
631    /// Stream every child award under the given OTIDV parent.
632    pub fn iterate_otidv_awards(
633        &self,
634        key: &str,
635        opts: ListOTIDVAwardsOptions,
636    ) -> PageStream<Record> {
637        let opts = Arc::new(opts);
638        let key = Arc::new(key.to_string());
639        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
640            let mut next = (*opts).clone();
641            next.page = page;
642            next.cursor = cursor;
643            let key = key.clone();
644            Box::pin(async move { client.list_otidv_awards(&key, next).await })
645        });
646        PageStream::new(self.clone(), fetch)
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
655        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
656    }
657
658    #[test]
659    fn list_otas_all_filters_emit() {
660        let opts = ListOTAsOptions::builder()
661            .awarding_agency("9700")
662            .funding_agency("9800")
663            .piid("FA8650")
664            .recipient("Acme")
665            .uei("UEI123")
666            .fiscal_year("2024")
667            .fiscal_year_gte("2020")
668            .fiscal_year_lte("2024")
669            .award_date("2024-01-01")
670            .award_date_gte("2023-01-01")
671            .award_date_lte("2024-12-31")
672            .expiring_gte("2024-01-01")
673            .expiring_lte("2025-12-31")
674            .pop_start_date_gte("2024-01-01")
675            .pop_start_date_lte("2024-06-30")
676            .pop_end_date_gte("2024-07-01")
677            .pop_end_date_lte("2024-12-31")
678            .psc("D302")
679            .search("hypersonics")
680            .ordering("-award_date")
681            .build();
682        let q = opts.to_query();
683        assert_eq!(get_q(&q, "awarding_agency").as_deref(), Some("9700"));
684        assert_eq!(get_q(&q, "funding_agency").as_deref(), Some("9800"));
685        assert_eq!(get_q(&q, "piid").as_deref(), Some("FA8650"));
686        assert_eq!(get_q(&q, "recipient").as_deref(), Some("Acme"));
687        assert_eq!(get_q(&q, "uei").as_deref(), Some("UEI123"));
688        assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024"));
689        assert_eq!(get_q(&q, "fiscal_year_gte").as_deref(), Some("2020"));
690        assert_eq!(get_q(&q, "fiscal_year_lte").as_deref(), Some("2024"));
691        assert_eq!(get_q(&q, "award_date").as_deref(), Some("2024-01-01"));
692        assert_eq!(get_q(&q, "psc").as_deref(), Some("D302"));
693        assert_eq!(get_q(&q, "search").as_deref(), Some("hypersonics"));
694        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
695    }
696
697    #[test]
698    fn list_otas_zero_value_omitted() {
699        let opts = ListOTAsOptions::builder().build();
700        let q = opts.to_query();
701        assert!(q.is_empty(), "expected empty query, got {q:?}");
702    }
703
704    #[test]
705    fn list_otas_extra_emits() {
706        let mut extra = BTreeMap::new();
707        extra.insert("custom_x".to_string(), "xval".to_string());
708        let opts = ListOTAsOptions::builder().extra(extra).build();
709        let q = opts.to_query();
710        assert!(q.contains(&("custom_x".into(), "xval".into())));
711    }
712
713    #[test]
714    fn list_otas_cursor_wins_over_page() {
715        let opts = ListOTAsOptions::builder()
716            .page(3u32)
717            .cursor("abc".to_string())
718            .build();
719        let q = opts.to_query();
720        assert_eq!(get_q(&q, "cursor").as_deref(), Some("abc"));
721        assert_eq!(get_q(&q, "page"), None);
722    }
723
724    #[test]
725    fn list_otas_shape_and_flat_emit() {
726        let opts = ListOTAsOptions::builder()
727            .shape(crate::SHAPE_OTAS_MINIMAL)
728            .flat(true)
729            .flat_lists(true)
730            .joiner("__")
731            .build();
732        let q = opts.to_query();
733        assert_eq!(
734            get_q(&q, "shape").as_deref(),
735            Some(crate::SHAPE_OTAS_MINIMAL)
736        );
737        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
738        assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
739        assert_eq!(get_q(&q, "joiner").as_deref(), Some("__"));
740    }
741
742    #[test]
743    fn list_otidvs_all_filters_emit() {
744        let opts = ListOTIDVsOptions::builder()
745            .awarding_agency("9700")
746            .piid("FA8650")
747            .uei("UEI123")
748            .fiscal_year("2024")
749            .award_date_gte("2023-01-01")
750            .search("hypersonics")
751            .ordering("-award_date")
752            .build();
753        let q = opts.to_query();
754        assert_eq!(get_q(&q, "awarding_agency").as_deref(), Some("9700"));
755        assert_eq!(get_q(&q, "piid").as_deref(), Some("FA8650"));
756        assert_eq!(get_q(&q, "uei").as_deref(), Some("UEI123"));
757        assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024"));
758        assert_eq!(get_q(&q, "award_date_gte").as_deref(), Some("2023-01-01"));
759        assert_eq!(get_q(&q, "search").as_deref(), Some("hypersonics"));
760        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
761    }
762
763    #[test]
764    fn list_otidv_awards_emits_filters() {
765        let opts = ListOTIDVAwardsOptions::builder()
766            .awarding_agency("9700")
767            .recipient("Acme")
768            .search("kw")
769            .build();
770        let q = opts.to_query();
771        assert_eq!(get_q(&q, "awarding_agency").as_deref(), Some("9700"));
772        assert_eq!(get_q(&q, "recipient").as_deref(), Some("Acme"));
773        assert_eq!(get_q(&q, "search").as_deref(), Some("kw"));
774    }
775
776    #[tokio::test]
777    async fn get_ota_validates_empty_key() {
778        let client = Client::builder().api_key("x").build().expect("client");
779        let err = client.get_ota("", None).await.unwrap_err();
780        match err {
781            Error::Validation { message, .. } => {
782                assert!(message.contains("key is required"));
783            }
784            other => panic!("expected Validation, got {other:?}"),
785        }
786    }
787
788    #[tokio::test]
789    async fn get_otidv_validates_empty_key() {
790        let client = Client::builder().api_key("x").build().expect("client");
791        let err = client.get_otidv("", None).await.unwrap_err();
792        match err {
793            Error::Validation { message, .. } => {
794                assert!(message.contains("key is required"));
795            }
796            other => panic!("expected Validation, got {other:?}"),
797        }
798    }
799
800    #[tokio::test]
801    async fn list_otidv_awards_validates_empty_key() {
802        let client = Client::builder().api_key("x").build().expect("client");
803        let err = client
804            .list_otidv_awards("", ListOTIDVAwardsOptions::default())
805            .await
806            .unwrap_err();
807        match err {
808            Error::Validation { message, .. } => {
809                assert!(message.contains("key is required"));
810            }
811            other => panic!("expected Validation, got {other:?}"),
812        }
813    }
814}