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        let bytes = self.get_bytes("/api/dibbs/rfqs/", &q).await?;
432        Page::decode(&bytes)
433    }
434
435    /// `GET /api/dibbs/rfqs/{uuid}/` — a single DIBBS request-for-quote line.
436    pub async fn get_dibbs_rfq(&self, uuid: &str, opts: Option<GetDibbsOptions>) -> Result<Record> {
437        if uuid.is_empty() {
438            return Err(Error::Validation {
439                message: "get_dibbs_rfq: uuid is required".into(),
440                response: None,
441            });
442        }
443        let q = opts.unwrap_or_default().to_query();
444        let path = format!("/api/dibbs/rfqs/{}/", urlencoding(uuid));
445        self.get_json::<Record>(&path, &q).await
446    }
447
448    /// Stream every DIBBS request-for-quote line matching `opts`.
449    pub fn iterate_dibbs_rfqs(&self, opts: ListDibbsRfqsOptions) -> PageStream<Record> {
450        let opts = Arc::new(opts);
451        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
452            let mut next = (*opts).clone();
453            next.page = page;
454            next.cursor = cursor;
455            Box::pin(async move { client.list_dibbs_rfqs(next).await })
456        });
457        PageStream::new(self.clone(), fetch)
458    }
459
460    /// `GET /api/dibbs/rfps/` — one page of DIBBS request-for-proposal records.
461    pub async fn list_dibbs_rfps(&self, opts: ListDibbsRfpsOptions) -> Result<Page<Record>> {
462        let q = opts.to_query();
463        let bytes = self.get_bytes("/api/dibbs/rfps/", &q).await?;
464        Page::decode(&bytes)
465    }
466
467    /// `GET /api/dibbs/rfps/{uuid}/` — a single DIBBS request-for-proposal.
468    pub async fn get_dibbs_rfp(&self, uuid: &str, opts: Option<GetDibbsOptions>) -> Result<Record> {
469        if uuid.is_empty() {
470            return Err(Error::Validation {
471                message: "get_dibbs_rfp: uuid is required".into(),
472                response: None,
473            });
474        }
475        let q = opts.unwrap_or_default().to_query();
476        let path = format!("/api/dibbs/rfps/{}/", urlencoding(uuid));
477        self.get_json::<Record>(&path, &q).await
478    }
479
480    /// Stream every DIBBS request-for-proposal matching `opts`.
481    pub fn iterate_dibbs_rfps(&self, opts: ListDibbsRfpsOptions) -> PageStream<Record> {
482        let opts = Arc::new(opts);
483        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
484            let mut next = (*opts).clone();
485            next.page = page;
486            next.cursor = cursor;
487            Box::pin(async move { client.list_dibbs_rfps(next).await })
488        });
489        PageStream::new(self.clone(), fetch)
490    }
491
492    /// `GET /api/dibbs/awards/` — one page of DIBBS award line item records.
493    pub async fn list_dibbs_awards(&self, opts: ListDibbsAwardsOptions) -> Result<Page<Record>> {
494        let q = opts.to_query();
495        let bytes = self.get_bytes("/api/dibbs/awards/", &q).await?;
496        Page::decode(&bytes)
497    }
498
499    /// `GET /api/dibbs/awards/{uuid}/` — a single DIBBS award line item.
500    pub async fn get_dibbs_award(
501        &self,
502        uuid: &str,
503        opts: Option<GetDibbsOptions>,
504    ) -> Result<Record> {
505        if uuid.is_empty() {
506            return Err(Error::Validation {
507                message: "get_dibbs_award: uuid is required".into(),
508                response: None,
509            });
510        }
511        let q = opts.unwrap_or_default().to_query();
512        let path = format!("/api/dibbs/awards/{}/", urlencoding(uuid));
513        self.get_json::<Record>(&path, &q).await
514    }
515
516    /// Stream every DIBBS award line item matching `opts`.
517    pub fn iterate_dibbs_awards(&self, opts: ListDibbsAwardsOptions) -> PageStream<Record> {
518        let opts = Arc::new(opts);
519        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
520            let mut next = (*opts).clone();
521            next.page = page;
522            next.cursor = cursor;
523            Box::pin(async move { client.list_dibbs_awards(next).await })
524        });
525        PageStream::new(self.clone(), fetch)
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
534        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
535    }
536
537    #[test]
538    fn list_dibbs_rfqs_all_filters_emit() {
539        let opts = ListDibbsRfqsOptions::builder()
540            .open(true)
541            .nsn("v-nsn")
542            .part_number("v-part_number")
543            .solicitation("v-solicitation")
544            .purchase_request("v-purchase_request")
545            .set_aside("v-set_aside")
546            .status_code("v-status_code")
547            .organization("v-organization")
548            .quantity_min("v-quantity_min")
549            .quantity_max("v-quantity_max")
550            .return_by_date_after("v-return_by_date_after")
551            .return_by_date_before("v-return_by_date_before")
552            .issue_date_after("v-issue_date_after")
553            .issue_date_before("v-issue_date_before")
554            .search("v-search")
555            .ordering("v-ordering")
556            .build();
557        let q = opts.to_query();
558        assert_eq!(get_q(&q, "open").as_deref(), Some("true"));
559        assert_eq!(get_q(&q, "nsn").as_deref(), Some("v-nsn"));
560        assert_eq!(get_q(&q, "part_number").as_deref(), Some("v-part_number"));
561        assert_eq!(get_q(&q, "solicitation").as_deref(), Some("v-solicitation"));
562        assert_eq!(
563            get_q(&q, "purchase_request").as_deref(),
564            Some("v-purchase_request")
565        );
566        assert_eq!(get_q(&q, "set_aside").as_deref(), Some("v-set_aside"));
567        assert_eq!(get_q(&q, "status_code").as_deref(), Some("v-status_code"));
568        assert_eq!(get_q(&q, "organization").as_deref(), Some("v-organization"));
569        assert_eq!(get_q(&q, "quantity_min").as_deref(), Some("v-quantity_min"));
570        assert_eq!(get_q(&q, "quantity_max").as_deref(), Some("v-quantity_max"));
571        assert_eq!(
572            get_q(&q, "return_by_date_after").as_deref(),
573            Some("v-return_by_date_after")
574        );
575        assert_eq!(
576            get_q(&q, "return_by_date_before").as_deref(),
577            Some("v-return_by_date_before")
578        );
579        assert_eq!(
580            get_q(&q, "issue_date_after").as_deref(),
581            Some("v-issue_date_after")
582        );
583        assert_eq!(
584            get_q(&q, "issue_date_before").as_deref(),
585            Some("v-issue_date_before")
586        );
587        assert_eq!(get_q(&q, "search").as_deref(), Some("v-search"));
588        assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering"));
589        assert_eq!(q.len(), 16);
590    }
591
592    #[test]
593    fn list_dibbs_rfqs_open_false_is_a_filter_not_an_absence() {
594        let opts = ListDibbsRfqsOptions::builder().open(false).build();
595        assert_eq!(get_q(&opts.to_query(), "open").as_deref(), Some("false"));
596    }
597
598    #[test]
599    fn list_dibbs_rfqs_zero_value_omitted() {
600        assert!(ListDibbsRfqsOptions::builder()
601            .build()
602            .to_query()
603            .is_empty());
604    }
605
606    #[test]
607    fn list_dibbs_rfqs_cursor_wins_over_page_and_shape_emits() {
608        let opts = ListDibbsRfqsOptions::builder()
609            .page(3u32)
610            .cursor("c0".to_string())
611            .shape(crate::SHAPE_DIBBS_RFQS_MINIMAL)
612            .build();
613        let q = opts.to_query();
614        assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0"));
615        assert_eq!(get_q(&q, "page"), None);
616        assert_eq!(
617            get_q(&q, "shape").as_deref(),
618            Some(crate::SHAPE_DIBBS_RFQS_MINIMAL)
619        );
620    }
621
622    #[test]
623    fn list_dibbs_rfqs_extra_emits() {
624        let mut extra = BTreeMap::new();
625        extra.insert("custom_x".to_string(), "xv".to_string());
626        let q = ListDibbsRfqsOptions::builder()
627            .extra(extra)
628            .build()
629            .to_query();
630        assert!(q.contains(&("custom_x".into(), "xv".into())));
631    }
632
633    #[tokio::test]
634    async fn get_dibbs_rfq_validates_empty_uuid() {
635        let client = Client::builder().api_key("x").build().expect("client");
636        let err = client.get_dibbs_rfq("", None).await.unwrap_err();
637        match err {
638            Error::Validation { message, .. } => {
639                assert!(message.contains("uuid is required"));
640            }
641            other => panic!("expected Validation, got {other:?}"),
642        }
643    }
644
645    #[test]
646    fn list_dibbs_rfps_all_filters_emit() {
647        let opts = ListDibbsRfpsOptions::builder()
648            .open(true)
649            .nsn("v-nsn")
650            .part_number("v-part_number")
651            .solicitation("v-solicitation")
652            .buyer_code("v-buyer_code")
653            .organization("v-organization")
654            .issued_date_after("v-issued_date_after")
655            .issued_date_before("v-issued_date_before")
656            .closes_date_after("v-closes_date_after")
657            .closes_date_before("v-closes_date_before")
658            .search("v-search")
659            .ordering("v-ordering")
660            .build();
661        let q = opts.to_query();
662        assert_eq!(get_q(&q, "open").as_deref(), Some("true"));
663        assert_eq!(get_q(&q, "nsn").as_deref(), Some("v-nsn"));
664        assert_eq!(get_q(&q, "part_number").as_deref(), Some("v-part_number"));
665        assert_eq!(get_q(&q, "solicitation").as_deref(), Some("v-solicitation"));
666        assert_eq!(get_q(&q, "buyer_code").as_deref(), Some("v-buyer_code"));
667        assert_eq!(get_q(&q, "organization").as_deref(), Some("v-organization"));
668        assert_eq!(
669            get_q(&q, "issued_date_after").as_deref(),
670            Some("v-issued_date_after")
671        );
672        assert_eq!(
673            get_q(&q, "issued_date_before").as_deref(),
674            Some("v-issued_date_before")
675        );
676        assert_eq!(
677            get_q(&q, "closes_date_after").as_deref(),
678            Some("v-closes_date_after")
679        );
680        assert_eq!(
681            get_q(&q, "closes_date_before").as_deref(),
682            Some("v-closes_date_before")
683        );
684        assert_eq!(get_q(&q, "search").as_deref(), Some("v-search"));
685        assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering"));
686        assert_eq!(q.len(), 12);
687    }
688
689    #[test]
690    fn list_dibbs_rfps_open_false_is_a_filter_not_an_absence() {
691        let opts = ListDibbsRfpsOptions::builder().open(false).build();
692        assert_eq!(get_q(&opts.to_query(), "open").as_deref(), Some("false"));
693    }
694
695    #[test]
696    fn list_dibbs_rfps_zero_value_omitted() {
697        assert!(ListDibbsRfpsOptions::builder()
698            .build()
699            .to_query()
700            .is_empty());
701    }
702
703    #[test]
704    fn list_dibbs_rfps_cursor_wins_over_page_and_shape_emits() {
705        let opts = ListDibbsRfpsOptions::builder()
706            .page(3u32)
707            .cursor("c0".to_string())
708            .shape(crate::SHAPE_DIBBS_RFPS_MINIMAL)
709            .build();
710        let q = opts.to_query();
711        assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0"));
712        assert_eq!(get_q(&q, "page"), None);
713        assert_eq!(
714            get_q(&q, "shape").as_deref(),
715            Some(crate::SHAPE_DIBBS_RFPS_MINIMAL)
716        );
717    }
718
719    #[test]
720    fn list_dibbs_rfps_extra_emits() {
721        let mut extra = BTreeMap::new();
722        extra.insert("custom_x".to_string(), "xv".to_string());
723        let q = ListDibbsRfpsOptions::builder()
724            .extra(extra)
725            .build()
726            .to_query();
727        assert!(q.contains(&("custom_x".into(), "xv".into())));
728    }
729
730    #[tokio::test]
731    async fn get_dibbs_rfp_validates_empty_uuid() {
732        let client = Client::builder().api_key("x").build().expect("client");
733        let err = client.get_dibbs_rfp("", None).await.unwrap_err();
734        match err {
735            Error::Validation { message, .. } => {
736                assert!(message.contains("uuid is required"));
737            }
738            other => panic!("expected Validation, got {other:?}"),
739        }
740    }
741
742    #[test]
743    fn list_dibbs_awards_all_filters_emit() {
744        let opts = ListDibbsAwardsOptions::builder()
745            .nsn("v-nsn")
746            .part_number("v-part_number")
747            .solicitation("v-solicitation")
748            .award_number("v-award_number")
749            .delivery_order_number("v-delivery_order_number")
750            .purchase_request("v-purchase_request")
751            .awardee_cage("v-awardee_cage")
752            .entity("v-entity")
753            .organization("v-organization")
754            .award_date_after("v-award_date_after")
755            .award_date_before("v-award_date_before")
756            .posted_date_after("v-posted_date_after")
757            .posted_date_before("v-posted_date_before")
758            .total_contract_price_min("v-total_contract_price_min")
759            .total_contract_price_max("v-total_contract_price_max")
760            .search("v-search")
761            .ordering("v-ordering")
762            .build();
763        let q = opts.to_query();
764        assert_eq!(get_q(&q, "nsn").as_deref(), Some("v-nsn"));
765        assert_eq!(get_q(&q, "part_number").as_deref(), Some("v-part_number"));
766        assert_eq!(get_q(&q, "solicitation").as_deref(), Some("v-solicitation"));
767        assert_eq!(get_q(&q, "award_number").as_deref(), Some("v-award_number"));
768        assert_eq!(
769            get_q(&q, "delivery_order_number").as_deref(),
770            Some("v-delivery_order_number")
771        );
772        assert_eq!(
773            get_q(&q, "purchase_request").as_deref(),
774            Some("v-purchase_request")
775        );
776        assert_eq!(get_q(&q, "awardee_cage").as_deref(), Some("v-awardee_cage"));
777        assert_eq!(get_q(&q, "entity").as_deref(), Some("v-entity"));
778        assert_eq!(get_q(&q, "organization").as_deref(), Some("v-organization"));
779        assert_eq!(
780            get_q(&q, "award_date_after").as_deref(),
781            Some("v-award_date_after")
782        );
783        assert_eq!(
784            get_q(&q, "award_date_before").as_deref(),
785            Some("v-award_date_before")
786        );
787        assert_eq!(
788            get_q(&q, "posted_date_after").as_deref(),
789            Some("v-posted_date_after")
790        );
791        assert_eq!(
792            get_q(&q, "posted_date_before").as_deref(),
793            Some("v-posted_date_before")
794        );
795        assert_eq!(
796            get_q(&q, "total_contract_price_min").as_deref(),
797            Some("v-total_contract_price_min")
798        );
799        assert_eq!(
800            get_q(&q, "total_contract_price_max").as_deref(),
801            Some("v-total_contract_price_max")
802        );
803        assert_eq!(get_q(&q, "search").as_deref(), Some("v-search"));
804        assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering"));
805        assert_eq!(q.len(), 17);
806    }
807
808    #[test]
809    fn list_dibbs_awards_zero_value_omitted() {
810        assert!(ListDibbsAwardsOptions::builder()
811            .build()
812            .to_query()
813            .is_empty());
814    }
815
816    #[test]
817    fn list_dibbs_awards_cursor_wins_over_page_and_shape_emits() {
818        let opts = ListDibbsAwardsOptions::builder()
819            .page(3u32)
820            .cursor("c0".to_string())
821            .shape(crate::SHAPE_DIBBS_AWARDS_MINIMAL)
822            .build();
823        let q = opts.to_query();
824        assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0"));
825        assert_eq!(get_q(&q, "page"), None);
826        assert_eq!(
827            get_q(&q, "shape").as_deref(),
828            Some(crate::SHAPE_DIBBS_AWARDS_MINIMAL)
829        );
830    }
831
832    #[test]
833    fn list_dibbs_awards_extra_emits() {
834        let mut extra = BTreeMap::new();
835        extra.insert("custom_x".to_string(), "xv".to_string());
836        let q = ListDibbsAwardsOptions::builder()
837            .extra(extra)
838            .build()
839            .to_query();
840        assert!(q.contains(&("custom_x".into(), "xv".into())));
841    }
842
843    #[tokio::test]
844    async fn get_dibbs_award_validates_empty_uuid() {
845        let client = Client::builder().api_key("x").build().expect("client");
846        let err = client.get_dibbs_award("", None).await.unwrap_err();
847        match err {
848            Error::Validation { message, .. } => {
849                assert!(message.contains("uuid is required"));
850            }
851            other => panic!("expected Validation, got {other:?}"),
852        }
853    }
854
855    #[test]
856    fn get_options_emits() {
857        let q = GetDibbsOptions::builder()
858            .shape("a,b")
859            .flat(true)
860            .flat_lists(true)
861            .build()
862            .to_query();
863        assert_eq!(get_q(&q, "shape").as_deref(), Some("a,b"));
864        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
865        assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
866    }
867}