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    /// Whether the opportunity has an award, either posted on it or linked to it from a separate award notice (Tango API 5.5.0). Searches every opportunity rather than only active ones, so pair it with [`active`](Self::active) to narrow to open opportunities. `Some(false)` reaches the server as a filter value.
49    pub awarded: Option<bool>,
50    /// The awardee's UEI, case-insensitive; OR several with `|` (Tango API 5.5.0). Searches every opportunity rather than only active ones.
51    #[builder(into)]
52    pub awardee_uei: Option<String>,
53    /// Awarding agency CGAC code.
54    #[builder(into)]
55    pub agency: Option<String>,
56    /// Lower bound for first-notice date (inclusive, ISO `YYYY-MM-DD`).
57    #[builder(into)]
58    pub first_notice_date_after: Option<String>,
59    /// Upper bound for first-notice date (inclusive).
60    #[builder(into)]
61    pub first_notice_date_before: Option<String>,
62    /// Lower bound for last-notice date (inclusive).
63    #[builder(into)]
64    pub last_notice_date_after: Option<String>,
65    /// Upper bound for last-notice date (inclusive).
66    #[builder(into)]
67    pub last_notice_date_before: Option<String>,
68    /// NAICS code filter.
69    #[builder(into)]
70    pub naics: Option<String>,
71    /// Notice-type filter (e.g. `"PRESOL"`).
72    #[builder(into)]
73    pub notice_type: Option<String>,
74    /// Server-side sort spec (prefix with `-` for descending).
75    #[builder(into)]
76    pub ordering: Option<String>,
77    /// Place of performance filter (state code, etc.).
78    #[builder(into)]
79    pub place_of_performance: Option<String>,
80    /// PSC code filter.
81    #[builder(into)]
82    pub psc: Option<String>,
83    /// Lower bound for response deadline.
84    #[builder(into)]
85    pub response_deadline_after: Option<String>,
86    /// Upper bound for response deadline.
87    #[builder(into)]
88    pub response_deadline_before: Option<String>,
89    /// Free-text search filter.
90    #[builder(into)]
91    pub search: Option<String>,
92    /// Set-aside filter.
93    #[builder(into)]
94    pub set_aside: Option<String>,
95    /// Solicitation number filter.
96    #[builder(into)]
97    pub solicitation_number: Option<String>,
98
99    /// Opportunity ID filter.
100    #[builder(into)]
101    pub opportunity_id: Option<String>,
102
103    /// Escape hatch for filter keys not yet first-classed on this struct.
104    #[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// ---------------------------------------------------------------------------
181// Notices
182// ---------------------------------------------------------------------------
183
184/// Options for [`Client::list_notices`] and [`Client::iterate_notices`].
185/// Mirrors `ListNoticesOptions` in the Go SDK. The server rejects every
186/// `?ordering=` value on this endpoint, so the field is intentionally
187/// omitted.
188#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
189#[non_exhaustive]
190pub struct ListNoticesOptions {
191    /// 1-based page number.
192    #[builder(into)]
193    pub page: Option<u32>,
194    /// Page size.
195    #[builder(into)]
196    pub limit: Option<u32>,
197    /// Keyset cursor.
198    #[builder(into)]
199    pub cursor: Option<String>,
200    /// Comma-separated field selector.
201    #[builder(into)]
202    pub shape: Option<String>,
203    /// Collapse nested objects into dot-separated keys.
204    #[builder(default)]
205    pub flat: bool,
206    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
207    #[builder(default)]
208    pub flat_lists: bool,
209
210    /// Tri-state active/inactive filter.
211    #[builder(into)]
212    pub active: Option<bool>,
213    /// Awarding agency CGAC code.
214    #[builder(into)]
215    pub agency: Option<String>,
216    /// NAICS code filter.
217    #[builder(into)]
218    pub naics: Option<String>,
219    /// Notice-type filter.
220    #[builder(into)]
221    pub notice_type: Option<String>,
222    /// Lower bound for posted date (inclusive).
223    #[builder(into)]
224    pub posted_date_after: Option<String>,
225    /// Upper bound for posted date (inclusive).
226    #[builder(into)]
227    pub posted_date_before: Option<String>,
228    /// PSC code filter.
229    #[builder(into)]
230    pub psc: Option<String>,
231    /// Lower bound for response deadline.
232    #[builder(into)]
233    pub response_deadline_after: Option<String>,
234    /// Upper bound for response deadline.
235    #[builder(into)]
236    pub response_deadline_before: Option<String>,
237    /// Free-text search filter.
238    #[builder(into)]
239    pub search: Option<String>,
240    /// Set-aside filter.
241    #[builder(into)]
242    pub set_aside: Option<String>,
243    /// Solicitation number filter.
244    #[builder(into)]
245    pub solicitation_number: Option<String>,
246
247    /// Notice ID filter.
248    #[builder(into)]
249    pub notice_id: Option<String>,
250
251    /// Department filter: a name, abbreviation or code, with `|` for multiple values.
252    #[builder(into)]
253    pub department: Option<String>,
254
255    /// Contracting office filter: a name, abbreviation or code, with `|` for multiple values.
256    #[builder(into)]
257    pub office: Option<String>,
258
259    /// Escape hatch for filter keys not yet first-classed on this struct.
260    #[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// ---------------------------------------------------------------------------
321// Forecasts
322// ---------------------------------------------------------------------------
323
324/// Options for [`Client::list_forecasts`] and [`Client::iterate_forecasts`].
325/// Mirrors `ListForecastsOptions` in the Go SDK.
326#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
327#[non_exhaustive]
328pub struct ListForecastsOptions {
329    /// 1-based page number.
330    #[builder(into)]
331    pub page: Option<u32>,
332    /// Page size.
333    #[builder(into)]
334    pub limit: Option<u32>,
335    /// Keyset cursor.
336    #[builder(into)]
337    pub cursor: Option<String>,
338    /// Comma-separated field selector.
339    #[builder(into)]
340    pub shape: Option<String>,
341    /// Collapse nested objects into dot-separated keys.
342    #[builder(default)]
343    pub flat: bool,
344    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
345    #[builder(default)]
346    pub flat_lists: bool,
347
348    /// Awarding agency CGAC code.
349    #[builder(into)]
350    pub agency: Option<String>,
351    /// Lower bound for award date.
352    #[builder(into)]
353    pub award_date_after: Option<String>,
354    /// Upper bound for award date.
355    #[builder(into)]
356    pub award_date_before: Option<String>,
357    /// `fiscal_year` filter.
358    #[builder(into)]
359    pub fiscal_year: Option<String>,
360    /// Lower bound for `fiscal_year`.
361    #[builder(into)]
362    pub fiscal_year_gte: Option<String>,
363    /// Upper bound for `fiscal_year`.
364    #[builder(into)]
365    pub fiscal_year_lte: Option<String>,
366    /// Lower bound for last-modified timestamp.
367    #[builder(into)]
368    pub modified_after: Option<String>,
369    /// Upper bound for last-modified timestamp.
370    #[builder(into)]
371    pub modified_before: Option<String>,
372    /// NAICS code filter (exact).
373    #[builder(into)]
374    pub naics_code: Option<String>,
375    /// NAICS prefix filter.
376    #[builder(into)]
377    pub naics_starts_with: Option<String>,
378    /// Server-side sort spec.
379    #[builder(into)]
380    pub ordering: Option<String>,
381    /// Free-text search filter.
382    #[builder(into)]
383    pub search: Option<String>,
384    /// Source-system filter (e.g. `"SAM"`).
385    #[builder(into)]
386    pub source_system: Option<String>,
387    /// Status filter (e.g. `"active"`).
388    #[builder(into)]
389    pub status: Option<String>,
390
391    /// Forecast ID filter.
392    #[builder(into)]
393    pub id: Option<String>,
394
395    /// Escape hatch for filter keys not yet first-classed on this struct.
396    #[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// ---------------------------------------------------------------------------
445// Grants
446// ---------------------------------------------------------------------------
447
448/// Options for [`Client::list_grants`] and [`Client::iterate_grants`].
449/// Mirrors `ListGrantsOptions` in the Go SDK.
450#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
451#[non_exhaustive]
452pub struct ListGrantsOptions {
453    /// 1-based page number.
454    #[builder(into)]
455    pub page: Option<u32>,
456    /// Page size.
457    #[builder(into)]
458    pub limit: Option<u32>,
459    /// Keyset cursor.
460    #[builder(into)]
461    pub cursor: Option<String>,
462    /// Comma-separated field selector.
463    #[builder(into)]
464    pub shape: Option<String>,
465    /// Collapse nested objects into dot-separated keys.
466    #[builder(default)]
467    pub flat: bool,
468    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
469    #[builder(default)]
470    pub flat_lists: bool,
471
472    /// Awarding agency filter.
473    #[builder(into)]
474    pub agency: Option<String>,
475    /// Applicant-types filter (CSV of grants.gov codes).
476    #[builder(into)]
477    pub applicant_types: Option<String>,
478    /// CFDA number filter.
479    #[builder(into)]
480    pub cfda_number: Option<String>,
481    /// Grant identifier filter (exact match on `grant_id`).
482    #[builder(into)]
483    pub grant_id: Option<String>,
484    /// Funding-categories filter (CSV).
485    #[builder(into)]
486    pub funding_categories: Option<String>,
487    /// Funding-instruments filter (CSV).
488    #[builder(into)]
489    pub funding_instruments: Option<String>,
490    /// Opportunity number filter.
491    #[builder(into)]
492    pub opportunity_number: Option<String>,
493    /// Server-side sort spec.
494    #[builder(into)]
495    pub ordering: Option<String>,
496    /// Lower bound for posted date.
497    #[builder(into)]
498    pub posted_date_after: Option<String>,
499    /// Upper bound for posted date.
500    #[builder(into)]
501    pub posted_date_before: Option<String>,
502    /// Lower bound for response date.
503    #[builder(into)]
504    pub response_date_after: Option<String>,
505    /// Upper bound for response date.
506    #[builder(into)]
507    pub response_date_before: Option<String>,
508    /// Free-text search filter.
509    #[builder(into)]
510    pub search: Option<String>,
511    /// Status filter (e.g. `"posted"`).
512    #[builder(into)]
513    pub status: Option<String>,
514
515    /// Escape hatch for filter keys not yet first-classed on this struct.
516    #[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
583// ---------------------------------------------------------------------------
584// Client methods
585// ---------------------------------------------------------------------------
586
587impl Client {
588    /// `GET /api/opportunities/` — one page of opportunity records.
589    ///
590    /// The shape leaves documented on [`get_opportunity`](Self::get_opportunity) (award fields, attachment counts, `attachments(extraction_status)`) are accepted on the list too.
591    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    /// Stream every opportunity matching `opts`.
597    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    /// `GET /api/notices/` — one page of notice records.
609    ///
610    /// The shape leaves documented on [`get_notice`](Self::get_notice) are accepted on the list too.
611    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    /// Stream every notice matching `opts`.
617    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    /// `GET /api/forecasts/` — one page of forecast records.
629    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    /// Stream every forecast matching `opts`.
635    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    /// `GET /api/grants/` — one page of grant records.
647    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    /// Stream every grant matching `opts`.
653    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    /// `GET /api/opportunities/{opportunity_id}/` — a single opportunity.
665    ///
666    /// None of the fields below is in the SDK's default shape, so name the ones you want.
667    ///
668    /// **Award fields** (Tango API 5.5.0): `awarded`, `award_date`, `award_amount`, `awardee`, `awardee_uei`, `award_count`, `solicitation_opportunity_id` and the `awards(opportunity_id,notice_id,award_number,award_date,award_amount,awardee,awardee_uei)` expand.
669    /// `award_date`, `award_amount`, `awardee` and `awardee_uei` are filled only where SAM.gov posted an award notice, and `award_amount` is the text SAM.gov published, not a number.
670    /// When an award is posted as its own opportunity and references its solicitation, the award's `solicitation_opportunity_id` points back to the solicitation, whose `awards(...)` lists up to ten of the most recent linked awards; `award_count` is the full count.
671    /// `awarded` is true when the opportunity has an award of its own or has linked awards.
672    ///
673    /// **Attachment counts** (Tango API 5.9.0): `meta(attachments_count,files_count,links_count)`.
674    /// A `file` is a document attached to the notice (only files have extracted text) and a `link` is a URL the notice lists; `meta.attachments_count` still counts every attachment, so files plus links need not equal it.
675    /// `files_count` and `links_count` are null until the record has been counted: treat null as unknown, not zero.
676    ///
677    /// **`attachments(extraction_status)`** (Tango API 5.11.0) reports `pending`, `extracting`, `completed`, `failed` or `unsupported` for each attachment, which says why `extracted_text` is missing. It is available on every plan and returned only when named; `attachments(*)` does not carry it.
678    ///
679    /// ```text
680    /// opportunity_id,title,awarded,award_count,awards(notice_id,award_date,awardee_uei),meta(files_count,links_count),attachments(name,extraction_status)
681    /// ```
682    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    /// `GET /api/notices/{notice_id}/` — a single notice.
700    ///
701    /// None of the fields below is in the SDK's default shape, so name the ones you want.
702    ///
703    /// - **Award fields** (Tango API 5.5.0): `award_date`, `award_amount`, `awardee` and `awardee_uei`, filled only where the notice posted an award.
704    /// - **Attachment counts** (Tango API 5.9.0): `file_count` and `link_count` beside `attachment_count`, which still counts every attachment, links included. The new counts are null until the record has been counted: treat null as unknown, not zero.
705    /// - **`attachments(extraction_status)`** (Tango API 5.11.0): `pending`, `extracting`, `completed`, `failed` or `unsupported` per attachment, on every plan, returned only when named.
706    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    /// `GET /api/forecasts/{id}/` — a single procurement forecast.
720    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    /// `GET /api/grants/{grant_id}/` — a single grant opportunity.
734    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}