1use crate::client::Client;
5use crate::error::{Error, Result};
6use crate::internal::{apply_pagination, push_opt, push_opt_bool, ListOptions};
7use crate::pagination::{FetchFn, Page, PageStream};
8use crate::resources::agencies::urlencoding;
9use crate::Record;
10use bon::Builder;
11use std::collections::BTreeMap;
12use std::sync::Arc;
13
14#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
22#[non_exhaustive]
23pub struct ListOpportunitiesOptions {
24 #[builder(into)]
27 pub page: Option<u32>,
28 #[builder(into)]
30 pub limit: Option<u32>,
31 #[builder(into)]
33 pub cursor: Option<String>,
34 #[builder(into)]
36 pub shape: Option<String>,
37 #[builder(default)]
39 pub flat: bool,
40 #[builder(default)]
42 pub flat_lists: bool,
43
44 #[builder(into)]
47 pub active: Option<bool>,
48 #[builder(into)]
50 pub agency: Option<String>,
51 #[builder(into)]
53 pub first_notice_date_after: Option<String>,
54 #[builder(into)]
56 pub first_notice_date_before: Option<String>,
57 #[builder(into)]
59 pub last_notice_date_after: Option<String>,
60 #[builder(into)]
62 pub last_notice_date_before: Option<String>,
63 #[builder(into)]
65 pub naics: Option<String>,
66 #[builder(into)]
68 pub notice_type: Option<String>,
69 #[builder(into)]
71 pub ordering: Option<String>,
72 #[builder(into)]
74 pub place_of_performance: Option<String>,
75 #[builder(into)]
77 pub psc: Option<String>,
78 #[builder(into)]
80 pub response_deadline_after: Option<String>,
81 #[builder(into)]
83 pub response_deadline_before: Option<String>,
84 #[builder(into)]
86 pub search: Option<String>,
87 #[builder(into)]
89 pub set_aside: Option<String>,
90 #[builder(into)]
92 pub solicitation_number: Option<String>,
93
94 #[builder(into)]
96 pub opportunity_id: Option<String>,
97
98 #[builder(default)]
100 pub extra: BTreeMap<String, String>,
101}
102
103impl ListOpportunitiesOptions {
104 fn to_query(&self) -> Vec<(String, String)> {
105 let mut q = Vec::new();
106 apply_pagination(
107 &mut q,
108 self.page,
109 self.limit,
110 self.cursor.as_deref(),
111 self.shape.as_deref(),
112 self.flat,
113 self.flat_lists,
114 );
115 push_opt_bool(&mut q, "active", self.active);
116 push_opt(&mut q, "agency", self.agency.as_deref());
117 push_opt(
118 &mut q,
119 "first_notice_date_after",
120 self.first_notice_date_after.as_deref(),
121 );
122 push_opt(
123 &mut q,
124 "first_notice_date_before",
125 self.first_notice_date_before.as_deref(),
126 );
127 push_opt(
128 &mut q,
129 "last_notice_date_after",
130 self.last_notice_date_after.as_deref(),
131 );
132 push_opt(
133 &mut q,
134 "last_notice_date_before",
135 self.last_notice_date_before.as_deref(),
136 );
137 push_opt(&mut q, "naics", self.naics.as_deref());
138 push_opt(&mut q, "notice_type", self.notice_type.as_deref());
139 push_opt(&mut q, "ordering", self.ordering.as_deref());
140 push_opt(
141 &mut q,
142 "place_of_performance",
143 self.place_of_performance.as_deref(),
144 );
145 push_opt(&mut q, "psc", self.psc.as_deref());
146 push_opt(
147 &mut q,
148 "response_deadline_after",
149 self.response_deadline_after.as_deref(),
150 );
151 push_opt(
152 &mut q,
153 "response_deadline_before",
154 self.response_deadline_before.as_deref(),
155 );
156 push_opt(&mut q, "search", self.search.as_deref());
157 push_opt(&mut q, "opportunity_id", self.opportunity_id.as_deref());
158 push_opt(&mut q, "set_aside", self.set_aside.as_deref());
159 push_opt(
160 &mut q,
161 "solicitation_number",
162 self.solicitation_number.as_deref(),
163 );
164 for (k, v) in &self.extra {
165 if !v.is_empty() {
166 q.push((k.clone(), v.clone()));
167 }
168 }
169 q
170 }
171}
172
173#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
182#[non_exhaustive]
183pub struct ListNoticesOptions {
184 #[builder(into)]
186 pub page: Option<u32>,
187 #[builder(into)]
189 pub limit: Option<u32>,
190 #[builder(into)]
192 pub cursor: Option<String>,
193 #[builder(into)]
195 pub shape: Option<String>,
196 #[builder(default)]
198 pub flat: bool,
199 #[builder(default)]
201 pub flat_lists: bool,
202
203 #[builder(into)]
205 pub active: Option<bool>,
206 #[builder(into)]
208 pub agency: Option<String>,
209 #[builder(into)]
211 pub naics: Option<String>,
212 #[builder(into)]
214 pub notice_type: Option<String>,
215 #[builder(into)]
217 pub posted_date_after: Option<String>,
218 #[builder(into)]
220 pub posted_date_before: Option<String>,
221 #[builder(into)]
223 pub psc: Option<String>,
224 #[builder(into)]
226 pub response_deadline_after: Option<String>,
227 #[builder(into)]
229 pub response_deadline_before: Option<String>,
230 #[builder(into)]
232 pub search: Option<String>,
233 #[builder(into)]
235 pub set_aside: Option<String>,
236 #[builder(into)]
238 pub solicitation_number: Option<String>,
239
240 #[builder(into)]
242 pub notice_id: Option<String>,
243
244 #[builder(into)]
246 pub department: Option<String>,
247
248 #[builder(into)]
250 pub office: Option<String>,
251
252 #[builder(default)]
254 pub extra: BTreeMap<String, String>,
255}
256
257impl ListNoticesOptions {
258 fn to_query(&self) -> Vec<(String, String)> {
259 let mut q = Vec::new();
260 apply_pagination(
261 &mut q,
262 self.page,
263 self.limit,
264 self.cursor.as_deref(),
265 self.shape.as_deref(),
266 self.flat,
267 self.flat_lists,
268 );
269 push_opt_bool(&mut q, "active", self.active);
270 push_opt(&mut q, "agency", self.agency.as_deref());
271 push_opt(&mut q, "naics", self.naics.as_deref());
272 push_opt(&mut q, "notice_type", self.notice_type.as_deref());
273 push_opt(
274 &mut q,
275 "posted_date_after",
276 self.posted_date_after.as_deref(),
277 );
278 push_opt(
279 &mut q,
280 "posted_date_before",
281 self.posted_date_before.as_deref(),
282 );
283 push_opt(&mut q, "psc", self.psc.as_deref());
284 push_opt(
285 &mut q,
286 "response_deadline_after",
287 self.response_deadline_after.as_deref(),
288 );
289 push_opt(
290 &mut q,
291 "response_deadline_before",
292 self.response_deadline_before.as_deref(),
293 );
294 push_opt(&mut q, "search", self.search.as_deref());
295 push_opt(&mut q, "office", self.office.as_deref());
296 push_opt(&mut q, "department", self.department.as_deref());
297 push_opt(&mut q, "notice_id", self.notice_id.as_deref());
298 push_opt(&mut q, "set_aside", self.set_aside.as_deref());
299 push_opt(
300 &mut q,
301 "solicitation_number",
302 self.solicitation_number.as_deref(),
303 );
304 for (k, v) in &self.extra {
305 if !v.is_empty() {
306 q.push((k.clone(), v.clone()));
307 }
308 }
309 q
310 }
311}
312
313#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
320#[non_exhaustive]
321pub struct ListForecastsOptions {
322 #[builder(into)]
324 pub page: Option<u32>,
325 #[builder(into)]
327 pub limit: Option<u32>,
328 #[builder(into)]
330 pub cursor: Option<String>,
331 #[builder(into)]
333 pub shape: Option<String>,
334 #[builder(default)]
336 pub flat: bool,
337 #[builder(default)]
339 pub flat_lists: bool,
340
341 #[builder(into)]
343 pub agency: Option<String>,
344 #[builder(into)]
346 pub award_date_after: Option<String>,
347 #[builder(into)]
349 pub award_date_before: Option<String>,
350 #[builder(into)]
352 pub fiscal_year: Option<String>,
353 #[builder(into)]
355 pub fiscal_year_gte: Option<String>,
356 #[builder(into)]
358 pub fiscal_year_lte: Option<String>,
359 #[builder(into)]
361 pub modified_after: Option<String>,
362 #[builder(into)]
364 pub modified_before: Option<String>,
365 #[builder(into)]
367 pub naics_code: Option<String>,
368 #[builder(into)]
370 pub naics_starts_with: Option<String>,
371 #[builder(into)]
373 pub ordering: Option<String>,
374 #[builder(into)]
376 pub search: Option<String>,
377 #[builder(into)]
379 pub source_system: Option<String>,
380 #[builder(into)]
382 pub status: Option<String>,
383
384 #[builder(into)]
386 pub id: Option<String>,
387
388 #[builder(default)]
390 pub extra: BTreeMap<String, String>,
391}
392
393impl ListForecastsOptions {
394 fn to_query(&self) -> Vec<(String, String)> {
395 let mut q = Vec::new();
396 apply_pagination(
397 &mut q,
398 self.page,
399 self.limit,
400 self.cursor.as_deref(),
401 self.shape.as_deref(),
402 self.flat,
403 self.flat_lists,
404 );
405 push_opt(&mut q, "agency", self.agency.as_deref());
406 push_opt(&mut q, "award_date_after", self.award_date_after.as_deref());
407 push_opt(
408 &mut q,
409 "award_date_before",
410 self.award_date_before.as_deref(),
411 );
412 push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
413 push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
414 push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
415 push_opt(&mut q, "modified_after", self.modified_after.as_deref());
416 push_opt(&mut q, "modified_before", self.modified_before.as_deref());
417 push_opt(&mut q, "naics_code", self.naics_code.as_deref());
418 push_opt(
419 &mut q,
420 "naics_starts_with",
421 self.naics_starts_with.as_deref(),
422 );
423 push_opt(&mut q, "ordering", self.ordering.as_deref());
424 push_opt(&mut q, "search", self.search.as_deref());
425 push_opt(&mut q, "id", self.id.as_deref());
426 push_opt(&mut q, "source_system", self.source_system.as_deref());
427 push_opt(&mut q, "status", self.status.as_deref());
428 for (k, v) in &self.extra {
429 if !v.is_empty() {
430 q.push((k.clone(), v.clone()));
431 }
432 }
433 q
434 }
435}
436
437#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
444#[non_exhaustive]
445pub struct ListGrantsOptions {
446 #[builder(into)]
448 pub page: Option<u32>,
449 #[builder(into)]
451 pub limit: Option<u32>,
452 #[builder(into)]
454 pub cursor: Option<String>,
455 #[builder(into)]
457 pub shape: Option<String>,
458 #[builder(default)]
460 pub flat: bool,
461 #[builder(default)]
463 pub flat_lists: bool,
464
465 #[builder(into)]
467 pub agency: Option<String>,
468 #[builder(into)]
470 pub applicant_types: Option<String>,
471 #[builder(into)]
473 pub cfda_number: Option<String>,
474 #[builder(into)]
476 pub grant_id: Option<String>,
477 #[builder(into)]
479 pub funding_categories: Option<String>,
480 #[builder(into)]
482 pub funding_instruments: Option<String>,
483 #[builder(into)]
485 pub opportunity_number: Option<String>,
486 #[builder(into)]
488 pub ordering: Option<String>,
489 #[builder(into)]
491 pub posted_date_after: Option<String>,
492 #[builder(into)]
494 pub posted_date_before: Option<String>,
495 #[builder(into)]
497 pub response_date_after: Option<String>,
498 #[builder(into)]
500 pub response_date_before: Option<String>,
501 #[builder(into)]
503 pub search: Option<String>,
504 #[builder(into)]
506 pub status: Option<String>,
507
508 #[builder(default)]
510 pub extra: BTreeMap<String, String>,
511}
512
513impl ListGrantsOptions {
514 fn to_query(&self) -> Vec<(String, String)> {
515 let mut q = Vec::new();
516 apply_pagination(
517 &mut q,
518 self.page,
519 self.limit,
520 self.cursor.as_deref(),
521 self.shape.as_deref(),
522 self.flat,
523 self.flat_lists,
524 );
525 push_opt(&mut q, "agency", self.agency.as_deref());
526 push_opt(&mut q, "applicant_types", self.applicant_types.as_deref());
527 push_opt(&mut q, "cfda_number", self.cfda_number.as_deref());
528 push_opt(&mut q, "grant_id", self.grant_id.as_deref());
529 push_opt(
530 &mut q,
531 "funding_categories",
532 self.funding_categories.as_deref(),
533 );
534 push_opt(
535 &mut q,
536 "funding_instruments",
537 self.funding_instruments.as_deref(),
538 );
539 push_opt(
540 &mut q,
541 "opportunity_number",
542 self.opportunity_number.as_deref(),
543 );
544 push_opt(&mut q, "ordering", self.ordering.as_deref());
545 push_opt(
546 &mut q,
547 "posted_date_after",
548 self.posted_date_after.as_deref(),
549 );
550 push_opt(
551 &mut q,
552 "posted_date_before",
553 self.posted_date_before.as_deref(),
554 );
555 push_opt(
556 &mut q,
557 "response_date_after",
558 self.response_date_after.as_deref(),
559 );
560 push_opt(
561 &mut q,
562 "response_date_before",
563 self.response_date_before.as_deref(),
564 );
565 push_opt(&mut q, "search", self.search.as_deref());
566 push_opt(&mut q, "status", self.status.as_deref());
567 for (k, v) in &self.extra {
568 if !v.is_empty() {
569 q.push((k.clone(), v.clone()));
570 }
571 }
572 q
573 }
574}
575
576impl Client {
581 pub async fn list_opportunities(&self, opts: ListOpportunitiesOptions) -> Result<Page<Record>> {
583 let q = opts.to_query();
584 let bytes = self.get_bytes("/api/opportunities/", &q).await?;
585 Page::decode(&bytes)
586 }
587
588 pub fn iterate_opportunities(&self, opts: ListOpportunitiesOptions) -> PageStream<Record> {
590 let opts = Arc::new(opts);
591 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
592 let mut next = (*opts).clone();
593 next.page = page;
594 next.cursor = cursor;
595 Box::pin(async move { client.list_opportunities(next).await })
596 });
597 PageStream::new(self.clone(), fetch)
598 }
599
600 pub async fn list_notices(&self, opts: ListNoticesOptions) -> Result<Page<Record>> {
602 let q = opts.to_query();
603 let bytes = self.get_bytes("/api/notices/", &q).await?;
604 Page::decode(&bytes)
605 }
606
607 pub fn iterate_notices(&self, opts: ListNoticesOptions) -> PageStream<Record> {
609 let opts = Arc::new(opts);
610 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
611 let mut next = (*opts).clone();
612 next.page = page;
613 next.cursor = cursor;
614 Box::pin(async move { client.list_notices(next).await })
615 });
616 PageStream::new(self.clone(), fetch)
617 }
618
619 pub async fn list_forecasts(&self, opts: ListForecastsOptions) -> Result<Page<Record>> {
621 let q = opts.to_query();
622 let bytes = self.get_bytes("/api/forecasts/", &q).await?;
623 Page::decode(&bytes)
624 }
625
626 pub fn iterate_forecasts(&self, opts: ListForecastsOptions) -> PageStream<Record> {
628 let opts = Arc::new(opts);
629 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
630 let mut next = (*opts).clone();
631 next.page = page;
632 next.cursor = cursor;
633 Box::pin(async move { client.list_forecasts(next).await })
634 });
635 PageStream::new(self.clone(), fetch)
636 }
637
638 pub async fn list_grants(&self, opts: ListGrantsOptions) -> Result<Page<Record>> {
640 let q = opts.to_query();
641 let bytes = self.get_bytes("/api/grants/", &q).await?;
642 Page::decode(&bytes)
643 }
644
645 pub fn iterate_grants(&self, opts: ListGrantsOptions) -> PageStream<Record> {
647 let opts = Arc::new(opts);
648 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
649 let mut next = (*opts).clone();
650 next.page = page;
651 next.cursor = cursor;
652 Box::pin(async move { client.list_grants(next).await })
653 });
654 PageStream::new(self.clone(), fetch)
655 }
656
657 pub async fn get_opportunity(
659 &self,
660 opportunity_id: &str,
661 opts: Option<ListOptions>,
662 ) -> Result<Record> {
663 if opportunity_id.is_empty() {
664 return Err(Error::Validation {
665 message: "get_opportunity: opportunity_id is required".into(),
666 response: None,
667 });
668 }
669 let mut q = Vec::new();
670 opts.unwrap_or_default().apply(&mut q);
671 let path = format!("/api/opportunities/{}/", urlencoding(opportunity_id));
672 self.get_json::<Record>(&path, &q).await
673 }
674
675 pub async fn get_notice(&self, notice_id: &str, opts: Option<ListOptions>) -> Result<Record> {
677 if notice_id.is_empty() {
678 return Err(Error::Validation {
679 message: "get_notice: notice_id is required".into(),
680 response: None,
681 });
682 }
683 let mut q = Vec::new();
684 opts.unwrap_or_default().apply(&mut q);
685 let path = format!("/api/notices/{}/", urlencoding(notice_id));
686 self.get_json::<Record>(&path, &q).await
687 }
688
689 pub async fn get_forecast(&self, id: &str, opts: Option<ListOptions>) -> Result<Record> {
691 if id.is_empty() {
692 return Err(Error::Validation {
693 message: "get_forecast: id is required".into(),
694 response: None,
695 });
696 }
697 let mut q = Vec::new();
698 opts.unwrap_or_default().apply(&mut q);
699 let path = format!("/api/forecasts/{}/", urlencoding(id));
700 self.get_json::<Record>(&path, &q).await
701 }
702
703 pub async fn get_grant(&self, grant_id: &str, opts: Option<ListOptions>) -> Result<Record> {
705 if grant_id.is_empty() {
706 return Err(Error::Validation {
707 message: "get_grant: grant_id is required".into(),
708 response: None,
709 });
710 }
711 let mut q = Vec::new();
712 opts.unwrap_or_default().apply(&mut q);
713 let path = format!("/api/grants/{}/", urlencoding(grant_id));
714 self.get_json::<Record>(&path, &q).await
715 }
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721
722 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
723 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
724 }
725
726 #[test]
727 fn opportunities_emits_all_string_filters() {
728 let opts = ListOpportunitiesOptions::builder()
729 .active(true)
730 .agency("9700")
731 .first_notice_date_after("2024-01-01")
732 .last_notice_date_before("2024-12-31")
733 .naics("541512")
734 .notice_type("PRESOL")
735 .ordering("-response_deadline")
736 .place_of_performance("VA")
737 .psc("D302")
738 .response_deadline_after("2024-01-15")
739 .response_deadline_before("2024-03-31")
740 .search("cloud computing")
741 .set_aside("8A")
742 .solicitation_number("W15P7T24R0001")
743 .build();
744 let q = opts.to_query();
745 assert_eq!(get_q(&q, "active").as_deref(), Some("true"));
746 assert_eq!(get_q(&q, "agency").as_deref(), Some("9700"));
747 assert_eq!(
748 get_q(&q, "first_notice_date_after").as_deref(),
749 Some("2024-01-01")
750 );
751 assert_eq!(
752 get_q(&q, "last_notice_date_before").as_deref(),
753 Some("2024-12-31")
754 );
755 assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
756 assert_eq!(get_q(&q, "notice_type").as_deref(), Some("PRESOL"));
757 assert_eq!(get_q(&q, "ordering").as_deref(), Some("-response_deadline"));
758 assert_eq!(get_q(&q, "place_of_performance").as_deref(), Some("VA"));
759 assert_eq!(get_q(&q, "psc").as_deref(), Some("D302"));
760 assert_eq!(get_q(&q, "search").as_deref(), Some("cloud computing"));
761 assert_eq!(get_q(&q, "set_aside").as_deref(), Some("8A"));
762 assert_eq!(
763 get_q(&q, "solicitation_number").as_deref(),
764 Some("W15P7T24R0001")
765 );
766 }
767
768 #[test]
769 fn opportunities_active_false_emits_false() {
770 let opts = ListOpportunitiesOptions::builder().active(false).build();
771 let q = opts.to_query();
772 assert_eq!(get_q(&q, "active").as_deref(), Some("false"));
773 }
774
775 #[test]
776 fn opportunities_active_none_omits() {
777 let opts = ListOpportunitiesOptions::default();
778 let q = opts.to_query();
779 assert!(!q.iter().any(|(k, _)| k == "active"));
780 }
781
782 #[test]
783 fn notices_emits_filters_without_ordering_field() {
784 let opts = ListNoticesOptions::builder()
785 .active(true)
786 .agency("9700")
787 .naics("541512")
788 .notice_type("AWARD")
789 .posted_date_after("2024-01-01")
790 .posted_date_before("2024-12-31")
791 .search("cybersecurity")
792 .build();
793 let q = opts.to_query();
794 assert_eq!(get_q(&q, "active").as_deref(), Some("true"));
795 assert_eq!(get_q(&q, "agency").as_deref(), Some("9700"));
796 assert_eq!(get_q(&q, "notice_type").as_deref(), Some("AWARD"));
797 assert_eq!(get_q(&q, "search").as_deref(), Some("cybersecurity"));
798 }
799
800 #[test]
801 fn forecasts_emits_all_filters() {
802 let opts = ListForecastsOptions::builder()
803 .agency("9700")
804 .fiscal_year("2024")
805 .fiscal_year_gte("2023")
806 .fiscal_year_lte("2025")
807 .naics_code("541512")
808 .naics_starts_with("5415")
809 .ordering("award_date")
810 .source_system("SAM")
811 .status("active")
812 .build();
813 let q = opts.to_query();
814 assert_eq!(get_q(&q, "agency").as_deref(), Some("9700"));
815 assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024"));
816 assert_eq!(get_q(&q, "naics_starts_with").as_deref(), Some("5415"));
817 assert_eq!(get_q(&q, "source_system").as_deref(), Some("SAM"));
818 assert_eq!(get_q(&q, "status").as_deref(), Some("active"));
819 }
820
821 #[test]
822 fn grants_emits_all_filters() {
823 let opts = ListGrantsOptions::builder()
824 .agency("9700")
825 .applicant_types("11")
826 .cfda_number("10.001")
827 .grant_id("GRANT-123")
828 .funding_categories("AR")
829 .funding_instruments("G")
830 .opportunity_number("OPP-001")
831 .ordering("response_date")
832 .posted_date_after("2024-01-01")
833 .response_date_before("2024-11-30")
834 .search("environment")
835 .status("posted")
836 .build();
837 let q = opts.to_query();
838 assert_eq!(get_q(&q, "applicant_types").as_deref(), Some("11"));
839 assert_eq!(get_q(&q, "cfda_number").as_deref(), Some("10.001"));
840 assert_eq!(get_q(&q, "grant_id").as_deref(), Some("GRANT-123"));
841 assert_eq!(get_q(&q, "funding_categories").as_deref(), Some("AR"));
842 assert_eq!(get_q(&q, "funding_instruments").as_deref(), Some("G"));
843 assert_eq!(get_q(&q, "opportunity_number").as_deref(), Some("OPP-001"));
844 assert_eq!(get_q(&q, "status").as_deref(), Some("posted"));
845 }
846
847 #[tokio::test]
848 async fn get_grant_empty_key_returns_validation() {
849 let client = Client::builder().api_key("x").build().expect("build");
850 let err = client.get_grant("", None).await.expect_err("must error");
851 match err {
852 Error::Validation { message, .. } => assert!(message.contains("grant_id")),
853 other => panic!("expected Validation, got {other:?}"),
854 }
855 }
856
857 #[tokio::test]
858 async fn get_opportunity_empty_key_returns_validation() {
859 let client = Client::builder().api_key("x").build().expect("build");
860 let err = client
861 .get_opportunity("", None)
862 .await
863 .expect_err("must error");
864 match err {
865 Error::Validation { message, .. } => assert!(message.contains("opportunity_id")),
866 other => panic!("expected Validation, got {other:?}"),
867 }
868 }
869
870 #[tokio::test]
871 async fn get_forecast_empty_key_returns_validation() {
872 let client = Client::builder().api_key("x").build().expect("build");
873 let err = client.get_forecast("", None).await.expect_err("must error");
874 match err {
875 Error::Validation { message, .. } => assert!(message.contains("id")),
876 other => panic!("expected Validation, got {other:?}"),
877 }
878 }
879
880 #[tokio::test]
881 async fn get_notice_empty_key_returns_validation() {
882 let client = Client::builder().api_key("x").build().expect("build");
883 let err = client.get_notice("", None).await.expect_err("must error");
884 match err {
885 Error::Validation { message, .. } => assert!(message.contains("notice_id")),
886 other => panic!("expected Validation, got {other:?}"),
887 }
888 }
889
890 #[test]
891 fn identifier_and_org_filters_emit() {
892 let q = ListNoticesOptions::builder()
893 .notice_id("n1")
894 .department("DOD")
895 .office("W912")
896 .build()
897 .to_query();
898 assert_eq!(get_q(&q, "notice_id").as_deref(), Some("n1"));
899 assert_eq!(get_q(&q, "department").as_deref(), Some("DOD"));
900 assert_eq!(get_q(&q, "office").as_deref(), Some("W912"));
901 let q = ListOpportunitiesOptions::builder()
902 .opportunity_id("o1")
903 .build()
904 .to_query();
905 assert_eq!(get_q(&q, "opportunity_id").as_deref(), Some("o1"));
906 let q = ListForecastsOptions::builder().id("42").build().to_query();
907 assert_eq!(get_q(&q, "id").as_deref(), Some("42"));
908 }
909}