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