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 pub awarded: Option<bool>,
50 #[builder(into)]
52 pub awardee_uei: Option<String>,
53 #[builder(into)]
55 pub agency: Option<String>,
56 #[builder(into)]
58 pub first_notice_date_after: Option<String>,
59 #[builder(into)]
61 pub first_notice_date_before: Option<String>,
62 #[builder(into)]
64 pub last_notice_date_after: Option<String>,
65 #[builder(into)]
67 pub last_notice_date_before: Option<String>,
68 #[builder(into)]
70 pub naics: Option<String>,
71 #[builder(into)]
73 pub notice_type: Option<String>,
74 #[builder(into)]
76 pub ordering: Option<String>,
77 #[builder(into)]
79 pub place_of_performance: Option<String>,
80 #[builder(into)]
82 pub psc: Option<String>,
83 #[builder(into)]
85 pub response_deadline_after: Option<String>,
86 #[builder(into)]
88 pub response_deadline_before: Option<String>,
89 #[builder(into)]
91 pub search: Option<String>,
92 #[builder(into)]
94 pub set_aside: Option<String>,
95 #[builder(into)]
97 pub solicitation_number: Option<String>,
98
99 #[builder(into)]
101 pub opportunity_id: Option<String>,
102
103 #[builder(default)]
105 pub extra: BTreeMap<String, String>,
106}
107
108impl ListOpportunitiesOptions {
109 fn to_query(&self) -> Vec<(String, String)> {
110 let mut q = Vec::new();
111 apply_pagination(
112 &mut q,
113 self.page,
114 self.limit,
115 self.cursor.as_deref(),
116 self.shape.as_deref(),
117 self.flat,
118 self.flat_lists,
119 );
120 push_opt_bool(&mut q, "active", self.active);
121 push_opt_bool(&mut q, "awarded", self.awarded);
122 push_opt(&mut q, "awardee_uei", self.awardee_uei.as_deref());
123 push_opt(&mut q, "agency", self.agency.as_deref());
124 push_opt(
125 &mut q,
126 "first_notice_date_after",
127 self.first_notice_date_after.as_deref(),
128 );
129 push_opt(
130 &mut q,
131 "first_notice_date_before",
132 self.first_notice_date_before.as_deref(),
133 );
134 push_opt(
135 &mut q,
136 "last_notice_date_after",
137 self.last_notice_date_after.as_deref(),
138 );
139 push_opt(
140 &mut q,
141 "last_notice_date_before",
142 self.last_notice_date_before.as_deref(),
143 );
144 push_opt(&mut q, "naics", self.naics.as_deref());
145 push_opt(&mut q, "notice_type", self.notice_type.as_deref());
146 push_opt(&mut q, "ordering", self.ordering.as_deref());
147 push_opt(
148 &mut q,
149 "place_of_performance",
150 self.place_of_performance.as_deref(),
151 );
152 push_opt(&mut q, "psc", self.psc.as_deref());
153 push_opt(
154 &mut q,
155 "response_deadline_after",
156 self.response_deadline_after.as_deref(),
157 );
158 push_opt(
159 &mut q,
160 "response_deadline_before",
161 self.response_deadline_before.as_deref(),
162 );
163 push_opt(&mut q, "search", self.search.as_deref());
164 push_opt(&mut q, "opportunity_id", self.opportunity_id.as_deref());
165 push_opt(&mut q, "set_aside", self.set_aside.as_deref());
166 push_opt(
167 &mut q,
168 "solicitation_number",
169 self.solicitation_number.as_deref(),
170 );
171 for (k, v) in &self.extra {
172 if !v.is_empty() {
173 q.push((k.clone(), v.clone()));
174 }
175 }
176 q
177 }
178}
179
180#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
189#[non_exhaustive]
190pub struct ListNoticesOptions {
191 #[builder(into)]
193 pub page: Option<u32>,
194 #[builder(into)]
196 pub limit: Option<u32>,
197 #[builder(into)]
199 pub cursor: Option<String>,
200 #[builder(into)]
202 pub shape: Option<String>,
203 #[builder(default)]
205 pub flat: bool,
206 #[builder(default)]
208 pub flat_lists: bool,
209
210 #[builder(into)]
212 pub active: Option<bool>,
213 #[builder(into)]
215 pub agency: Option<String>,
216 #[builder(into)]
218 pub naics: Option<String>,
219 #[builder(into)]
221 pub notice_type: Option<String>,
222 #[builder(into)]
224 pub posted_date_after: Option<String>,
225 #[builder(into)]
227 pub posted_date_before: Option<String>,
228 #[builder(into)]
230 pub psc: Option<String>,
231 #[builder(into)]
233 pub response_deadline_after: Option<String>,
234 #[builder(into)]
236 pub response_deadline_before: Option<String>,
237 #[builder(into)]
239 pub search: Option<String>,
240 #[builder(into)]
242 pub set_aside: Option<String>,
243 #[builder(into)]
245 pub solicitation_number: Option<String>,
246
247 #[builder(into)]
249 pub notice_id: Option<String>,
250
251 #[builder(into)]
253 pub department: Option<String>,
254
255 #[builder(into)]
257 pub office: Option<String>,
258
259 #[builder(default)]
261 pub extra: BTreeMap<String, String>,
262}
263
264impl ListNoticesOptions {
265 fn to_query(&self) -> Vec<(String, String)> {
266 let mut q = Vec::new();
267 apply_pagination(
268 &mut q,
269 self.page,
270 self.limit,
271 self.cursor.as_deref(),
272 self.shape.as_deref(),
273 self.flat,
274 self.flat_lists,
275 );
276 push_opt_bool(&mut q, "active", self.active);
277 push_opt(&mut q, "agency", self.agency.as_deref());
278 push_opt(&mut q, "naics", self.naics.as_deref());
279 push_opt(&mut q, "notice_type", self.notice_type.as_deref());
280 push_opt(
281 &mut q,
282 "posted_date_after",
283 self.posted_date_after.as_deref(),
284 );
285 push_opt(
286 &mut q,
287 "posted_date_before",
288 self.posted_date_before.as_deref(),
289 );
290 push_opt(&mut q, "psc", self.psc.as_deref());
291 push_opt(
292 &mut q,
293 "response_deadline_after",
294 self.response_deadline_after.as_deref(),
295 );
296 push_opt(
297 &mut q,
298 "response_deadline_before",
299 self.response_deadline_before.as_deref(),
300 );
301 push_opt(&mut q, "search", self.search.as_deref());
302 push_opt(&mut q, "office", self.office.as_deref());
303 push_opt(&mut q, "department", self.department.as_deref());
304 push_opt(&mut q, "notice_id", self.notice_id.as_deref());
305 push_opt(&mut q, "set_aside", self.set_aside.as_deref());
306 push_opt(
307 &mut q,
308 "solicitation_number",
309 self.solicitation_number.as_deref(),
310 );
311 for (k, v) in &self.extra {
312 if !v.is_empty() {
313 q.push((k.clone(), v.clone()));
314 }
315 }
316 q
317 }
318}
319
320#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
327#[non_exhaustive]
328pub struct ListForecastsOptions {
329 #[builder(into)]
331 pub page: Option<u32>,
332 #[builder(into)]
334 pub limit: Option<u32>,
335 #[builder(into)]
337 pub cursor: Option<String>,
338 #[builder(into)]
340 pub shape: Option<String>,
341 #[builder(default)]
343 pub flat: bool,
344 #[builder(default)]
346 pub flat_lists: bool,
347
348 #[builder(into)]
350 pub agency: Option<String>,
351 #[builder(into)]
353 pub award_date_after: Option<String>,
354 #[builder(into)]
356 pub award_date_before: Option<String>,
357 #[builder(into)]
359 pub fiscal_year: Option<String>,
360 #[builder(into)]
362 pub fiscal_year_gte: Option<String>,
363 #[builder(into)]
365 pub fiscal_year_lte: Option<String>,
366 #[builder(into)]
368 pub modified_after: Option<String>,
369 #[builder(into)]
371 pub modified_before: Option<String>,
372 #[builder(into)]
374 pub naics_code: Option<String>,
375 #[builder(into)]
377 pub naics_starts_with: Option<String>,
378 #[builder(into)]
380 pub ordering: Option<String>,
381 #[builder(into)]
383 pub search: Option<String>,
384 #[builder(into)]
386 pub source_system: Option<String>,
387 #[builder(into)]
389 pub status: Option<String>,
390
391 #[builder(into)]
393 pub id: Option<String>,
394
395 #[builder(default)]
397 pub extra: BTreeMap<String, String>,
398}
399
400impl ListForecastsOptions {
401 fn to_query(&self) -> Vec<(String, String)> {
402 let mut q = Vec::new();
403 apply_pagination(
404 &mut q,
405 self.page,
406 self.limit,
407 self.cursor.as_deref(),
408 self.shape.as_deref(),
409 self.flat,
410 self.flat_lists,
411 );
412 push_opt(&mut q, "agency", self.agency.as_deref());
413 push_opt(&mut q, "award_date_after", self.award_date_after.as_deref());
414 push_opt(
415 &mut q,
416 "award_date_before",
417 self.award_date_before.as_deref(),
418 );
419 push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
420 push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
421 push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
422 push_opt(&mut q, "modified_after", self.modified_after.as_deref());
423 push_opt(&mut q, "modified_before", self.modified_before.as_deref());
424 push_opt(&mut q, "naics_code", self.naics_code.as_deref());
425 push_opt(
426 &mut q,
427 "naics_starts_with",
428 self.naics_starts_with.as_deref(),
429 );
430 push_opt(&mut q, "ordering", self.ordering.as_deref());
431 push_opt(&mut q, "search", self.search.as_deref());
432 push_opt(&mut q, "id", self.id.as_deref());
433 push_opt(&mut q, "source_system", self.source_system.as_deref());
434 push_opt(&mut q, "status", self.status.as_deref());
435 for (k, v) in &self.extra {
436 if !v.is_empty() {
437 q.push((k.clone(), v.clone()));
438 }
439 }
440 q
441 }
442}
443
444#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
451#[non_exhaustive]
452pub struct ListGrantsOptions {
453 #[builder(into)]
455 pub page: Option<u32>,
456 #[builder(into)]
458 pub limit: Option<u32>,
459 #[builder(into)]
461 pub cursor: Option<String>,
462 #[builder(into)]
464 pub shape: Option<String>,
465 #[builder(default)]
467 pub flat: bool,
468 #[builder(default)]
470 pub flat_lists: bool,
471
472 #[builder(into)]
474 pub agency: Option<String>,
475 #[builder(into)]
477 pub applicant_types: Option<String>,
478 #[builder(into)]
480 pub cfda_number: Option<String>,
481 #[builder(into)]
483 pub grant_id: Option<String>,
484 #[builder(into)]
486 pub funding_categories: Option<String>,
487 #[builder(into)]
489 pub funding_instruments: Option<String>,
490 #[builder(into)]
492 pub opportunity_number: Option<String>,
493 #[builder(into)]
495 pub ordering: Option<String>,
496 #[builder(into)]
498 pub posted_date_after: Option<String>,
499 #[builder(into)]
501 pub posted_date_before: Option<String>,
502 #[builder(into)]
504 pub response_date_after: Option<String>,
505 #[builder(into)]
507 pub response_date_before: Option<String>,
508 #[builder(into)]
510 pub search: Option<String>,
511 #[builder(into)]
513 pub status: Option<String>,
514
515 #[builder(default)]
517 pub extra: BTreeMap<String, String>,
518}
519
520impl ListGrantsOptions {
521 fn to_query(&self) -> Vec<(String, String)> {
522 let mut q = Vec::new();
523 apply_pagination(
524 &mut q,
525 self.page,
526 self.limit,
527 self.cursor.as_deref(),
528 self.shape.as_deref(),
529 self.flat,
530 self.flat_lists,
531 );
532 push_opt(&mut q, "agency", self.agency.as_deref());
533 push_opt(&mut q, "applicant_types", self.applicant_types.as_deref());
534 push_opt(&mut q, "cfda_number", self.cfda_number.as_deref());
535 push_opt(&mut q, "grant_id", self.grant_id.as_deref());
536 push_opt(
537 &mut q,
538 "funding_categories",
539 self.funding_categories.as_deref(),
540 );
541 push_opt(
542 &mut q,
543 "funding_instruments",
544 self.funding_instruments.as_deref(),
545 );
546 push_opt(
547 &mut q,
548 "opportunity_number",
549 self.opportunity_number.as_deref(),
550 );
551 push_opt(&mut q, "ordering", self.ordering.as_deref());
552 push_opt(
553 &mut q,
554 "posted_date_after",
555 self.posted_date_after.as_deref(),
556 );
557 push_opt(
558 &mut q,
559 "posted_date_before",
560 self.posted_date_before.as_deref(),
561 );
562 push_opt(
563 &mut q,
564 "response_date_after",
565 self.response_date_after.as_deref(),
566 );
567 push_opt(
568 &mut q,
569 "response_date_before",
570 self.response_date_before.as_deref(),
571 );
572 push_opt(&mut q, "search", self.search.as_deref());
573 push_opt(&mut q, "status", self.status.as_deref());
574 for (k, v) in &self.extra {
575 if !v.is_empty() {
576 q.push((k.clone(), v.clone()));
577 }
578 }
579 q
580 }
581}
582
583impl Client {
588 pub async fn list_opportunities(&self, opts: ListOpportunitiesOptions) -> Result<Page<Record>> {
592 let q = opts.to_query();
593 self.get_page("/api/opportunities/", &q).await
594 }
595
596 pub fn iterate_opportunities(&self, opts: ListOpportunitiesOptions) -> PageStream<Record> {
598 let opts = Arc::new(opts);
599 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
600 let mut next = (*opts).clone();
601 next.page = page;
602 next.cursor = cursor;
603 Box::pin(async move { client.list_opportunities(next).await })
604 });
605 PageStream::new(self.clone(), fetch)
606 }
607
608 pub async fn list_notices(&self, opts: ListNoticesOptions) -> Result<Page<Record>> {
612 let q = opts.to_query();
613 self.get_page("/api/notices/", &q).await
614 }
615
616 pub fn iterate_notices(&self, opts: ListNoticesOptions) -> PageStream<Record> {
618 let opts = Arc::new(opts);
619 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
620 let mut next = (*opts).clone();
621 next.page = page;
622 next.cursor = cursor;
623 Box::pin(async move { client.list_notices(next).await })
624 });
625 PageStream::new(self.clone(), fetch)
626 }
627
628 pub async fn list_forecasts(&self, opts: ListForecastsOptions) -> Result<Page<Record>> {
630 let q = opts.to_query();
631 self.get_page("/api/forecasts/", &q).await
632 }
633
634 pub fn iterate_forecasts(&self, opts: ListForecastsOptions) -> PageStream<Record> {
636 let opts = Arc::new(opts);
637 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
638 let mut next = (*opts).clone();
639 next.page = page;
640 next.cursor = cursor;
641 Box::pin(async move { client.list_forecasts(next).await })
642 });
643 PageStream::new(self.clone(), fetch)
644 }
645
646 pub async fn list_grants(&self, opts: ListGrantsOptions) -> Result<Page<Record>> {
648 let q = opts.to_query();
649 self.get_page("/api/grants/", &q).await
650 }
651
652 pub fn iterate_grants(&self, opts: ListGrantsOptions) -> PageStream<Record> {
654 let opts = Arc::new(opts);
655 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
656 let mut next = (*opts).clone();
657 next.page = page;
658 next.cursor = cursor;
659 Box::pin(async move { client.list_grants(next).await })
660 });
661 PageStream::new(self.clone(), fetch)
662 }
663
664 pub async fn get_opportunity(
683 &self,
684 opportunity_id: &str,
685 opts: Option<ListOptions>,
686 ) -> Result<Record> {
687 if opportunity_id.is_empty() {
688 return Err(Error::Validation {
689 message: "get_opportunity: opportunity_id is required".into(),
690 response: None,
691 });
692 }
693 let mut q = Vec::new();
694 opts.unwrap_or_default().apply(&mut q);
695 let path = format!("/api/opportunities/{}/", urlencoding(opportunity_id));
696 self.get_json::<Record>(&path, &q).await
697 }
698
699 pub async fn get_notice(&self, notice_id: &str, opts: Option<ListOptions>) -> Result<Record> {
707 if notice_id.is_empty() {
708 return Err(Error::Validation {
709 message: "get_notice: notice_id is required".into(),
710 response: None,
711 });
712 }
713 let mut q = Vec::new();
714 opts.unwrap_or_default().apply(&mut q);
715 let path = format!("/api/notices/{}/", urlencoding(notice_id));
716 self.get_json::<Record>(&path, &q).await
717 }
718
719 pub async fn get_forecast(&self, id: &str, opts: Option<ListOptions>) -> Result<Record> {
721 if id.is_empty() {
722 return Err(Error::Validation {
723 message: "get_forecast: id is required".into(),
724 response: None,
725 });
726 }
727 let mut q = Vec::new();
728 opts.unwrap_or_default().apply(&mut q);
729 let path = format!("/api/forecasts/{}/", urlencoding(id));
730 self.get_json::<Record>(&path, &q).await
731 }
732
733 pub async fn get_grant(&self, grant_id: &str, opts: Option<ListOptions>) -> Result<Record> {
735 if grant_id.is_empty() {
736 return Err(Error::Validation {
737 message: "get_grant: grant_id is required".into(),
738 response: None,
739 });
740 }
741 let mut q = Vec::new();
742 opts.unwrap_or_default().apply(&mut q);
743 let path = format!("/api/grants/{}/", urlencoding(grant_id));
744 self.get_json::<Record>(&path, &q).await
745 }
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751
752 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
753 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
754 }
755
756 #[test]
757 fn opportunities_emits_all_string_filters() {
758 let opts = ListOpportunitiesOptions::builder()
759 .active(true)
760 .agency("9700")
761 .first_notice_date_after("2024-01-01")
762 .last_notice_date_before("2024-12-31")
763 .naics("541512")
764 .notice_type("PRESOL")
765 .ordering("-response_deadline")
766 .place_of_performance("VA")
767 .psc("D302")
768 .response_deadline_after("2024-01-15")
769 .response_deadline_before("2024-03-31")
770 .search("cloud computing")
771 .set_aside("8A")
772 .solicitation_number("W15P7T24R0001")
773 .build();
774 let q = opts.to_query();
775 assert_eq!(get_q(&q, "active").as_deref(), Some("true"));
776 assert_eq!(get_q(&q, "agency").as_deref(), Some("9700"));
777 assert_eq!(
778 get_q(&q, "first_notice_date_after").as_deref(),
779 Some("2024-01-01")
780 );
781 assert_eq!(
782 get_q(&q, "last_notice_date_before").as_deref(),
783 Some("2024-12-31")
784 );
785 assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
786 assert_eq!(get_q(&q, "notice_type").as_deref(), Some("PRESOL"));
787 assert_eq!(get_q(&q, "ordering").as_deref(), Some("-response_deadline"));
788 assert_eq!(get_q(&q, "place_of_performance").as_deref(), Some("VA"));
789 assert_eq!(get_q(&q, "psc").as_deref(), Some("D302"));
790 assert_eq!(get_q(&q, "search").as_deref(), Some("cloud computing"));
791 assert_eq!(get_q(&q, "set_aside").as_deref(), Some("8A"));
792 assert_eq!(
793 get_q(&q, "solicitation_number").as_deref(),
794 Some("W15P7T24R0001")
795 );
796 }
797
798 #[test]
799 fn opportunities_active_false_emits_false() {
800 let opts = ListOpportunitiesOptions::builder().active(false).build();
801 let q = opts.to_query();
802 assert_eq!(get_q(&q, "active").as_deref(), Some("false"));
803 }
804
805 #[test]
806 fn opportunities_award_filters_emit() {
807 let q = ListOpportunitiesOptions::builder()
808 .awarded(false)
809 .awardee_uei("ABCDEF123456|ZYXWVU654321")
810 .build()
811 .to_query();
812 assert_eq!(get_q(&q, "awarded").as_deref(), Some("false"));
813 assert_eq!(
814 get_q(&q, "awardee_uei").as_deref(),
815 Some("ABCDEF123456|ZYXWVU654321")
816 );
817 let q = ListOpportunitiesOptions::default().to_query();
818 assert!(!q.iter().any(|(k, _)| k == "awarded" || k == "awardee_uei"));
819 }
820
821 #[test]
822 fn opportunities_active_none_omits() {
823 let opts = ListOpportunitiesOptions::default();
824 let q = opts.to_query();
825 assert!(!q.iter().any(|(k, _)| k == "active"));
826 }
827
828 #[test]
829 fn notices_emits_filters_without_ordering_field() {
830 let opts = ListNoticesOptions::builder()
831 .active(true)
832 .agency("9700")
833 .naics("541512")
834 .notice_type("AWARD")
835 .posted_date_after("2024-01-01")
836 .posted_date_before("2024-12-31")
837 .search("cybersecurity")
838 .build();
839 let q = opts.to_query();
840 assert_eq!(get_q(&q, "active").as_deref(), Some("true"));
841 assert_eq!(get_q(&q, "agency").as_deref(), Some("9700"));
842 assert_eq!(get_q(&q, "notice_type").as_deref(), Some("AWARD"));
843 assert_eq!(get_q(&q, "search").as_deref(), Some("cybersecurity"));
844 }
845
846 #[test]
847 fn forecasts_emits_all_filters() {
848 let opts = ListForecastsOptions::builder()
849 .agency("9700")
850 .fiscal_year("2024")
851 .fiscal_year_gte("2023")
852 .fiscal_year_lte("2025")
853 .naics_code("541512")
854 .naics_starts_with("5415")
855 .ordering("award_date")
856 .source_system("SAM")
857 .status("active")
858 .build();
859 let q = opts.to_query();
860 assert_eq!(get_q(&q, "agency").as_deref(), Some("9700"));
861 assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024"));
862 assert_eq!(get_q(&q, "naics_starts_with").as_deref(), Some("5415"));
863 assert_eq!(get_q(&q, "source_system").as_deref(), Some("SAM"));
864 assert_eq!(get_q(&q, "status").as_deref(), Some("active"));
865 }
866
867 #[test]
868 fn grants_emits_all_filters() {
869 let opts = ListGrantsOptions::builder()
870 .agency("9700")
871 .applicant_types("11")
872 .cfda_number("10.001")
873 .grant_id("GRANT-123")
874 .funding_categories("AR")
875 .funding_instruments("G")
876 .opportunity_number("OPP-001")
877 .ordering("response_date")
878 .posted_date_after("2024-01-01")
879 .response_date_before("2024-11-30")
880 .search("environment")
881 .status("posted")
882 .build();
883 let q = opts.to_query();
884 assert_eq!(get_q(&q, "applicant_types").as_deref(), Some("11"));
885 assert_eq!(get_q(&q, "cfda_number").as_deref(), Some("10.001"));
886 assert_eq!(get_q(&q, "grant_id").as_deref(), Some("GRANT-123"));
887 assert_eq!(get_q(&q, "funding_categories").as_deref(), Some("AR"));
888 assert_eq!(get_q(&q, "funding_instruments").as_deref(), Some("G"));
889 assert_eq!(get_q(&q, "opportunity_number").as_deref(), Some("OPP-001"));
890 assert_eq!(get_q(&q, "status").as_deref(), Some("posted"));
891 }
892
893 #[tokio::test]
894 async fn get_grant_empty_key_returns_validation() {
895 let client = Client::builder().api_key("x").build().expect("build");
896 let err = client.get_grant("", None).await.expect_err("must error");
897 match err {
898 Error::Validation { message, .. } => assert!(message.contains("grant_id")),
899 other => panic!("expected Validation, got {other:?}"),
900 }
901 }
902
903 #[tokio::test]
904 async fn get_opportunity_empty_key_returns_validation() {
905 let client = Client::builder().api_key("x").build().expect("build");
906 let err = client
907 .get_opportunity("", None)
908 .await
909 .expect_err("must error");
910 match err {
911 Error::Validation { message, .. } => assert!(message.contains("opportunity_id")),
912 other => panic!("expected Validation, got {other:?}"),
913 }
914 }
915
916 #[tokio::test]
917 async fn get_forecast_empty_key_returns_validation() {
918 let client = Client::builder().api_key("x").build().expect("build");
919 let err = client.get_forecast("", None).await.expect_err("must error");
920 match err {
921 Error::Validation { message, .. } => assert!(message.contains("id")),
922 other => panic!("expected Validation, got {other:?}"),
923 }
924 }
925
926 #[tokio::test]
927 async fn get_notice_empty_key_returns_validation() {
928 let client = Client::builder().api_key("x").build().expect("build");
929 let err = client.get_notice("", None).await.expect_err("must error");
930 match err {
931 Error::Validation { message, .. } => assert!(message.contains("notice_id")),
932 other => panic!("expected Validation, got {other:?}"),
933 }
934 }
935
936 #[test]
937 fn identifier_and_org_filters_emit() {
938 let q = ListNoticesOptions::builder()
939 .notice_id("n1")
940 .department("DOD")
941 .office("W912")
942 .build()
943 .to_query();
944 assert_eq!(get_q(&q, "notice_id").as_deref(), Some("n1"));
945 assert_eq!(get_q(&q, "department").as_deref(), Some("DOD"));
946 assert_eq!(get_q(&q, "office").as_deref(), Some("W912"));
947 let q = ListOpportunitiesOptions::builder()
948 .opportunity_id("o1")
949 .build()
950 .to_query();
951 assert_eq!(get_q(&q, "opportunity_id").as_deref(), Some("o1"));
952 let q = ListForecastsOptions::builder().id("42").build().to_query();
953 assert_eq!(get_q(&q, "id").as_deref(), Some("42"));
954 }
955}