1use 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#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
18#[non_exhaustive]
19pub struct ListDibbsRfqsOptions {
20 #[builder(into)]
22 pub page: Option<u32>,
23 #[builder(into)]
25 pub limit: Option<u32>,
26 #[builder(into)]
28 pub cursor: Option<String>,
29 #[builder(into)]
31 pub shape: Option<String>,
32 #[builder(default)]
34 pub flat: bool,
35 #[builder(default)]
37 pub flat_lists: bool,
38
39 pub open: Option<bool>,
41 #[builder(into)]
43 pub nsn: Option<String>,
44 #[builder(into)]
46 pub part_number: Option<String>,
47 #[builder(into)]
49 pub solicitation: Option<String>,
50 #[builder(into)]
52 pub purchase_request: Option<String>,
53 #[builder(into)]
55 pub set_aside: Option<String>,
56 #[builder(into)]
58 pub status_code: Option<String>,
59 #[builder(into)]
61 pub organization: Option<String>,
62 #[builder(into)]
64 pub quantity_min: Option<String>,
65 #[builder(into)]
67 pub quantity_max: Option<String>,
68 #[builder(into)]
70 pub return_by_date_after: Option<String>,
71 #[builder(into)]
73 pub return_by_date_before: Option<String>,
74 #[builder(into)]
76 pub issue_date_after: Option<String>,
77 #[builder(into)]
79 pub issue_date_before: Option<String>,
80 #[builder(into)]
82 pub search: Option<String>,
83 #[builder(into)]
85 pub ordering: Option<String>,
86
87 #[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#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
143#[non_exhaustive]
144pub struct ListDibbsRfpsOptions {
145 #[builder(into)]
147 pub page: Option<u32>,
148 #[builder(into)]
150 pub limit: Option<u32>,
151 #[builder(into)]
153 pub cursor: Option<String>,
154 #[builder(into)]
156 pub shape: Option<String>,
157 #[builder(default)]
159 pub flat: bool,
160 #[builder(default)]
162 pub flat_lists: bool,
163
164 pub open: Option<bool>,
166 #[builder(into)]
168 pub nsn: Option<String>,
169 #[builder(into)]
171 pub part_number: Option<String>,
172 #[builder(into)]
174 pub solicitation: Option<String>,
175 #[builder(into)]
177 pub buyer_code: Option<String>,
178 #[builder(into)]
180 pub organization: Option<String>,
181 #[builder(into)]
183 pub issued_date_after: Option<String>,
184 #[builder(into)]
186 pub issued_date_before: Option<String>,
187 #[builder(into)]
189 pub closes_date_after: Option<String>,
190 #[builder(into)]
192 pub closes_date_before: Option<String>,
193 #[builder(into)]
195 pub search: Option<String>,
196 #[builder(into)]
198 pub ordering: Option<String>,
199
200 #[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#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
258#[non_exhaustive]
259pub struct ListDibbsAwardsOptions {
260 #[builder(into)]
262 pub page: Option<u32>,
263 #[builder(into)]
265 pub limit: Option<u32>,
266 #[builder(into)]
268 pub cursor: Option<String>,
269 #[builder(into)]
271 pub shape: Option<String>,
272 #[builder(default)]
274 pub flat: bool,
275 #[builder(default)]
277 pub flat_lists: bool,
278
279 #[builder(into)]
281 pub nsn: Option<String>,
282 #[builder(into)]
284 pub part_number: Option<String>,
285 #[builder(into)]
287 pub solicitation: Option<String>,
288 #[builder(into)]
290 pub award_number: Option<String>,
291 #[builder(into)]
293 pub delivery_order_number: Option<String>,
294 #[builder(into)]
296 pub purchase_request: Option<String>,
297 #[builder(into)]
299 pub awardee_cage: Option<String>,
300 #[builder(into)]
302 pub entity: Option<String>,
303 #[builder(into)]
305 pub organization: Option<String>,
306 #[builder(into)]
308 pub award_date_after: Option<String>,
309 #[builder(into)]
311 pub award_date_before: Option<String>,
312 #[builder(into)]
314 pub posted_date_after: Option<String>,
315 #[builder(into)]
317 pub posted_date_before: Option<String>,
318 #[builder(into)]
320 pub total_contract_price_min: Option<String>,
321 #[builder(into)]
323 pub total_contract_price_max: Option<String>,
324 #[builder(into)]
326 pub search: Option<String>,
327 #[builder(into)]
329 pub ordering: Option<String>,
330
331 #[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#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
400#[non_exhaustive]
401pub struct GetDibbsOptions {
402 #[builder(into)]
404 pub shape: Option<String>,
405 #[builder(default)]
407 pub flat: bool,
408 #[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 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 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 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 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 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 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 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 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 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}