Skip to main content

tango/resources/
dibbs.rs

1//! `GET /api/dibbs/` — Defense Logistics Agency (DLA) DIBBS solicitations and awards: requests for quote (RFQs), requests for proposal (RFPs), and award line items.
2//!
3//! Every DIBBS record belongs to the Defense Logistics Agency.
4//! Open/closed on RFQs and RFPs is derived at query time from the response date, so filter with `open` rather than comparing a returned `is_open` from an older page.
5
6use crate::client::Client;
7use crate::error::{Error, Result};
8use crate::internal::{apply_pagination, push_opt, push_opt_bool};
9use crate::pagination::{FetchFn, Page, PageStream};
10use crate::resources::agencies::urlencoding;
11use crate::Record;
12use bon::Builder;
13use std::collections::BTreeMap;
14use std::sync::Arc;
15
16/// Options for [`Client::list_dibbs_rfqs`] and [`Client::iterate_dibbs_rfqs`].
17#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
18#[non_exhaustive]
19pub struct ListDibbsRfqsOptions {
20    /// 1-based page number.
21    #[builder(into)]
22    pub page: Option<u32>,
23    /// Page size (server caps at 100).
24    #[builder(into)]
25    pub limit: Option<u32>,
26    /// Keyset cursor.
27    #[builder(into)]
28    pub cursor: Option<String>,
29    /// Comma-separated field selector. Use [`SHAPE_DIBBS_RFQS_MINIMAL`](crate::SHAPE_DIBBS_RFQS_MINIMAL) or roll your own.
30    #[builder(into)]
31    pub shape: Option<String>,
32    /// Collapse nested objects into dot-separated keys.
33    #[builder(default)]
34    pub flat: bool,
35    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
36    #[builder(default)]
37    pub flat_lists: bool,
38
39    /// Whether the RFQ is still accepting quotes as of today (its `return_by_date` has not passed). Derived at query time; there is no stored flag. `Some(false)` reaches the server as a filter value.
40    pub open: Option<bool>,
41    /// National Stock Number. Supports OR via `|`.
42    #[builder(into)]
43    pub nsn: Option<String>,
44    /// Manufacturer part number. Supports OR via `|`.
45    #[builder(into)]
46    pub part_number: Option<String>,
47    /// DLA solicitation number (e.g. `SPE1C126Q0337`).
48    #[builder(into)]
49    pub solicitation: Option<String>,
50    /// Purchase request number. Supports OR via `|`.
51    #[builder(into)]
52    pub purchase_request: Option<String>,
53    /// Set-aside flag (`Y` or `N`).
54    #[builder(into)]
55    pub set_aside: Option<String>,
56    /// DIBBS status code. Supports OR via `|`.
57    #[builder(into)]
58    pub status_code: Option<String>,
59    /// The resolved organization's federal hierarchy key.
60    #[builder(into)]
61    pub organization: Option<String>,
62    /// Quantity greater than or equal to.
63    #[builder(into)]
64    pub quantity_min: Option<String>,
65    /// Quantity less than or equal to.
66    #[builder(into)]
67    pub quantity_max: Option<String>,
68    /// Return-by date on or after (`YYYY-MM-DD`).
69    #[builder(into)]
70    pub return_by_date_after: Option<String>,
71    /// Return-by date on or before (`YYYY-MM-DD`).
72    #[builder(into)]
73    pub return_by_date_before: Option<String>,
74    /// Issue date on or after (`YYYY-MM-DD`).
75    #[builder(into)]
76    pub issue_date_after: Option<String>,
77    /// Issue date on or before (`YYYY-MM-DD`).
78    #[builder(into)]
79    pub issue_date_before: Option<String>,
80    /// Full-text search across nomenclature, NSN, part number, and solicitation number.
81    #[builder(into)]
82    pub search: Option<String>,
83    /// One of `issue_date`, `return_by_date`, `quantity`, `modified`, each optionally `-` prefixed.
84    #[builder(into)]
85    pub ordering: Option<String>,
86
87    /// Escape hatch for filter keys not yet first-classed on this struct.
88    #[builder(default)]
89    pub extra: BTreeMap<String, String>,
90}
91
92impl ListDibbsRfqsOptions {
93    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
94        let mut q = Vec::new();
95        apply_pagination(
96            &mut q,
97            self.page,
98            self.limit,
99            self.cursor.as_deref(),
100            self.shape.as_deref(),
101            self.flat,
102            self.flat_lists,
103        );
104        push_opt_bool(&mut q, "open", self.open);
105        push_opt(&mut q, "nsn", self.nsn.as_deref());
106        push_opt(&mut q, "part_number", self.part_number.as_deref());
107        push_opt(&mut q, "solicitation", self.solicitation.as_deref());
108        push_opt(&mut q, "purchase_request", self.purchase_request.as_deref());
109        push_opt(&mut q, "set_aside", self.set_aside.as_deref());
110        push_opt(&mut q, "status_code", self.status_code.as_deref());
111        push_opt(&mut q, "organization", self.organization.as_deref());
112        push_opt(&mut q, "quantity_min", self.quantity_min.as_deref());
113        push_opt(&mut q, "quantity_max", self.quantity_max.as_deref());
114        push_opt(
115            &mut q,
116            "return_by_date_after",
117            self.return_by_date_after.as_deref(),
118        );
119        push_opt(
120            &mut q,
121            "return_by_date_before",
122            self.return_by_date_before.as_deref(),
123        );
124        push_opt(&mut q, "issue_date_after", self.issue_date_after.as_deref());
125        push_opt(
126            &mut q,
127            "issue_date_before",
128            self.issue_date_before.as_deref(),
129        );
130        push_opt(&mut q, "search", self.search.as_deref());
131        push_opt(&mut q, "ordering", self.ordering.as_deref());
132        for (k, v) in &self.extra {
133            if !v.is_empty() {
134                q.push((k.clone(), v.clone()));
135            }
136        }
137        q
138    }
139}
140
141/// Options for [`Client::list_dibbs_rfps`] and [`Client::iterate_dibbs_rfps`].
142#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
143#[non_exhaustive]
144pub struct ListDibbsRfpsOptions {
145    /// 1-based page number.
146    #[builder(into)]
147    pub page: Option<u32>,
148    /// Page size (server caps at 100).
149    #[builder(into)]
150    pub limit: Option<u32>,
151    /// Keyset cursor.
152    #[builder(into)]
153    pub cursor: Option<String>,
154    /// Comma-separated field selector. Use [`SHAPE_DIBBS_RFPS_MINIMAL`](crate::SHAPE_DIBBS_RFPS_MINIMAL) or roll your own.
155    #[builder(into)]
156    pub shape: Option<String>,
157    /// Collapse nested objects into dot-separated keys.
158    #[builder(default)]
159    pub flat: bool,
160    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
161    #[builder(default)]
162    pub flat_lists: bool,
163
164    /// Whether the solicitation is still accepting offers as of today (its `closes_date` has not passed). Derived at query time; there is no stored flag. `Some(false)` reaches the server as a filter value.
165    pub open: Option<bool>,
166    /// National Stock Number. Supports OR via `|`.
167    #[builder(into)]
168    pub nsn: Option<String>,
169    /// Manufacturer part number. Supports OR via `|`.
170    #[builder(into)]
171    pub part_number: Option<String>,
172    /// DLA solicitation number.
173    #[builder(into)]
174    pub solicitation: Option<String>,
175    /// DLA buyer code. Supports OR via `|`.
176    #[builder(into)]
177    pub buyer_code: Option<String>,
178    /// The resolved organization's federal hierarchy key.
179    #[builder(into)]
180    pub organization: Option<String>,
181    /// Issued date on or after (`YYYY-MM-DD`).
182    #[builder(into)]
183    pub issued_date_after: Option<String>,
184    /// Issued date on or before (`YYYY-MM-DD`).
185    #[builder(into)]
186    pub issued_date_before: Option<String>,
187    /// Close date on or after (`YYYY-MM-DD`).
188    #[builder(into)]
189    pub closes_date_after: Option<String>,
190    /// Close date on or before (`YYYY-MM-DD`).
191    #[builder(into)]
192    pub closes_date_before: Option<String>,
193    /// Full-text search across nomenclature, NSN, part number, and solicitation number.
194    #[builder(into)]
195    pub search: Option<String>,
196    /// One of `issued_date`, `closes_date`, `modified`, each optionally `-` prefixed.
197    #[builder(into)]
198    pub ordering: Option<String>,
199
200    /// Escape hatch for filter keys not yet first-classed on this struct.
201    #[builder(default)]
202    pub extra: BTreeMap<String, String>,
203}
204
205impl ListDibbsRfpsOptions {
206    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
207        let mut q = Vec::new();
208        apply_pagination(
209            &mut q,
210            self.page,
211            self.limit,
212            self.cursor.as_deref(),
213            self.shape.as_deref(),
214            self.flat,
215            self.flat_lists,
216        );
217        push_opt_bool(&mut q, "open", self.open);
218        push_opt(&mut q, "nsn", self.nsn.as_deref());
219        push_opt(&mut q, "part_number", self.part_number.as_deref());
220        push_opt(&mut q, "solicitation", self.solicitation.as_deref());
221        push_opt(&mut q, "buyer_code", self.buyer_code.as_deref());
222        push_opt(&mut q, "organization", self.organization.as_deref());
223        push_opt(
224            &mut q,
225            "issued_date_after",
226            self.issued_date_after.as_deref(),
227        );
228        push_opt(
229            &mut q,
230            "issued_date_before",
231            self.issued_date_before.as_deref(),
232        );
233        push_opt(
234            &mut q,
235            "closes_date_after",
236            self.closes_date_after.as_deref(),
237        );
238        push_opt(
239            &mut q,
240            "closes_date_before",
241            self.closes_date_before.as_deref(),
242        );
243        push_opt(&mut q, "search", self.search.as_deref());
244        push_opt(&mut q, "ordering", self.ordering.as_deref());
245        for (k, v) in &self.extra {
246            if !v.is_empty() {
247                q.push((k.clone(), v.clone()));
248            }
249        }
250        q
251    }
252}
253
254/// Options for [`Client::list_dibbs_awards`] and [`Client::iterate_dibbs_awards`].
255///
256/// Each row is one line item of an award. **`total_contract_price` is the order total repeated on every line item**, so never sum it across rows: deduplicate on `award_number` + `delivery_order_number` first.
257#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
258#[non_exhaustive]
259pub struct ListDibbsAwardsOptions {
260    /// 1-based page number.
261    #[builder(into)]
262    pub page: Option<u32>,
263    /// Page size (server caps at 100).
264    #[builder(into)]
265    pub limit: Option<u32>,
266    /// Keyset cursor.
267    #[builder(into)]
268    pub cursor: Option<String>,
269    /// Comma-separated field selector. Use [`SHAPE_DIBBS_AWARDS_MINIMAL`](crate::SHAPE_DIBBS_AWARDS_MINIMAL) or roll your own.
270    #[builder(into)]
271    pub shape: Option<String>,
272    /// Collapse nested objects into dot-separated keys.
273    #[builder(default)]
274    pub flat: bool,
275    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
276    #[builder(default)]
277    pub flat_lists: bool,
278
279    /// National Stock Number. Supports OR via `|`.
280    #[builder(into)]
281    pub nsn: Option<String>,
282    /// Manufacturer part number. Supports OR via `|`.
283    #[builder(into)]
284    pub part_number: Option<String>,
285    /// DLA solicitation number.
286    #[builder(into)]
287    pub solicitation: Option<String>,
288    /// DLA award (contract) number. Order-scoped: one award number spans every line item of the order.
289    #[builder(into)]
290    pub award_number: Option<String>,
291    /// Delivery order number. Null on a basic award with no delivery order.
292    #[builder(into)]
293    pub delivery_order_number: Option<String>,
294    /// Purchase request number. Supports OR via `|`.
295    #[builder(into)]
296    pub purchase_request: Option<String>,
297    /// The awardee's CAGE code. Populated on every award row, whether or not it resolved to a Tango entity.
298    #[builder(into)]
299    pub awardee_cage: Option<String>,
300    /// UEI of the resolved Tango entity. Set only when the awardee's CAGE code matched a registered entity, so an unresolved award never matches it; use [`awardee_cage`](Self::awardee_cage) to reach every award.
301    #[builder(into)]
302    pub entity: Option<String>,
303    /// The resolved organization's federal hierarchy key.
304    #[builder(into)]
305    pub organization: Option<String>,
306    /// Award date on or after (`YYYY-MM-DD`).
307    #[builder(into)]
308    pub award_date_after: Option<String>,
309    /// Award date on or before (`YYYY-MM-DD`).
310    #[builder(into)]
311    pub award_date_before: Option<String>,
312    /// Posted date on or after (`YYYY-MM-DD`).
313    #[builder(into)]
314    pub posted_date_after: Option<String>,
315    /// Posted date on or before (`YYYY-MM-DD`).
316    #[builder(into)]
317    pub posted_date_before: Option<String>,
318    /// Order total contract price greater than or equal to.
319    #[builder(into)]
320    pub total_contract_price_min: Option<String>,
321    /// Order total contract price less than or equal to.
322    #[builder(into)]
323    pub total_contract_price_max: Option<String>,
324    /// Full-text search across nomenclature, NSN, part number, and solicitation number.
325    #[builder(into)]
326    pub search: Option<String>,
327    /// One of `award_date`, `posted_date`, `total_contract_price`, `modified`, each optionally `-` prefixed.
328    #[builder(into)]
329    pub ordering: Option<String>,
330
331    /// Escape hatch for filter keys not yet first-classed on this struct.
332    #[builder(default)]
333    pub extra: BTreeMap<String, String>,
334}
335
336impl ListDibbsAwardsOptions {
337    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
338        let mut q = Vec::new();
339        apply_pagination(
340            &mut q,
341            self.page,
342            self.limit,
343            self.cursor.as_deref(),
344            self.shape.as_deref(),
345            self.flat,
346            self.flat_lists,
347        );
348        push_opt(&mut q, "nsn", self.nsn.as_deref());
349        push_opt(&mut q, "part_number", self.part_number.as_deref());
350        push_opt(&mut q, "solicitation", self.solicitation.as_deref());
351        push_opt(&mut q, "award_number", self.award_number.as_deref());
352        push_opt(
353            &mut q,
354            "delivery_order_number",
355            self.delivery_order_number.as_deref(),
356        );
357        push_opt(&mut q, "purchase_request", self.purchase_request.as_deref());
358        push_opt(&mut q, "awardee_cage", self.awardee_cage.as_deref());
359        push_opt(&mut q, "entity", self.entity.as_deref());
360        push_opt(&mut q, "organization", self.organization.as_deref());
361        push_opt(&mut q, "award_date_after", self.award_date_after.as_deref());
362        push_opt(
363            &mut q,
364            "award_date_before",
365            self.award_date_before.as_deref(),
366        );
367        push_opt(
368            &mut q,
369            "posted_date_after",
370            self.posted_date_after.as_deref(),
371        );
372        push_opt(
373            &mut q,
374            "posted_date_before",
375            self.posted_date_before.as_deref(),
376        );
377        push_opt(
378            &mut q,
379            "total_contract_price_min",
380            self.total_contract_price_min.as_deref(),
381        );
382        push_opt(
383            &mut q,
384            "total_contract_price_max",
385            self.total_contract_price_max.as_deref(),
386        );
387        push_opt(&mut q, "search", self.search.as_deref());
388        push_opt(&mut q, "ordering", self.ordering.as_deref());
389        for (k, v) in &self.extra {
390            if !v.is_empty() {
391                q.push((k.clone(), v.clone()));
392            }
393        }
394        q
395    }
396}
397
398/// Options for [`Client::get_dibbs_rfq`], [`Client::get_dibbs_rfp`], [`Client::get_dibbs_award`].
399#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
400#[non_exhaustive]
401pub struct GetDibbsOptions {
402    /// Shape selector. When empty, the server returns its default detail shape.
403    #[builder(into)]
404    pub shape: Option<String>,
405    /// Flatten nested objects into dot-separated keys.
406    #[builder(default)]
407    pub flat: bool,
408    /// When `flat=true`, also flatten list-valued nested fields.
409    #[builder(default)]
410    pub flat_lists: bool,
411}
412
413impl GetDibbsOptions {
414    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
415        let mut q = Vec::new();
416        push_opt(&mut q, "shape", self.shape.as_deref());
417        if self.flat {
418            q.push(("flat".into(), "true".into()));
419        }
420        if self.flat_lists {
421            q.push(("flat_lists".into(), "true".into()));
422        }
423        q
424    }
425}
426
427impl Client {
428    /// `GET /api/dibbs/rfqs/` — one page of DIBBS request-for-quote line records.
429    pub async fn list_dibbs_rfqs(&self, opts: ListDibbsRfqsOptions) -> Result<Page<Record>> {
430        let q = opts.to_query();
431        self.get_page("/api/dibbs/rfqs/", &q).await
432    }
433
434    /// `GET /api/dibbs/rfqs/{uuid}/` — a single DIBBS request-for-quote line.
435    pub async fn get_dibbs_rfq(&self, uuid: &str, opts: Option<GetDibbsOptions>) -> Result<Record> {
436        if uuid.is_empty() {
437            return Err(Error::Validation {
438                message: "get_dibbs_rfq: uuid is required".into(),
439                response: None,
440            });
441        }
442        let q = opts.unwrap_or_default().to_query();
443        let path = format!("/api/dibbs/rfqs/{}/", urlencoding(uuid));
444        self.get_json::<Record>(&path, &q).await
445    }
446
447    /// Stream every DIBBS request-for-quote line matching `opts`.
448    pub fn iterate_dibbs_rfqs(&self, opts: ListDibbsRfqsOptions) -> PageStream<Record> {
449        let opts = Arc::new(opts);
450        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
451            let mut next = (*opts).clone();
452            next.page = page;
453            next.cursor = cursor;
454            Box::pin(async move { client.list_dibbs_rfqs(next).await })
455        });
456        PageStream::new(self.clone(), fetch)
457    }
458
459    /// `GET /api/dibbs/rfps/` — one page of DIBBS request-for-proposal records.
460    pub async fn list_dibbs_rfps(&self, opts: ListDibbsRfpsOptions) -> Result<Page<Record>> {
461        let q = opts.to_query();
462        self.get_page("/api/dibbs/rfps/", &q).await
463    }
464
465    /// `GET /api/dibbs/rfps/{uuid}/` — a single DIBBS request-for-proposal.
466    pub async fn get_dibbs_rfp(&self, uuid: &str, opts: Option<GetDibbsOptions>) -> Result<Record> {
467        if uuid.is_empty() {
468            return Err(Error::Validation {
469                message: "get_dibbs_rfp: uuid is required".into(),
470                response: None,
471            });
472        }
473        let q = opts.unwrap_or_default().to_query();
474        let path = format!("/api/dibbs/rfps/{}/", urlencoding(uuid));
475        self.get_json::<Record>(&path, &q).await
476    }
477
478    /// Stream every DIBBS request-for-proposal matching `opts`.
479    pub fn iterate_dibbs_rfps(&self, opts: ListDibbsRfpsOptions) -> PageStream<Record> {
480        let opts = Arc::new(opts);
481        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
482            let mut next = (*opts).clone();
483            next.page = page;
484            next.cursor = cursor;
485            Box::pin(async move { client.list_dibbs_rfps(next).await })
486        });
487        PageStream::new(self.clone(), fetch)
488    }
489
490    /// `GET /api/dibbs/awards/` — one page of DIBBS award line item records.
491    pub async fn list_dibbs_awards(&self, opts: ListDibbsAwardsOptions) -> Result<Page<Record>> {
492        let q = opts.to_query();
493        self.get_page("/api/dibbs/awards/", &q).await
494    }
495
496    /// `GET /api/dibbs/awards/{uuid}/` — a single DIBBS award line item.
497    pub async fn get_dibbs_award(
498        &self,
499        uuid: &str,
500        opts: Option<GetDibbsOptions>,
501    ) -> Result<Record> {
502        if uuid.is_empty() {
503            return Err(Error::Validation {
504                message: "get_dibbs_award: uuid is required".into(),
505                response: None,
506            });
507        }
508        let q = opts.unwrap_or_default().to_query();
509        let path = format!("/api/dibbs/awards/{}/", urlencoding(uuid));
510        self.get_json::<Record>(&path, &q).await
511    }
512
513    /// Stream every DIBBS award line item matching `opts`.
514    pub fn iterate_dibbs_awards(&self, opts: ListDibbsAwardsOptions) -> PageStream<Record> {
515        let opts = Arc::new(opts);
516        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
517            let mut next = (*opts).clone();
518            next.page = page;
519            next.cursor = cursor;
520            Box::pin(async move { client.list_dibbs_awards(next).await })
521        });
522        PageStream::new(self.clone(), fetch)
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
531        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
532    }
533
534    #[test]
535    fn list_dibbs_rfqs_all_filters_emit() {
536        let opts = ListDibbsRfqsOptions::builder()
537            .open(true)
538            .nsn("v-nsn")
539            .part_number("v-part_number")
540            .solicitation("v-solicitation")
541            .purchase_request("v-purchase_request")
542            .set_aside("v-set_aside")
543            .status_code("v-status_code")
544            .organization("v-organization")
545            .quantity_min("v-quantity_min")
546            .quantity_max("v-quantity_max")
547            .return_by_date_after("v-return_by_date_after")
548            .return_by_date_before("v-return_by_date_before")
549            .issue_date_after("v-issue_date_after")
550            .issue_date_before("v-issue_date_before")
551            .search("v-search")
552            .ordering("v-ordering")
553            .build();
554        let q = opts.to_query();
555        assert_eq!(get_q(&q, "open").as_deref(), Some("true"));
556        assert_eq!(get_q(&q, "nsn").as_deref(), Some("v-nsn"));
557        assert_eq!(get_q(&q, "part_number").as_deref(), Some("v-part_number"));
558        assert_eq!(get_q(&q, "solicitation").as_deref(), Some("v-solicitation"));
559        assert_eq!(
560            get_q(&q, "purchase_request").as_deref(),
561            Some("v-purchase_request")
562        );
563        assert_eq!(get_q(&q, "set_aside").as_deref(), Some("v-set_aside"));
564        assert_eq!(get_q(&q, "status_code").as_deref(), Some("v-status_code"));
565        assert_eq!(get_q(&q, "organization").as_deref(), Some("v-organization"));
566        assert_eq!(get_q(&q, "quantity_min").as_deref(), Some("v-quantity_min"));
567        assert_eq!(get_q(&q, "quantity_max").as_deref(), Some("v-quantity_max"));
568        assert_eq!(
569            get_q(&q, "return_by_date_after").as_deref(),
570            Some("v-return_by_date_after")
571        );
572        assert_eq!(
573            get_q(&q, "return_by_date_before").as_deref(),
574            Some("v-return_by_date_before")
575        );
576        assert_eq!(
577            get_q(&q, "issue_date_after").as_deref(),
578            Some("v-issue_date_after")
579        );
580        assert_eq!(
581            get_q(&q, "issue_date_before").as_deref(),
582            Some("v-issue_date_before")
583        );
584        assert_eq!(get_q(&q, "search").as_deref(), Some("v-search"));
585        assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering"));
586        assert_eq!(q.len(), 16);
587    }
588
589    #[test]
590    fn list_dibbs_rfqs_open_false_is_a_filter_not_an_absence() {
591        let opts = ListDibbsRfqsOptions::builder().open(false).build();
592        assert_eq!(get_q(&opts.to_query(), "open").as_deref(), Some("false"));
593    }
594
595    #[test]
596    fn list_dibbs_rfqs_zero_value_omitted() {
597        assert!(ListDibbsRfqsOptions::builder()
598            .build()
599            .to_query()
600            .is_empty());
601    }
602
603    #[test]
604    fn list_dibbs_rfqs_cursor_wins_over_page_and_shape_emits() {
605        let opts = ListDibbsRfqsOptions::builder()
606            .page(3u32)
607            .cursor("c0".to_string())
608            .shape(crate::SHAPE_DIBBS_RFQS_MINIMAL)
609            .build();
610        let q = opts.to_query();
611        assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0"));
612        assert_eq!(get_q(&q, "page"), None);
613        assert_eq!(
614            get_q(&q, "shape").as_deref(),
615            Some(crate::SHAPE_DIBBS_RFQS_MINIMAL)
616        );
617    }
618
619    #[test]
620    fn list_dibbs_rfqs_extra_emits() {
621        let mut extra = BTreeMap::new();
622        extra.insert("custom_x".to_string(), "xv".to_string());
623        let q = ListDibbsRfqsOptions::builder()
624            .extra(extra)
625            .build()
626            .to_query();
627        assert!(q.contains(&("custom_x".into(), "xv".into())));
628    }
629
630    #[tokio::test]
631    async fn get_dibbs_rfq_validates_empty_uuid() {
632        let client = Client::builder().api_key("x").build().expect("client");
633        let err = client.get_dibbs_rfq("", None).await.unwrap_err();
634        match err {
635            Error::Validation { message, .. } => {
636                assert!(message.contains("uuid is required"));
637            }
638            other => panic!("expected Validation, got {other:?}"),
639        }
640    }
641
642    #[test]
643    fn list_dibbs_rfps_all_filters_emit() {
644        let opts = ListDibbsRfpsOptions::builder()
645            .open(true)
646            .nsn("v-nsn")
647            .part_number("v-part_number")
648            .solicitation("v-solicitation")
649            .buyer_code("v-buyer_code")
650            .organization("v-organization")
651            .issued_date_after("v-issued_date_after")
652            .issued_date_before("v-issued_date_before")
653            .closes_date_after("v-closes_date_after")
654            .closes_date_before("v-closes_date_before")
655            .search("v-search")
656            .ordering("v-ordering")
657            .build();
658        let q = opts.to_query();
659        assert_eq!(get_q(&q, "open").as_deref(), Some("true"));
660        assert_eq!(get_q(&q, "nsn").as_deref(), Some("v-nsn"));
661        assert_eq!(get_q(&q, "part_number").as_deref(), Some("v-part_number"));
662        assert_eq!(get_q(&q, "solicitation").as_deref(), Some("v-solicitation"));
663        assert_eq!(get_q(&q, "buyer_code").as_deref(), Some("v-buyer_code"));
664        assert_eq!(get_q(&q, "organization").as_deref(), Some("v-organization"));
665        assert_eq!(
666            get_q(&q, "issued_date_after").as_deref(),
667            Some("v-issued_date_after")
668        );
669        assert_eq!(
670            get_q(&q, "issued_date_before").as_deref(),
671            Some("v-issued_date_before")
672        );
673        assert_eq!(
674            get_q(&q, "closes_date_after").as_deref(),
675            Some("v-closes_date_after")
676        );
677        assert_eq!(
678            get_q(&q, "closes_date_before").as_deref(),
679            Some("v-closes_date_before")
680        );
681        assert_eq!(get_q(&q, "search").as_deref(), Some("v-search"));
682        assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering"));
683        assert_eq!(q.len(), 12);
684    }
685
686    #[test]
687    fn list_dibbs_rfps_open_false_is_a_filter_not_an_absence() {
688        let opts = ListDibbsRfpsOptions::builder().open(false).build();
689        assert_eq!(get_q(&opts.to_query(), "open").as_deref(), Some("false"));
690    }
691
692    #[test]
693    fn list_dibbs_rfps_zero_value_omitted() {
694        assert!(ListDibbsRfpsOptions::builder()
695            .build()
696            .to_query()
697            .is_empty());
698    }
699
700    #[test]
701    fn list_dibbs_rfps_cursor_wins_over_page_and_shape_emits() {
702        let opts = ListDibbsRfpsOptions::builder()
703            .page(3u32)
704            .cursor("c0".to_string())
705            .shape(crate::SHAPE_DIBBS_RFPS_MINIMAL)
706            .build();
707        let q = opts.to_query();
708        assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0"));
709        assert_eq!(get_q(&q, "page"), None);
710        assert_eq!(
711            get_q(&q, "shape").as_deref(),
712            Some(crate::SHAPE_DIBBS_RFPS_MINIMAL)
713        );
714    }
715
716    #[test]
717    fn list_dibbs_rfps_extra_emits() {
718        let mut extra = BTreeMap::new();
719        extra.insert("custom_x".to_string(), "xv".to_string());
720        let q = ListDibbsRfpsOptions::builder()
721            .extra(extra)
722            .build()
723            .to_query();
724        assert!(q.contains(&("custom_x".into(), "xv".into())));
725    }
726
727    #[tokio::test]
728    async fn get_dibbs_rfp_validates_empty_uuid() {
729        let client = Client::builder().api_key("x").build().expect("client");
730        let err = client.get_dibbs_rfp("", None).await.unwrap_err();
731        match err {
732            Error::Validation { message, .. } => {
733                assert!(message.contains("uuid is required"));
734            }
735            other => panic!("expected Validation, got {other:?}"),
736        }
737    }
738
739    #[test]
740    fn list_dibbs_awards_all_filters_emit() {
741        let opts = ListDibbsAwardsOptions::builder()
742            .nsn("v-nsn")
743            .part_number("v-part_number")
744            .solicitation("v-solicitation")
745            .award_number("v-award_number")
746            .delivery_order_number("v-delivery_order_number")
747            .purchase_request("v-purchase_request")
748            .awardee_cage("v-awardee_cage")
749            .entity("v-entity")
750            .organization("v-organization")
751            .award_date_after("v-award_date_after")
752            .award_date_before("v-award_date_before")
753            .posted_date_after("v-posted_date_after")
754            .posted_date_before("v-posted_date_before")
755            .total_contract_price_min("v-total_contract_price_min")
756            .total_contract_price_max("v-total_contract_price_max")
757            .search("v-search")
758            .ordering("v-ordering")
759            .build();
760        let q = opts.to_query();
761        assert_eq!(get_q(&q, "nsn").as_deref(), Some("v-nsn"));
762        assert_eq!(get_q(&q, "part_number").as_deref(), Some("v-part_number"));
763        assert_eq!(get_q(&q, "solicitation").as_deref(), Some("v-solicitation"));
764        assert_eq!(get_q(&q, "award_number").as_deref(), Some("v-award_number"));
765        assert_eq!(
766            get_q(&q, "delivery_order_number").as_deref(),
767            Some("v-delivery_order_number")
768        );
769        assert_eq!(
770            get_q(&q, "purchase_request").as_deref(),
771            Some("v-purchase_request")
772        );
773        assert_eq!(get_q(&q, "awardee_cage").as_deref(), Some("v-awardee_cage"));
774        assert_eq!(get_q(&q, "entity").as_deref(), Some("v-entity"));
775        assert_eq!(get_q(&q, "organization").as_deref(), Some("v-organization"));
776        assert_eq!(
777            get_q(&q, "award_date_after").as_deref(),
778            Some("v-award_date_after")
779        );
780        assert_eq!(
781            get_q(&q, "award_date_before").as_deref(),
782            Some("v-award_date_before")
783        );
784        assert_eq!(
785            get_q(&q, "posted_date_after").as_deref(),
786            Some("v-posted_date_after")
787        );
788        assert_eq!(
789            get_q(&q, "posted_date_before").as_deref(),
790            Some("v-posted_date_before")
791        );
792        assert_eq!(
793            get_q(&q, "total_contract_price_min").as_deref(),
794            Some("v-total_contract_price_min")
795        );
796        assert_eq!(
797            get_q(&q, "total_contract_price_max").as_deref(),
798            Some("v-total_contract_price_max")
799        );
800        assert_eq!(get_q(&q, "search").as_deref(), Some("v-search"));
801        assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering"));
802        assert_eq!(q.len(), 17);
803    }
804
805    #[test]
806    fn list_dibbs_awards_zero_value_omitted() {
807        assert!(ListDibbsAwardsOptions::builder()
808            .build()
809            .to_query()
810            .is_empty());
811    }
812
813    #[test]
814    fn list_dibbs_awards_cursor_wins_over_page_and_shape_emits() {
815        let opts = ListDibbsAwardsOptions::builder()
816            .page(3u32)
817            .cursor("c0".to_string())
818            .shape(crate::SHAPE_DIBBS_AWARDS_MINIMAL)
819            .build();
820        let q = opts.to_query();
821        assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0"));
822        assert_eq!(get_q(&q, "page"), None);
823        assert_eq!(
824            get_q(&q, "shape").as_deref(),
825            Some(crate::SHAPE_DIBBS_AWARDS_MINIMAL)
826        );
827    }
828
829    #[test]
830    fn list_dibbs_awards_extra_emits() {
831        let mut extra = BTreeMap::new();
832        extra.insert("custom_x".to_string(), "xv".to_string());
833        let q = ListDibbsAwardsOptions::builder()
834            .extra(extra)
835            .build()
836            .to_query();
837        assert!(q.contains(&("custom_x".into(), "xv".into())));
838    }
839
840    #[tokio::test]
841    async fn get_dibbs_award_validates_empty_uuid() {
842        let client = Client::builder().api_key("x").build().expect("client");
843        let err = client.get_dibbs_award("", None).await.unwrap_err();
844        match err {
845            Error::Validation { message, .. } => {
846                assert!(message.contains("uuid is required"));
847            }
848            other => panic!("expected Validation, got {other:?}"),
849        }
850    }
851
852    #[test]
853    fn get_options_emits() {
854        let q = GetDibbsOptions::builder()
855            .shape("a,b")
856            .flat(true)
857            .flat_lists(true)
858            .build()
859            .to_query();
860        assert_eq!(get_q(&q, "shape").as_deref(), Some("a,b"));
861        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
862        assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
863    }
864}