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, 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// ---------------------------------------------------------------------------
14// Opportunities
15// ---------------------------------------------------------------------------
16
17/// Options for [`Client::list_opportunities`] and
18/// [`Client::iterate_opportunities`]. Mirrors `ListOpportunitiesOptions`
19/// in the Go SDK.
20#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct ListOpportunitiesOptions {
23    // ----- Pagination + shape -----
24    /// 1-based page number.
25    #[builder(into)]
26    pub page: Option<u32>,
27    /// Page size (server caps at 100).
28    #[builder(into)]
29    pub limit: Option<u32>,
30    /// Keyset cursor.
31    #[builder(into)]
32    pub cursor: Option<String>,
33    /// Comma-separated field selector.
34    #[builder(into)]
35    pub shape: Option<String>,
36    /// Collapse nested objects into dot-separated keys.
37    #[builder(default)]
38    pub flat: bool,
39    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
40    #[builder(default)]
41    pub flat_lists: bool,
42
43    // ----- Filters -----
44    /// Tri-state: `Some(true)`/`Some(false)` to filter; `None` to omit.
45    #[builder(into)]
46    pub active: Option<bool>,
47    /// Awarding agency CGAC code.
48    #[builder(into)]
49    pub agency: Option<String>,
50    /// Lower bound for first-notice date (inclusive, ISO `YYYY-MM-DD`).
51    #[builder(into)]
52    pub first_notice_date_after: Option<String>,
53    /// Upper bound for first-notice date (inclusive).
54    #[builder(into)]
55    pub first_notice_date_before: Option<String>,
56    /// Lower bound for last-notice date (inclusive).
57    #[builder(into)]
58    pub last_notice_date_after: Option<String>,
59    /// Upper bound for last-notice date (inclusive).
60    #[builder(into)]
61    pub last_notice_date_before: Option<String>,
62    /// NAICS code filter.
63    #[builder(into)]
64    pub naics: Option<String>,
65    /// Notice-type filter (e.g. `"PRESOL"`).
66    #[builder(into)]
67    pub notice_type: Option<String>,
68    /// Server-side sort spec (prefix with `-` for descending).
69    #[builder(into)]
70    pub ordering: Option<String>,
71    /// Place of performance filter (state code, etc.).
72    #[builder(into)]
73    pub place_of_performance: Option<String>,
74    /// PSC code filter.
75    #[builder(into)]
76    pub psc: Option<String>,
77    /// Lower bound for response deadline.
78    #[builder(into)]
79    pub response_deadline_after: Option<String>,
80    /// Upper bound for response deadline.
81    #[builder(into)]
82    pub response_deadline_before: Option<String>,
83    /// Free-text search filter.
84    #[builder(into)]
85    pub search: Option<String>,
86    /// Set-aside filter.
87    #[builder(into)]
88    pub set_aside: Option<String>,
89    /// Solicitation number filter.
90    #[builder(into)]
91    pub solicitation_number: Option<String>,
92
93    /// Escape hatch for filter keys not yet first-classed on this struct.
94    #[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// ---------------------------------------------------------------------------
168// Notices
169// ---------------------------------------------------------------------------
170
171/// Options for [`Client::list_notices`] and [`Client::iterate_notices`].
172/// Mirrors `ListNoticesOptions` in the Go SDK. The server rejects every
173/// `?ordering=` value on this endpoint, so the field is intentionally
174/// omitted.
175#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
176#[non_exhaustive]
177pub struct ListNoticesOptions {
178    /// 1-based page number.
179    #[builder(into)]
180    pub page: Option<u32>,
181    /// Page size.
182    #[builder(into)]
183    pub limit: Option<u32>,
184    /// Keyset cursor.
185    #[builder(into)]
186    pub cursor: Option<String>,
187    /// Comma-separated field selector.
188    #[builder(into)]
189    pub shape: Option<String>,
190    /// Collapse nested objects into dot-separated keys.
191    #[builder(default)]
192    pub flat: bool,
193    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
194    #[builder(default)]
195    pub flat_lists: bool,
196
197    /// Tri-state active/inactive filter.
198    #[builder(into)]
199    pub active: Option<bool>,
200    /// Awarding agency CGAC code.
201    #[builder(into)]
202    pub agency: Option<String>,
203    /// NAICS code filter.
204    #[builder(into)]
205    pub naics: Option<String>,
206    /// Notice-type filter.
207    #[builder(into)]
208    pub notice_type: Option<String>,
209    /// Lower bound for posted date (inclusive).
210    #[builder(into)]
211    pub posted_date_after: Option<String>,
212    /// Upper bound for posted date (inclusive).
213    #[builder(into)]
214    pub posted_date_before: Option<String>,
215    /// PSC code filter.
216    #[builder(into)]
217    pub psc: Option<String>,
218    /// Lower bound for response deadline.
219    #[builder(into)]
220    pub response_deadline_after: Option<String>,
221    /// Upper bound for response deadline.
222    #[builder(into)]
223    pub response_deadline_before: Option<String>,
224    /// Free-text search filter.
225    #[builder(into)]
226    pub search: Option<String>,
227    /// Set-aside filter.
228    #[builder(into)]
229    pub set_aside: Option<String>,
230    /// Solicitation number filter.
231    #[builder(into)]
232    pub solicitation_number: Option<String>,
233
234    /// Escape hatch for filter keys not yet first-classed on this struct.
235    #[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// ---------------------------------------------------------------------------
293// Forecasts
294// ---------------------------------------------------------------------------
295
296/// Options for [`Client::list_forecasts`] and [`Client::iterate_forecasts`].
297/// Mirrors `ListForecastsOptions` in the Go SDK.
298#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
299#[non_exhaustive]
300pub struct ListForecastsOptions {
301    /// 1-based page number.
302    #[builder(into)]
303    pub page: Option<u32>,
304    /// Page size.
305    #[builder(into)]
306    pub limit: Option<u32>,
307    /// Keyset cursor.
308    #[builder(into)]
309    pub cursor: Option<String>,
310    /// Comma-separated field selector.
311    #[builder(into)]
312    pub shape: Option<String>,
313    /// Collapse nested objects into dot-separated keys.
314    #[builder(default)]
315    pub flat: bool,
316    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
317    #[builder(default)]
318    pub flat_lists: bool,
319
320    /// Awarding agency CGAC code.
321    #[builder(into)]
322    pub agency: Option<String>,
323    /// Lower bound for award date.
324    #[builder(into)]
325    pub award_date_after: Option<String>,
326    /// Upper bound for award date.
327    #[builder(into)]
328    pub award_date_before: Option<String>,
329    /// `fiscal_year` filter.
330    #[builder(into)]
331    pub fiscal_year: Option<String>,
332    /// Lower bound for `fiscal_year`.
333    #[builder(into)]
334    pub fiscal_year_gte: Option<String>,
335    /// Upper bound for `fiscal_year`.
336    #[builder(into)]
337    pub fiscal_year_lte: Option<String>,
338    /// Lower bound for last-modified timestamp.
339    #[builder(into)]
340    pub modified_after: Option<String>,
341    /// Upper bound for last-modified timestamp.
342    #[builder(into)]
343    pub modified_before: Option<String>,
344    /// NAICS code filter (exact).
345    #[builder(into)]
346    pub naics_code: Option<String>,
347    /// NAICS prefix filter.
348    #[builder(into)]
349    pub naics_starts_with: Option<String>,
350    /// Server-side sort spec.
351    #[builder(into)]
352    pub ordering: Option<String>,
353    /// Free-text search filter.
354    #[builder(into)]
355    pub search: Option<String>,
356    /// Source-system filter (e.g. `"SAM"`).
357    #[builder(into)]
358    pub source_system: Option<String>,
359    /// Status filter (e.g. `"active"`).
360    #[builder(into)]
361    pub status: Option<String>,
362
363    /// Escape hatch for filter keys not yet first-classed on this struct.
364    #[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// ---------------------------------------------------------------------------
412// Grants
413// ---------------------------------------------------------------------------
414
415/// Options for [`Client::list_grants`] and [`Client::iterate_grants`].
416/// Mirrors `ListGrantsOptions` in the Go SDK.
417#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
418#[non_exhaustive]
419pub struct ListGrantsOptions {
420    /// 1-based page number.
421    #[builder(into)]
422    pub page: Option<u32>,
423    /// Page size.
424    #[builder(into)]
425    pub limit: Option<u32>,
426    /// Keyset cursor.
427    #[builder(into)]
428    pub cursor: Option<String>,
429    /// Comma-separated field selector.
430    #[builder(into)]
431    pub shape: Option<String>,
432    /// Collapse nested objects into dot-separated keys.
433    #[builder(default)]
434    pub flat: bool,
435    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
436    #[builder(default)]
437    pub flat_lists: bool,
438
439    /// Awarding agency filter.
440    #[builder(into)]
441    pub agency: Option<String>,
442    /// Applicant-types filter (CSV of grants.gov codes).
443    #[builder(into)]
444    pub applicant_types: Option<String>,
445    /// CFDA number filter.
446    #[builder(into)]
447    pub cfda_number: Option<String>,
448    /// Funding-categories filter (CSV).
449    #[builder(into)]
450    pub funding_categories: Option<String>,
451    /// Funding-instruments filter (CSV).
452    #[builder(into)]
453    pub funding_instruments: Option<String>,
454    /// Opportunity number filter.
455    #[builder(into)]
456    pub opportunity_number: Option<String>,
457    /// Server-side sort spec.
458    #[builder(into)]
459    pub ordering: Option<String>,
460    /// Lower bound for posted date.
461    #[builder(into)]
462    pub posted_date_after: Option<String>,
463    /// Upper bound for posted date.
464    #[builder(into)]
465    pub posted_date_before: Option<String>,
466    /// Lower bound for response date.
467    #[builder(into)]
468    pub response_date_after: Option<String>,
469    /// Upper bound for response date.
470    #[builder(into)]
471    pub response_date_before: Option<String>,
472    /// Free-text search filter.
473    #[builder(into)]
474    pub search: Option<String>,
475    /// Status filter (e.g. `"posted"`).
476    #[builder(into)]
477    pub status: Option<String>,
478
479    /// Escape hatch for filter keys not yet first-classed on this struct.
480    #[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// ---------------------------------------------------------------------------
547// Opportunity attachment search
548// ---------------------------------------------------------------------------
549
550/// Options for [`Client::search_opportunity_attachments`] — semantic search
551/// over the extracted text of opportunity attachments (SOWs, PWSs, J&As).
552///
553/// `q` is required; an empty `q` causes the call to return
554/// [`Error::Validation`] before any network request.
555#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
556#[non_exhaustive]
557pub struct SearchOpportunityAttachmentsOptions {
558    /// Natural-language query. Required.
559    #[builder(into)]
560    pub q: Option<String>,
561    /// Maximum number of matches to return. `None` / `0` means
562    /// "use the server default".
563    #[builder(into)]
564    pub top_k: Option<u32>,
565    /// When true, returns the matched attachment text alongside metadata.
566    /// Defaults to false to keep responses small.
567    #[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
583// ---------------------------------------------------------------------------
584// Client methods
585// ---------------------------------------------------------------------------
586
587impl Client {
588    /// `GET /api/opportunities/` — one page of opportunity records.
589    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    /// Stream every opportunity matching `opts`.
596    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    /// `GET /api/notices/` — one page of notice records.
608    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    /// Stream every notice matching `opts`.
615    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    /// `GET /api/forecasts/` — one page of forecast records.
627    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    /// Stream every forecast matching `opts`.
634    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    /// `GET /api/grants/` — one page of grant records.
646    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    /// 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/attachment-search/` — semantic search over
665    /// the extracted text of opportunity attachments (SOWs, PWSs, J&As).
666    ///
667    /// Returns [`Error::Validation`] when `opts.q` is missing or empty.
668    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}