Skip to main content

tango/resources/
opportunities.rs

1//! `/api/opportunities/`, `/api/notices/`, `/api/forecasts/`, `/api/grants/`,
2//! and the opportunity-attachment semantic-search endpoint.
3
4use 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// ---------------------------------------------------------------------------
15// Opportunities
16// ---------------------------------------------------------------------------
17
18/// Options for [`Client::list_opportunities`] and
19/// [`Client::iterate_opportunities`]. Mirrors `ListOpportunitiesOptions`
20/// in the Go SDK.
21#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
22#[non_exhaustive]
23pub struct ListOpportunitiesOptions {
24    // ----- Pagination + shape -----
25    /// 1-based page number.
26    #[builder(into)]
27    pub page: Option<u32>,
28    /// Page size (server caps at 100).
29    #[builder(into)]
30    pub limit: Option<u32>,
31    /// Keyset cursor.
32    #[builder(into)]
33    pub cursor: Option<String>,
34    /// Comma-separated field selector.
35    #[builder(into)]
36    pub shape: Option<String>,
37    /// Collapse nested objects into dot-separated keys.
38    #[builder(default)]
39    pub flat: bool,
40    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
41    #[builder(default)]
42    pub flat_lists: bool,
43
44    // ----- Filters -----
45    /// Tri-state: `Some(true)`/`Some(false)` to filter; `None` to omit.
46    #[builder(into)]
47    pub active: Option<bool>,
48    /// Awarding agency CGAC code.
49    #[builder(into)]
50    pub agency: Option<String>,
51    /// Lower bound for first-notice date (inclusive, ISO `YYYY-MM-DD`).
52    #[builder(into)]
53    pub first_notice_date_after: Option<String>,
54    /// Upper bound for first-notice date (inclusive).
55    #[builder(into)]
56    pub first_notice_date_before: Option<String>,
57    /// Lower bound for last-notice date (inclusive).
58    #[builder(into)]
59    pub last_notice_date_after: Option<String>,
60    /// Upper bound for last-notice date (inclusive).
61    #[builder(into)]
62    pub last_notice_date_before: Option<String>,
63    /// NAICS code filter.
64    #[builder(into)]
65    pub naics: Option<String>,
66    /// Notice-type filter (e.g. `"PRESOL"`).
67    #[builder(into)]
68    pub notice_type: Option<String>,
69    /// Server-side sort spec (prefix with `-` for descending).
70    #[builder(into)]
71    pub ordering: Option<String>,
72    /// Place of performance filter (state code, etc.).
73    #[builder(into)]
74    pub place_of_performance: Option<String>,
75    /// PSC code filter.
76    #[builder(into)]
77    pub psc: Option<String>,
78    /// Lower bound for response deadline.
79    #[builder(into)]
80    pub response_deadline_after: Option<String>,
81    /// Upper bound for response deadline.
82    #[builder(into)]
83    pub response_deadline_before: Option<String>,
84    /// Free-text search filter.
85    #[builder(into)]
86    pub search: Option<String>,
87    /// Set-aside filter.
88    #[builder(into)]
89    pub set_aside: Option<String>,
90    /// Solicitation number filter.
91    #[builder(into)]
92    pub solicitation_number: Option<String>,
93
94    /// Opportunity ID filter.
95    #[builder(into)]
96    pub opportunity_id: Option<String>,
97
98    /// Escape hatch for filter keys not yet first-classed on this struct.
99    #[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// ---------------------------------------------------------------------------
174// Notices
175// ---------------------------------------------------------------------------
176
177/// Options for [`Client::list_notices`] and [`Client::iterate_notices`].
178/// Mirrors `ListNoticesOptions` in the Go SDK. The server rejects every
179/// `?ordering=` value on this endpoint, so the field is intentionally
180/// omitted.
181#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
182#[non_exhaustive]
183pub struct ListNoticesOptions {
184    /// 1-based page number.
185    #[builder(into)]
186    pub page: Option<u32>,
187    /// Page size.
188    #[builder(into)]
189    pub limit: Option<u32>,
190    /// Keyset cursor.
191    #[builder(into)]
192    pub cursor: Option<String>,
193    /// Comma-separated field selector.
194    #[builder(into)]
195    pub shape: Option<String>,
196    /// Collapse nested objects into dot-separated keys.
197    #[builder(default)]
198    pub flat: bool,
199    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
200    #[builder(default)]
201    pub flat_lists: bool,
202
203    /// Tri-state active/inactive filter.
204    #[builder(into)]
205    pub active: Option<bool>,
206    /// Awarding agency CGAC code.
207    #[builder(into)]
208    pub agency: Option<String>,
209    /// NAICS code filter.
210    #[builder(into)]
211    pub naics: Option<String>,
212    /// Notice-type filter.
213    #[builder(into)]
214    pub notice_type: Option<String>,
215    /// Lower bound for posted date (inclusive).
216    #[builder(into)]
217    pub posted_date_after: Option<String>,
218    /// Upper bound for posted date (inclusive).
219    #[builder(into)]
220    pub posted_date_before: Option<String>,
221    /// PSC code filter.
222    #[builder(into)]
223    pub psc: Option<String>,
224    /// Lower bound for response deadline.
225    #[builder(into)]
226    pub response_deadline_after: Option<String>,
227    /// Upper bound for response deadline.
228    #[builder(into)]
229    pub response_deadline_before: Option<String>,
230    /// Free-text search filter.
231    #[builder(into)]
232    pub search: Option<String>,
233    /// Set-aside filter.
234    #[builder(into)]
235    pub set_aside: Option<String>,
236    /// Solicitation number filter.
237    #[builder(into)]
238    pub solicitation_number: Option<String>,
239
240    /// Notice ID filter.
241    #[builder(into)]
242    pub notice_id: Option<String>,
243
244    /// Department filter: a name, abbreviation or code, with `|` for multiple values.
245    #[builder(into)]
246    pub department: Option<String>,
247
248    /// Contracting office filter: a name, abbreviation or code, with `|` for multiple values.
249    #[builder(into)]
250    pub office: Option<String>,
251
252    /// Escape hatch for filter keys not yet first-classed on this struct.
253    #[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// ---------------------------------------------------------------------------
314// Forecasts
315// ---------------------------------------------------------------------------
316
317/// Options for [`Client::list_forecasts`] and [`Client::iterate_forecasts`].
318/// Mirrors `ListForecastsOptions` in the Go SDK.
319#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
320#[non_exhaustive]
321pub struct ListForecastsOptions {
322    /// 1-based page number.
323    #[builder(into)]
324    pub page: Option<u32>,
325    /// Page size.
326    #[builder(into)]
327    pub limit: Option<u32>,
328    /// Keyset cursor.
329    #[builder(into)]
330    pub cursor: Option<String>,
331    /// Comma-separated field selector.
332    #[builder(into)]
333    pub shape: Option<String>,
334    /// Collapse nested objects into dot-separated keys.
335    #[builder(default)]
336    pub flat: bool,
337    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
338    #[builder(default)]
339    pub flat_lists: bool,
340
341    /// Awarding agency CGAC code.
342    #[builder(into)]
343    pub agency: Option<String>,
344    /// Lower bound for award date.
345    #[builder(into)]
346    pub award_date_after: Option<String>,
347    /// Upper bound for award date.
348    #[builder(into)]
349    pub award_date_before: Option<String>,
350    /// `fiscal_year` filter.
351    #[builder(into)]
352    pub fiscal_year: Option<String>,
353    /// Lower bound for `fiscal_year`.
354    #[builder(into)]
355    pub fiscal_year_gte: Option<String>,
356    /// Upper bound for `fiscal_year`.
357    #[builder(into)]
358    pub fiscal_year_lte: Option<String>,
359    /// Lower bound for last-modified timestamp.
360    #[builder(into)]
361    pub modified_after: Option<String>,
362    /// Upper bound for last-modified timestamp.
363    #[builder(into)]
364    pub modified_before: Option<String>,
365    /// NAICS code filter (exact).
366    #[builder(into)]
367    pub naics_code: Option<String>,
368    /// NAICS prefix filter.
369    #[builder(into)]
370    pub naics_starts_with: Option<String>,
371    /// Server-side sort spec.
372    #[builder(into)]
373    pub ordering: Option<String>,
374    /// Free-text search filter.
375    #[builder(into)]
376    pub search: Option<String>,
377    /// Source-system filter (e.g. `"SAM"`).
378    #[builder(into)]
379    pub source_system: Option<String>,
380    /// Status filter (e.g. `"active"`).
381    #[builder(into)]
382    pub status: Option<String>,
383
384    /// Forecast ID filter.
385    #[builder(into)]
386    pub id: Option<String>,
387
388    /// Escape hatch for filter keys not yet first-classed on this struct.
389    #[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// ---------------------------------------------------------------------------
438// Grants
439// ---------------------------------------------------------------------------
440
441/// Options for [`Client::list_grants`] and [`Client::iterate_grants`].
442/// Mirrors `ListGrantsOptions` in the Go SDK.
443#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
444#[non_exhaustive]
445pub struct ListGrantsOptions {
446    /// 1-based page number.
447    #[builder(into)]
448    pub page: Option<u32>,
449    /// Page size.
450    #[builder(into)]
451    pub limit: Option<u32>,
452    /// Keyset cursor.
453    #[builder(into)]
454    pub cursor: Option<String>,
455    /// Comma-separated field selector.
456    #[builder(into)]
457    pub shape: Option<String>,
458    /// Collapse nested objects into dot-separated keys.
459    #[builder(default)]
460    pub flat: bool,
461    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
462    #[builder(default)]
463    pub flat_lists: bool,
464
465    /// Awarding agency filter.
466    #[builder(into)]
467    pub agency: Option<String>,
468    /// Applicant-types filter (CSV of grants.gov codes).
469    #[builder(into)]
470    pub applicant_types: Option<String>,
471    /// CFDA number filter.
472    #[builder(into)]
473    pub cfda_number: Option<String>,
474    /// Grant identifier filter (exact match on `grant_id`).
475    #[builder(into)]
476    pub grant_id: Option<String>,
477    /// Funding-categories filter (CSV).
478    #[builder(into)]
479    pub funding_categories: Option<String>,
480    /// Funding-instruments filter (CSV).
481    #[builder(into)]
482    pub funding_instruments: Option<String>,
483    /// Opportunity number filter.
484    #[builder(into)]
485    pub opportunity_number: Option<String>,
486    /// Server-side sort spec.
487    #[builder(into)]
488    pub ordering: Option<String>,
489    /// Lower bound for posted date.
490    #[builder(into)]
491    pub posted_date_after: Option<String>,
492    /// Upper bound for posted date.
493    #[builder(into)]
494    pub posted_date_before: Option<String>,
495    /// Lower bound for response date.
496    #[builder(into)]
497    pub response_date_after: Option<String>,
498    /// Upper bound for response date.
499    #[builder(into)]
500    pub response_date_before: Option<String>,
501    /// Free-text search filter.
502    #[builder(into)]
503    pub search: Option<String>,
504    /// Status filter (e.g. `"posted"`).
505    #[builder(into)]
506    pub status: Option<String>,
507
508    /// Escape hatch for filter keys not yet first-classed on this struct.
509    #[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
576// ---------------------------------------------------------------------------
577// Client methods
578// ---------------------------------------------------------------------------
579
580impl Client {
581    /// `GET /api/opportunities/` — one page of opportunity records.
582    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    /// Stream every opportunity matching `opts`.
589    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    /// `GET /api/notices/` — one page of notice records.
601    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    /// Stream every notice matching `opts`.
608    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    /// `GET /api/forecasts/` — one page of forecast records.
620    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    /// Stream every forecast matching `opts`.
627    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    /// `GET /api/grants/` — one page of grant records.
639    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    /// Stream every grant matching `opts`.
646    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    /// `GET /api/opportunities/{opportunity_id}/` — a single opportunity.
658    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    /// `GET /api/notices/{notice_id}/` — a single notice.
676    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    /// `GET /api/forecasts/{id}/` — a single procurement forecast.
690    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    /// `GET /api/grants/{grant_id}/` — a single grant opportunity.
704    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}