1use crate::client::Client;
5use crate::error::{Error, Result};
6use crate::internal::{apply_pagination, push_opt, push_opt_bool, push_opt_u32};
7use crate::pagination::{FetchFn, Page, PageStream};
8use crate::Record;
9use bon::Builder;
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct ListOpportunitiesOptions {
23 #[builder(into)]
26 pub page: Option<u32>,
27 #[builder(into)]
29 pub limit: Option<u32>,
30 #[builder(into)]
32 pub cursor: Option<String>,
33 #[builder(into)]
35 pub shape: Option<String>,
36 #[builder(default)]
38 pub flat: bool,
39 #[builder(default)]
41 pub flat_lists: bool,
42
43 #[builder(into)]
46 pub active: Option<bool>,
47 #[builder(into)]
49 pub agency: Option<String>,
50 #[builder(into)]
52 pub first_notice_date_after: Option<String>,
53 #[builder(into)]
55 pub first_notice_date_before: Option<String>,
56 #[builder(into)]
58 pub last_notice_date_after: Option<String>,
59 #[builder(into)]
61 pub last_notice_date_before: Option<String>,
62 #[builder(into)]
64 pub naics: Option<String>,
65 #[builder(into)]
67 pub notice_type: Option<String>,
68 #[builder(into)]
70 pub ordering: Option<String>,
71 #[builder(into)]
73 pub place_of_performance: Option<String>,
74 #[builder(into)]
76 pub psc: Option<String>,
77 #[builder(into)]
79 pub response_deadline_after: Option<String>,
80 #[builder(into)]
82 pub response_deadline_before: Option<String>,
83 #[builder(into)]
85 pub search: Option<String>,
86 #[builder(into)]
88 pub set_aside: Option<String>,
89 #[builder(into)]
91 pub solicitation_number: Option<String>,
92
93 #[builder(default)]
95 pub extra: BTreeMap<String, String>,
96}
97
98impl ListOpportunitiesOptions {
99 fn to_query(&self) -> Vec<(String, String)> {
100 let mut q = Vec::new();
101 apply_pagination(
102 &mut q,
103 self.page,
104 self.limit,
105 self.cursor.as_deref(),
106 self.shape.as_deref(),
107 self.flat,
108 self.flat_lists,
109 );
110 push_opt_bool(&mut q, "active", self.active);
111 push_opt(&mut q, "agency", self.agency.as_deref());
112 push_opt(
113 &mut q,
114 "first_notice_date_after",
115 self.first_notice_date_after.as_deref(),
116 );
117 push_opt(
118 &mut q,
119 "first_notice_date_before",
120 self.first_notice_date_before.as_deref(),
121 );
122 push_opt(
123 &mut q,
124 "last_notice_date_after",
125 self.last_notice_date_after.as_deref(),
126 );
127 push_opt(
128 &mut q,
129 "last_notice_date_before",
130 self.last_notice_date_before.as_deref(),
131 );
132 push_opt(&mut q, "naics", self.naics.as_deref());
133 push_opt(&mut q, "notice_type", self.notice_type.as_deref());
134 push_opt(&mut q, "ordering", self.ordering.as_deref());
135 push_opt(
136 &mut q,
137 "place_of_performance",
138 self.place_of_performance.as_deref(),
139 );
140 push_opt(&mut q, "psc", self.psc.as_deref());
141 push_opt(
142 &mut q,
143 "response_deadline_after",
144 self.response_deadline_after.as_deref(),
145 );
146 push_opt(
147 &mut q,
148 "response_deadline_before",
149 self.response_deadline_before.as_deref(),
150 );
151 push_opt(&mut q, "search", self.search.as_deref());
152 push_opt(&mut q, "set_aside", self.set_aside.as_deref());
153 push_opt(
154 &mut q,
155 "solicitation_number",
156 self.solicitation_number.as_deref(),
157 );
158 for (k, v) in &self.extra {
159 if !v.is_empty() {
160 q.push((k.clone(), v.clone()));
161 }
162 }
163 q
164 }
165}
166
167#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
176#[non_exhaustive]
177pub struct ListNoticesOptions {
178 #[builder(into)]
180 pub page: Option<u32>,
181 #[builder(into)]
183 pub limit: Option<u32>,
184 #[builder(into)]
186 pub cursor: Option<String>,
187 #[builder(into)]
189 pub shape: Option<String>,
190 #[builder(default)]
192 pub flat: bool,
193 #[builder(default)]
195 pub flat_lists: bool,
196
197 #[builder(into)]
199 pub active: Option<bool>,
200 #[builder(into)]
202 pub agency: Option<String>,
203 #[builder(into)]
205 pub naics: Option<String>,
206 #[builder(into)]
208 pub notice_type: Option<String>,
209 #[builder(into)]
211 pub posted_date_after: Option<String>,
212 #[builder(into)]
214 pub posted_date_before: Option<String>,
215 #[builder(into)]
217 pub psc: Option<String>,
218 #[builder(into)]
220 pub response_deadline_after: Option<String>,
221 #[builder(into)]
223 pub response_deadline_before: Option<String>,
224 #[builder(into)]
226 pub search: Option<String>,
227 #[builder(into)]
229 pub set_aside: Option<String>,
230 #[builder(into)]
232 pub solicitation_number: Option<String>,
233
234 #[builder(default)]
236 pub extra: BTreeMap<String, String>,
237}
238
239impl ListNoticesOptions {
240 fn to_query(&self) -> Vec<(String, String)> {
241 let mut q = Vec::new();
242 apply_pagination(
243 &mut q,
244 self.page,
245 self.limit,
246 self.cursor.as_deref(),
247 self.shape.as_deref(),
248 self.flat,
249 self.flat_lists,
250 );
251 push_opt_bool(&mut q, "active", self.active);
252 push_opt(&mut q, "agency", self.agency.as_deref());
253 push_opt(&mut q, "naics", self.naics.as_deref());
254 push_opt(&mut q, "notice_type", self.notice_type.as_deref());
255 push_opt(
256 &mut q,
257 "posted_date_after",
258 self.posted_date_after.as_deref(),
259 );
260 push_opt(
261 &mut q,
262 "posted_date_before",
263 self.posted_date_before.as_deref(),
264 );
265 push_opt(&mut q, "psc", self.psc.as_deref());
266 push_opt(
267 &mut q,
268 "response_deadline_after",
269 self.response_deadline_after.as_deref(),
270 );
271 push_opt(
272 &mut q,
273 "response_deadline_before",
274 self.response_deadline_before.as_deref(),
275 );
276 push_opt(&mut q, "search", self.search.as_deref());
277 push_opt(&mut q, "set_aside", self.set_aside.as_deref());
278 push_opt(
279 &mut q,
280 "solicitation_number",
281 self.solicitation_number.as_deref(),
282 );
283 for (k, v) in &self.extra {
284 if !v.is_empty() {
285 q.push((k.clone(), v.clone()));
286 }
287 }
288 q
289 }
290}
291
292#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
299#[non_exhaustive]
300pub struct ListForecastsOptions {
301 #[builder(into)]
303 pub page: Option<u32>,
304 #[builder(into)]
306 pub limit: Option<u32>,
307 #[builder(into)]
309 pub cursor: Option<String>,
310 #[builder(into)]
312 pub shape: Option<String>,
313 #[builder(default)]
315 pub flat: bool,
316 #[builder(default)]
318 pub flat_lists: bool,
319
320 #[builder(into)]
322 pub agency: Option<String>,
323 #[builder(into)]
325 pub award_date_after: Option<String>,
326 #[builder(into)]
328 pub award_date_before: Option<String>,
329 #[builder(into)]
331 pub fiscal_year: Option<String>,
332 #[builder(into)]
334 pub fiscal_year_gte: Option<String>,
335 #[builder(into)]
337 pub fiscal_year_lte: Option<String>,
338 #[builder(into)]
340 pub modified_after: Option<String>,
341 #[builder(into)]
343 pub modified_before: Option<String>,
344 #[builder(into)]
346 pub naics_code: Option<String>,
347 #[builder(into)]
349 pub naics_starts_with: Option<String>,
350 #[builder(into)]
352 pub ordering: Option<String>,
353 #[builder(into)]
355 pub search: Option<String>,
356 #[builder(into)]
358 pub source_system: Option<String>,
359 #[builder(into)]
361 pub status: Option<String>,
362
363 #[builder(default)]
365 pub extra: BTreeMap<String, String>,
366}
367
368impl ListForecastsOptions {
369 fn to_query(&self) -> Vec<(String, String)> {
370 let mut q = Vec::new();
371 apply_pagination(
372 &mut q,
373 self.page,
374 self.limit,
375 self.cursor.as_deref(),
376 self.shape.as_deref(),
377 self.flat,
378 self.flat_lists,
379 );
380 push_opt(&mut q, "agency", self.agency.as_deref());
381 push_opt(&mut q, "award_date_after", self.award_date_after.as_deref());
382 push_opt(
383 &mut q,
384 "award_date_before",
385 self.award_date_before.as_deref(),
386 );
387 push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
388 push_opt(&mut q, "fiscal_year_gte", self.fiscal_year_gte.as_deref());
389 push_opt(&mut q, "fiscal_year_lte", self.fiscal_year_lte.as_deref());
390 push_opt(&mut q, "modified_after", self.modified_after.as_deref());
391 push_opt(&mut q, "modified_before", self.modified_before.as_deref());
392 push_opt(&mut q, "naics_code", self.naics_code.as_deref());
393 push_opt(
394 &mut q,
395 "naics_starts_with",
396 self.naics_starts_with.as_deref(),
397 );
398 push_opt(&mut q, "ordering", self.ordering.as_deref());
399 push_opt(&mut q, "search", self.search.as_deref());
400 push_opt(&mut q, "source_system", self.source_system.as_deref());
401 push_opt(&mut q, "status", self.status.as_deref());
402 for (k, v) in &self.extra {
403 if !v.is_empty() {
404 q.push((k.clone(), v.clone()));
405 }
406 }
407 q
408 }
409}
410
411#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
418#[non_exhaustive]
419pub struct ListGrantsOptions {
420 #[builder(into)]
422 pub page: Option<u32>,
423 #[builder(into)]
425 pub limit: Option<u32>,
426 #[builder(into)]
428 pub cursor: Option<String>,
429 #[builder(into)]
431 pub shape: Option<String>,
432 #[builder(default)]
434 pub flat: bool,
435 #[builder(default)]
437 pub flat_lists: bool,
438
439 #[builder(into)]
441 pub agency: Option<String>,
442 #[builder(into)]
444 pub applicant_types: Option<String>,
445 #[builder(into)]
447 pub cfda_number: Option<String>,
448 #[builder(into)]
450 pub funding_categories: Option<String>,
451 #[builder(into)]
453 pub funding_instruments: Option<String>,
454 #[builder(into)]
456 pub opportunity_number: Option<String>,
457 #[builder(into)]
459 pub ordering: Option<String>,
460 #[builder(into)]
462 pub posted_date_after: Option<String>,
463 #[builder(into)]
465 pub posted_date_before: Option<String>,
466 #[builder(into)]
468 pub response_date_after: Option<String>,
469 #[builder(into)]
471 pub response_date_before: Option<String>,
472 #[builder(into)]
474 pub search: Option<String>,
475 #[builder(into)]
477 pub status: Option<String>,
478
479 #[builder(default)]
481 pub extra: BTreeMap<String, String>,
482}
483
484impl ListGrantsOptions {
485 fn to_query(&self) -> Vec<(String, String)> {
486 let mut q = Vec::new();
487 apply_pagination(
488 &mut q,
489 self.page,
490 self.limit,
491 self.cursor.as_deref(),
492 self.shape.as_deref(),
493 self.flat,
494 self.flat_lists,
495 );
496 push_opt(&mut q, "agency", self.agency.as_deref());
497 push_opt(&mut q, "applicant_types", self.applicant_types.as_deref());
498 push_opt(&mut q, "cfda_number", self.cfda_number.as_deref());
499 push_opt(
500 &mut q,
501 "funding_categories",
502 self.funding_categories.as_deref(),
503 );
504 push_opt(
505 &mut q,
506 "funding_instruments",
507 self.funding_instruments.as_deref(),
508 );
509 push_opt(
510 &mut q,
511 "opportunity_number",
512 self.opportunity_number.as_deref(),
513 );
514 push_opt(&mut q, "ordering", self.ordering.as_deref());
515 push_opt(
516 &mut q,
517 "posted_date_after",
518 self.posted_date_after.as_deref(),
519 );
520 push_opt(
521 &mut q,
522 "posted_date_before",
523 self.posted_date_before.as_deref(),
524 );
525 push_opt(
526 &mut q,
527 "response_date_after",
528 self.response_date_after.as_deref(),
529 );
530 push_opt(
531 &mut q,
532 "response_date_before",
533 self.response_date_before.as_deref(),
534 );
535 push_opt(&mut q, "search", self.search.as_deref());
536 push_opt(&mut q, "status", self.status.as_deref());
537 for (k, v) in &self.extra {
538 if !v.is_empty() {
539 q.push((k.clone(), v.clone()));
540 }
541 }
542 q
543 }
544}
545
546#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
556#[non_exhaustive]
557pub struct SearchOpportunityAttachmentsOptions {
558 #[builder(into)]
560 pub q: Option<String>,
561 #[builder(into)]
564 pub top_k: Option<u32>,
565 #[builder(default)]
568 pub include_extracted_text: bool,
569}
570
571impl SearchOpportunityAttachmentsOptions {
572 fn to_query(&self) -> Vec<(String, String)> {
573 let mut q = Vec::new();
574 push_opt(&mut q, "q", self.q.as_deref());
575 push_opt_u32(&mut q, "top_k", self.top_k);
576 if self.include_extracted_text {
577 q.push(("include_extracted_text".into(), "true".into()));
578 }
579 q
580 }
581}
582
583impl Client {
588 pub async fn list_opportunities(&self, opts: ListOpportunitiesOptions) -> Result<Page<Record>> {
590 let q = opts.to_query();
591 let bytes = self.get_bytes("/api/opportunities/", &q).await?;
592 Page::decode(&bytes)
593 }
594
595 pub fn iterate_opportunities(&self, opts: ListOpportunitiesOptions) -> PageStream<Record> {
597 let opts = Arc::new(opts);
598 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
599 let mut next = (*opts).clone();
600 next.page = page;
601 next.cursor = cursor;
602 Box::pin(async move { client.list_opportunities(next).await })
603 });
604 PageStream::new(self.clone(), fetch)
605 }
606
607 pub async fn list_notices(&self, opts: ListNoticesOptions) -> Result<Page<Record>> {
609 let q = opts.to_query();
610 let bytes = self.get_bytes("/api/notices/", &q).await?;
611 Page::decode(&bytes)
612 }
613
614 pub fn iterate_notices(&self, opts: ListNoticesOptions) -> PageStream<Record> {
616 let opts = Arc::new(opts);
617 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
618 let mut next = (*opts).clone();
619 next.page = page;
620 next.cursor = cursor;
621 Box::pin(async move { client.list_notices(next).await })
622 });
623 PageStream::new(self.clone(), fetch)
624 }
625
626 pub async fn list_forecasts(&self, opts: ListForecastsOptions) -> Result<Page<Record>> {
628 let q = opts.to_query();
629 let bytes = self.get_bytes("/api/forecasts/", &q).await?;
630 Page::decode(&bytes)
631 }
632
633 pub fn iterate_forecasts(&self, opts: ListForecastsOptions) -> PageStream<Record> {
635 let opts = Arc::new(opts);
636 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
637 let mut next = (*opts).clone();
638 next.page = page;
639 next.cursor = cursor;
640 Box::pin(async move { client.list_forecasts(next).await })
641 });
642 PageStream::new(self.clone(), fetch)
643 }
644
645 pub async fn list_grants(&self, opts: ListGrantsOptions) -> Result<Page<Record>> {
647 let q = opts.to_query();
648 let bytes = self.get_bytes("/api/grants/", &q).await?;
649 Page::decode(&bytes)
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 search_opportunity_attachments(
669 &self,
670 opts: SearchOpportunityAttachmentsOptions,
671 ) -> Result<Record> {
672 if opts.q.as_deref().filter(|s| !s.is_empty()).is_none() {
673 return Err(Error::Validation {
674 message: "search_opportunity_attachments: q is required".into(),
675 response: None,
676 });
677 }
678 let q = opts.to_query();
679 self.get_json::<Record>("/api/opportunities/attachment-search/", &q)
680 .await
681 }
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687
688 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
689 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
690 }
691
692 #[test]
693 fn opportunities_emits_all_string_filters() {
694 let opts = ListOpportunitiesOptions::builder()
695 .active(true)
696 .agency("9700")
697 .first_notice_date_after("2024-01-01")
698 .last_notice_date_before("2024-12-31")
699 .naics("541512")
700 .notice_type("PRESOL")
701 .ordering("-response_deadline")
702 .place_of_performance("VA")
703 .psc("D302")
704 .response_deadline_after("2024-01-15")
705 .response_deadline_before("2024-03-31")
706 .search("cloud computing")
707 .set_aside("8A")
708 .solicitation_number("W15P7T24R0001")
709 .build();
710 let q = opts.to_query();
711 assert_eq!(get_q(&q, "active").as_deref(), Some("true"));
712 assert_eq!(get_q(&q, "agency").as_deref(), Some("9700"));
713 assert_eq!(
714 get_q(&q, "first_notice_date_after").as_deref(),
715 Some("2024-01-01")
716 );
717 assert_eq!(
718 get_q(&q, "last_notice_date_before").as_deref(),
719 Some("2024-12-31")
720 );
721 assert_eq!(get_q(&q, "naics").as_deref(), Some("541512"));
722 assert_eq!(get_q(&q, "notice_type").as_deref(), Some("PRESOL"));
723 assert_eq!(get_q(&q, "ordering").as_deref(), Some("-response_deadline"));
724 assert_eq!(get_q(&q, "place_of_performance").as_deref(), Some("VA"));
725 assert_eq!(get_q(&q, "psc").as_deref(), Some("D302"));
726 assert_eq!(get_q(&q, "search").as_deref(), Some("cloud computing"));
727 assert_eq!(get_q(&q, "set_aside").as_deref(), Some("8A"));
728 assert_eq!(
729 get_q(&q, "solicitation_number").as_deref(),
730 Some("W15P7T24R0001")
731 );
732 }
733
734 #[test]
735 fn opportunities_active_false_emits_false() {
736 let opts = ListOpportunitiesOptions::builder().active(false).build();
737 let q = opts.to_query();
738 assert_eq!(get_q(&q, "active").as_deref(), Some("false"));
739 }
740
741 #[test]
742 fn opportunities_active_none_omits() {
743 let opts = ListOpportunitiesOptions::default();
744 let q = opts.to_query();
745 assert!(!q.iter().any(|(k, _)| k == "active"));
746 }
747
748 #[test]
749 fn notices_emits_filters_without_ordering_field() {
750 let opts = ListNoticesOptions::builder()
751 .active(true)
752 .agency("9700")
753 .naics("541512")
754 .notice_type("AWARD")
755 .posted_date_after("2024-01-01")
756 .posted_date_before("2024-12-31")
757 .search("cybersecurity")
758 .build();
759 let q = opts.to_query();
760 assert_eq!(get_q(&q, "active").as_deref(), Some("true"));
761 assert_eq!(get_q(&q, "agency").as_deref(), Some("9700"));
762 assert_eq!(get_q(&q, "notice_type").as_deref(), Some("AWARD"));
763 assert_eq!(get_q(&q, "search").as_deref(), Some("cybersecurity"));
764 }
765
766 #[test]
767 fn forecasts_emits_all_filters() {
768 let opts = ListForecastsOptions::builder()
769 .agency("9700")
770 .fiscal_year("2024")
771 .fiscal_year_gte("2023")
772 .fiscal_year_lte("2025")
773 .naics_code("541512")
774 .naics_starts_with("5415")
775 .ordering("award_date")
776 .source_system("SAM")
777 .status("active")
778 .build();
779 let q = opts.to_query();
780 assert_eq!(get_q(&q, "agency").as_deref(), Some("9700"));
781 assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024"));
782 assert_eq!(get_q(&q, "naics_starts_with").as_deref(), Some("5415"));
783 assert_eq!(get_q(&q, "source_system").as_deref(), Some("SAM"));
784 assert_eq!(get_q(&q, "status").as_deref(), Some("active"));
785 }
786
787 #[test]
788 fn grants_emits_all_filters() {
789 let opts = ListGrantsOptions::builder()
790 .agency("9700")
791 .applicant_types("11")
792 .cfda_number("10.001")
793 .funding_categories("AR")
794 .funding_instruments("G")
795 .opportunity_number("OPP-001")
796 .ordering("response_date")
797 .posted_date_after("2024-01-01")
798 .response_date_before("2024-11-30")
799 .search("environment")
800 .status("posted")
801 .build();
802 let q = opts.to_query();
803 assert_eq!(get_q(&q, "applicant_types").as_deref(), Some("11"));
804 assert_eq!(get_q(&q, "cfda_number").as_deref(), Some("10.001"));
805 assert_eq!(get_q(&q, "funding_categories").as_deref(), Some("AR"));
806 assert_eq!(get_q(&q, "funding_instruments").as_deref(), Some("G"));
807 assert_eq!(get_q(&q, "opportunity_number").as_deref(), Some("OPP-001"));
808 assert_eq!(get_q(&q, "status").as_deref(), Some("posted"));
809 }
810
811 #[test]
812 fn attachment_search_emits_all_flags() {
813 let opts = SearchOpportunityAttachmentsOptions::builder()
814 .q("statement of work cloud migration")
815 .top_k(5u32)
816 .include_extracted_text(true)
817 .build();
818 let q = opts.to_query();
819 assert_eq!(
820 get_q(&q, "q").as_deref(),
821 Some("statement of work cloud migration")
822 );
823 assert_eq!(get_q(&q, "top_k").as_deref(), Some("5"));
824 assert_eq!(get_q(&q, "include_extracted_text").as_deref(), Some("true"));
825 }
826
827 #[test]
828 fn attachment_search_top_k_zero_omitted() {
829 let opts = SearchOpportunityAttachmentsOptions::builder()
830 .q("test query")
831 .top_k(0u32)
832 .build();
833 let q = opts.to_query();
834 assert!(!q.iter().any(|(k, _)| k == "top_k"));
835 }
836
837 #[test]
838 fn attachment_search_extracted_text_omitted_when_false() {
839 let opts = SearchOpportunityAttachmentsOptions::builder()
840 .q("test")
841 .build();
842 let q = opts.to_query();
843 assert!(!q.iter().any(|(k, _)| k == "include_extracted_text"));
844 }
845
846 #[tokio::test]
847 async fn search_opportunity_attachments_empty_q_returns_validation() {
848 let client = Client::builder().api_key("x").build().expect("build");
849 let err = client
850 .search_opportunity_attachments(SearchOpportunityAttachmentsOptions::default())
851 .await
852 .expect_err("must error");
853 match err {
854 Error::Validation { message, .. } => {
855 assert!(message.contains('q'));
856 }
857 other => panic!("expected Validation, got {other:?}"),
858 }
859 }
860}