Skip to main content

tango/resources/
sled.rs

1//! `GET /api/sled/opportunities/` and `GET /api/sled/forecasts/` — state, local
2//! and education (SLED) procurement.
3//!
4//! **Beta.** Coverage is partial and grows one jurisdiction at a time. There is
5//! no national SLED feed: every jurisdiction publishes on its own portal, and
6//! Tango reads them one at a time. A thin per-state result is therefore at least
7//! as likely to be a portal Tango does not read as a quiet market —
8//! [`Client::get_sled_coverage`] is what resolves that ambiguity.
9//!
10//! This data does not join to the federal data. There is no UEI, no PIID, no
11//! agency-hierarchy key and no NAICS/PSC crosswalk; the `organization(*)` expand
12//! here is three strings, not the federal 7-key office payload.
13
14use crate::client::Client;
15use crate::error::{Error, Result};
16use crate::internal::{apply_pagination, push_opt, push_opt_bool};
17use crate::pagination::{FetchFn, Page, PageStream};
18use crate::resources::agencies::urlencoding;
19use crate::Record;
20use bon::Builder;
21use std::collections::BTreeMap;
22use std::sync::Arc;
23
24/// Options for [`Client::list_sled_opportunities`] and
25/// [`Client::iterate_sled_opportunities`].
26///
27/// Two things behave unlike the federal endpoints.
28///
29/// Leaving both [`status`](Self::status) and [`active`](Self::active) unset
30/// returns **open solicitations only** — the API defaults the list to
31/// `status=open`, because only about a fifth of the corpus is open and portals
32/// drop a closed solicitation rather than restating it. Set `status` explicitly
33/// to page the whole corpus. [`Client::get_sled_opportunity`] returns a
34/// solicitation whatever its status.
35///
36/// `status` is Tango-derived liveness, refreshed every fifteen minutes. The
37/// portal's own word is served as `source_status`, is frozen at last capture,
38/// and is **not** filterable — most of what it calls open already has a passed
39/// deadline.
40#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
41#[non_exhaustive]
42pub struct ListSledOpportunitiesOptions {
43    /// 1-based page number.
44    #[builder(into)]
45    pub page: Option<u32>,
46    /// Page size (server caps at 100).
47    #[builder(into)]
48    pub limit: Option<u32>,
49    /// Keyset cursor.
50    #[builder(into)]
51    pub cursor: Option<String>,
52    /// Comma-separated field selector. Use
53    /// [`SHAPE_SLED_OPPORTUNITIES_MINIMAL`](crate::SHAPE_SLED_OPPORTUNITIES_MINIMAL)
54    /// or roll your own.
55    #[builder(into)]
56    pub shape: Option<String>,
57    /// Collapse nested objects into dot-separated keys.
58    #[builder(default)]
59    pub flat: bool,
60    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
61    #[builder(default)]
62    pub flat_lists: bool,
63
64    /// Two-letter state or territory code. Multi-value: `"TX|OK"`.
65    #[builder(into)]
66    pub state: Option<String>,
67    /// Level of government: `"state"`, `"local"`, `"education"`, or
68    /// `"unknown"` for the aggregator rows that cannot tell state from local.
69    #[builder(into)]
70    pub jurisdiction: Option<String>,
71    /// `"open"`, `"closed"`, `"awarded"`, `"cancelled"` or `"unknown"`. With
72    /// [`active`](Self::active) also unset the API returns open only;
73    /// `"unknown"` (standing rosters, dateless RFIs) is hidden by that default
74    /// and reachable with `"open|unknown"`.
75    #[builder(into)]
76    pub status: Option<String>,
77    /// Sugar for federal-shaped callers: `true` is `status=open`, `false` is its
78    /// complement (so it includes `unknown`). `Option<bool>` so that `false` is
79    /// a real filter value rather than an absent one.
80    #[builder(into)]
81    pub active: Option<bool>,
82    /// Substring match on the buyer's published text (min 2 characters). No code
83    /// resolution behind it — state agencies have no entry in the federal
84    /// organization tree.
85    #[builder(into)]
86    pub agency: Option<String>,
87    /// The number a human would quote. Null on roughly a third of the corpus,
88    /// where the portal publishes none.
89    #[builder(into)]
90    pub solicitation_number: Option<String>,
91    /// `"rfp"`, `"ifb"`, `"rfq"`, `"rfi"`, `"itb"`, `"sole_source"`, `"grant"`
92    /// or `"other"`. `"null"` (the portal states no type) is a distinct answer
93    /// from `"other"` (a type the vocabulary does not recognize).
94    #[builder(into)]
95    pub solicitation_type: Option<String>,
96    /// Whether the solicitation advertises at least one document.
97    #[builder(into)]
98    pub has_documents: Option<bool>,
99    /// Kind of the most recent substantive revision: `"deadline_change"`,
100    /// `"status_change"`, `"documents_added"`, `"documents_removed"`,
101    /// `"documents_replaced"`, `"title_change"` or `"content_change"`.
102    #[builder(into)]
103    pub revision_kind: Option<String>,
104
105    /// Exact match within the `naics` category scheme only. Thin on purpose:
106    /// scheme tagging is mid-migration, so only a small share of entries are
107    /// tagged NAICS. Prefer [`category_code`](Self::category_code) unless you
108    /// need scheme precision.
109    #[builder(into)]
110    pub naics: Option<String>,
111    /// Exact match within the `nigp` scheme, the most widely tagged of the four.
112    #[builder(into)]
113    pub nigp: Option<String>,
114    /// Exact match within the `unspsc` scheme.
115    #[builder(into)]
116    pub unspsc: Option<String>,
117    /// Exact match within the `text` scheme, where the code is the portal's own
118    /// human label.
119    #[builder(into)]
120    pub category: Option<String>,
121    /// Match a code under ANY scheme, including the untagged pre-migration
122    /// strings. The escape hatch when a scheme-specific filter returns less than
123    /// you expected.
124    #[builder(into)]
125    pub category_code: Option<String>,
126
127    /// Lower bound on `posted_date` (ISO `YYYY-MM-DD`, inclusive).
128    #[builder(into)]
129    pub posted_after: Option<String>,
130    /// Upper bound on `posted_date` (inclusive).
131    #[builder(into)]
132    pub posted_before: Option<String>,
133    /// Lower bound on `response_deadline` (inclusive).
134    #[builder(into)]
135    pub response_deadline_after: Option<String>,
136    /// Upper bound on `response_deadline` (inclusive).
137    #[builder(into)]
138    pub response_deadline_before: Option<String>,
139    /// Lower bound on when Tango FIRST OBSERVED the solicitation. The polling
140    /// primitive: everything new since your last call.
141    #[builder(into)]
142    pub first_seen_after: Option<String>,
143    /// Upper bound on first observation (inclusive).
144    #[builder(into)]
145    pub first_seen_before: Option<String>,
146    /// Lower bound on when Tango OBSERVED the last substantive change. A scrape
147    /// date, not an amendment date: a state portal restates one page in place
148    /// and publishes no amendment date, so the resolution is that state's crawl
149    /// cadence. The API exposes no upper bound on this field.
150    #[builder(into)]
151    pub change_seen_after: Option<String>,
152    /// Lower bound on when the Tango row last changed (inclusive).
153    #[builder(into)]
154    pub modified_after: Option<String>,
155    /// Upper bound on when the Tango row last changed (inclusive).
156    #[builder(into)]
157    pub modified_before: Option<String>,
158
159    /// Support filter identifying the source portal's platform family. In no
160    /// response shape, and not a stable value.
161    #[builder(into)]
162    pub platform: Option<String>,
163    /// Support filter — the portal's own identifier.
164    #[builder(into)]
165    pub native_id: Option<String>,
166    /// Support filter — Tango's opaque lake key. At most 500 values.
167    #[builder(into)]
168    pub external_id: Option<String>,
169
170    /// Ranked full-text search over title, agency, identifiers, category labels
171    /// and description, widened by the solicitations whose ATTACHMENT text
172    /// matched (min 2 characters). A row that matched on its description gains a
173    /// `snippet` field; a title-or-agency match carries none.
174    #[builder(into)]
175    pub search: Option<String>,
176    /// One of `rank`, `response_deadline`, `posted_date`, `first_seen_at`,
177    /// `last_seen_at`, `last_change_seen_at`, `modified`. `rank` requires a
178    /// non-empty [`search`](Self::search).
179    #[builder(into)]
180    pub ordering: Option<String>,
181
182    /// When `Some(true)`, add `description` and `contact` to each list row. `description` is detail-only by default because its longest values run past 120,000 characters.
183    pub verbose: Option<bool>,
184
185    /// Escape hatch for filter keys not yet first-classed on this struct.
186    #[builder(default)]
187    pub extra: BTreeMap<String, String>,
188}
189
190impl ListSledOpportunitiesOptions {
191    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
192        let mut q = Vec::new();
193        apply_pagination(
194            &mut q,
195            self.page,
196            self.limit,
197            self.cursor.as_deref(),
198            self.shape.as_deref(),
199            self.flat,
200            self.flat_lists,
201        );
202        push_opt(&mut q, "state", self.state.as_deref());
203        push_opt(&mut q, "jurisdiction", self.jurisdiction.as_deref());
204        push_opt(&mut q, "status", self.status.as_deref());
205        push_opt_bool(&mut q, "active", self.active);
206        push_opt(&mut q, "agency", self.agency.as_deref());
207        push_opt(
208            &mut q,
209            "solicitation_number",
210            self.solicitation_number.as_deref(),
211        );
212        push_opt(
213            &mut q,
214            "solicitation_type",
215            self.solicitation_type.as_deref(),
216        );
217        push_opt_bool(&mut q, "has_documents", self.has_documents);
218        push_opt(&mut q, "revision_kind", self.revision_kind.as_deref());
219        push_opt(&mut q, "naics", self.naics.as_deref());
220        push_opt(&mut q, "nigp", self.nigp.as_deref());
221        push_opt(&mut q, "unspsc", self.unspsc.as_deref());
222        push_opt(&mut q, "category", self.category.as_deref());
223        push_opt(&mut q, "category_code", self.category_code.as_deref());
224        push_opt(&mut q, "posted_after", self.posted_after.as_deref());
225        push_opt(&mut q, "posted_before", self.posted_before.as_deref());
226        push_opt(
227            &mut q,
228            "response_deadline_after",
229            self.response_deadline_after.as_deref(),
230        );
231        push_opt(
232            &mut q,
233            "response_deadline_before",
234            self.response_deadline_before.as_deref(),
235        );
236        push_opt(&mut q, "first_seen_after", self.first_seen_after.as_deref());
237        push_opt(
238            &mut q,
239            "first_seen_before",
240            self.first_seen_before.as_deref(),
241        );
242        push_opt(
243            &mut q,
244            "change_seen_after",
245            self.change_seen_after.as_deref(),
246        );
247        push_opt(&mut q, "modified_after", self.modified_after.as_deref());
248        push_opt(&mut q, "modified_before", self.modified_before.as_deref());
249        push_opt(&mut q, "platform", self.platform.as_deref());
250        push_opt(&mut q, "native_id", self.native_id.as_deref());
251        push_opt(&mut q, "external_id", self.external_id.as_deref());
252        push_opt(&mut q, "search", self.search.as_deref());
253        push_opt_bool(&mut q, "verbose", self.verbose);
254        push_opt(&mut q, "ordering", self.ordering.as_deref());
255        for (k, v) in &self.extra {
256            if !v.is_empty() {
257                q.push((k.clone(), v.clone()));
258            }
259        }
260        q
261    }
262}
263
264/// Options for [`Client::list_sled_opportunity_revisions`], the nested
265/// `/revisions/` route.
266#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
267#[non_exhaustive]
268pub struct ListSledOpportunityRevisionsOptions {
269    /// 1-based page number.
270    #[builder(into)]
271    pub page: Option<u32>,
272    /// Page size (server caps at 100).
273    #[builder(into)]
274    pub limit: Option<u32>,
275    /// Keyset cursor.
276    #[builder(into)]
277    pub cursor: Option<String>,
278    /// Comma-separated field selector. Use
279    /// [`SHAPE_SLED_REVISIONS_MINIMAL`](crate::SHAPE_SLED_REVISIONS_MINIMAL),
280    /// which omits the plan-gated `changes` leaf.
281    #[builder(into)]
282    pub shape: Option<String>,
283    /// Collapse nested objects into dot-separated keys.
284    #[builder(default)]
285    pub flat: bool,
286    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
287    #[builder(default)]
288    pub flat_lists: bool,
289
290    /// A revision kind, plus `"enrichment"` — which the `revisions(*)` expand
291    /// excludes and this route serves. Multi-value: use `|`.
292    #[builder(into)]
293    pub kind: Option<String>,
294    /// Whether a portal's own amendment marker moved at this emission. True on
295    /// about 5% of revisions; everything else is Tango inferring the change from
296    /// the diff.
297    #[builder(into)]
298    pub source_declared: Option<bool>,
299    /// Lower bound on `observed_at` (ISO `YYYY-MM-DD`, inclusive).
300    #[builder(into)]
301    pub observed_after: Option<String>,
302    /// Upper bound on `observed_at` (inclusive).
303    #[builder(into)]
304    pub observed_before: Option<String>,
305
306    /// Escape hatch for filter keys not yet first-classed on this struct.
307    #[builder(default)]
308    pub extra: BTreeMap<String, String>,
309}
310
311impl ListSledOpportunityRevisionsOptions {
312    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
313        let mut q = Vec::new();
314        apply_pagination(
315            &mut q,
316            self.page,
317            self.limit,
318            self.cursor.as_deref(),
319            self.shape.as_deref(),
320            self.flat,
321            self.flat_lists,
322        );
323        push_opt(&mut q, "kind", self.kind.as_deref());
324        push_opt_bool(&mut q, "source_declared", self.source_declared);
325        push_opt(&mut q, "observed_after", self.observed_after.as_deref());
326        push_opt(&mut q, "observed_before", self.observed_before.as_deref());
327        for (k, v) in &self.extra {
328            if !v.is_empty() {
329                q.push((k.clone(), v.clone()));
330            }
331        }
332        q
333    }
334}
335
336/// Options for [`Client::list_sled_forecasts`] and
337/// [`Client::iterate_sled_forecasts`].
338///
339/// Forecasts carry no liveness at all — there is no deadline to have passed, so
340/// there is no `status` field, no `active` field, and no open-only default.
341/// Currency is the caller's call from `estimated_advertisement_date`, which is
342/// the START of the published quarter rather than a posting date.
343#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
344#[non_exhaustive]
345pub struct ListSledForecastsOptions {
346    /// 1-based page number.
347    #[builder(into)]
348    pub page: Option<u32>,
349    /// Page size (server caps at 100).
350    #[builder(into)]
351    pub limit: Option<u32>,
352    /// Keyset cursor.
353    #[builder(into)]
354    pub cursor: Option<String>,
355    /// Comma-separated field selector. Use
356    /// [`SHAPE_SLED_FORECASTS_MINIMAL`](crate::SHAPE_SLED_FORECASTS_MINIMAL)
357    /// or roll your own.
358    #[builder(into)]
359    pub shape: Option<String>,
360    /// Collapse nested objects into dot-separated keys.
361    #[builder(default)]
362    pub flat: bool,
363    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
364    #[builder(default)]
365    pub flat_lists: bool,
366
367    /// Two-letter state code. Multi-value: use `|`.
368    #[builder(into)]
369    pub state: Option<String>,
370    /// Substring match on the buyer's published text (min 2 characters).
371    #[builder(into)]
372    pub agency: Option<String>,
373    /// The portal's own category word, passed through verbatim.
374    #[builder(into)]
375    pub procurement_category: Option<String>,
376    /// The portal's own method word, passed through verbatim.
377    #[builder(into)]
378    pub procurement_method: Option<String>,
379    /// The number the state expects to award under, where it publishes one in
380    /// advance.
381    #[builder(into)]
382    pub contract_number: Option<String>,
383    /// Substring match on the incumbent vendor's name as published. NOT resolved
384    /// to a Tango entity.
385    #[builder(into)]
386    pub incumbent_name: Option<String>,
387    /// Lower bound on the estimated advertisement date (inclusive). Remember the
388    /// date is a QUARTER START, not a posting date.
389    #[builder(into)]
390    pub advertisement_after: Option<String>,
391    /// Upper bound on the estimated advertisement date (inclusive).
392    #[builder(into)]
393    pub advertisement_before: Option<String>,
394    /// Lower bound on when Tango first observed the forecast (inclusive).
395    #[builder(into)]
396    pub first_seen_after: Option<String>,
397    /// Upper bound on first observation (inclusive).
398    #[builder(into)]
399    pub first_seen_before: Option<String>,
400    /// Lower bound on when the Tango row last changed (inclusive).
401    #[builder(into)]
402    pub modified_after: Option<String>,
403    /// Upper bound on when the Tango row last changed (inclusive).
404    #[builder(into)]
405    pub modified_before: Option<String>,
406    /// Ranked full-text search over title, agency and description (min 2
407    /// characters).
408    #[builder(into)]
409    pub search: Option<String>,
410    /// One of `rank`, `estimated_advertisement_date`, `first_seen_at`,
411    /// `last_seen_at`, `modified`. `rank` requires a non-empty
412    /// [`search`](Self::search).
413    #[builder(into)]
414    pub ordering: Option<String>,
415
416    /// Escape hatch for filter keys not yet first-classed on this struct.
417    #[builder(default)]
418    pub extra: BTreeMap<String, String>,
419}
420
421impl ListSledForecastsOptions {
422    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
423        let mut q = Vec::new();
424        apply_pagination(
425            &mut q,
426            self.page,
427            self.limit,
428            self.cursor.as_deref(),
429            self.shape.as_deref(),
430            self.flat,
431            self.flat_lists,
432        );
433        push_opt(&mut q, "state", self.state.as_deref());
434        push_opt(&mut q, "agency", self.agency.as_deref());
435        push_opt(
436            &mut q,
437            "procurement_category",
438            self.procurement_category.as_deref(),
439        );
440        push_opt(
441            &mut q,
442            "procurement_method",
443            self.procurement_method.as_deref(),
444        );
445        push_opt(&mut q, "contract_number", self.contract_number.as_deref());
446        push_opt(&mut q, "incumbent_name", self.incumbent_name.as_deref());
447        push_opt(
448            &mut q,
449            "advertisement_after",
450            self.advertisement_after.as_deref(),
451        );
452        push_opt(
453            &mut q,
454            "advertisement_before",
455            self.advertisement_before.as_deref(),
456        );
457        push_opt(&mut q, "first_seen_after", self.first_seen_after.as_deref());
458        push_opt(
459            &mut q,
460            "first_seen_before",
461            self.first_seen_before.as_deref(),
462        );
463        push_opt(&mut q, "modified_after", self.modified_after.as_deref());
464        push_opt(&mut q, "modified_before", self.modified_before.as_deref());
465        push_opt(&mut q, "search", self.search.as_deref());
466        push_opt(&mut q, "ordering", self.ordering.as_deref());
467        for (k, v) in &self.extra {
468            if !v.is_empty() {
469                q.push((k.clone(), v.clone()));
470            }
471        }
472        q
473    }
474}
475
476/// Options for [`Client::get_sled_opportunity`] and
477/// [`Client::get_sled_forecast`].
478#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
479#[non_exhaustive]
480pub struct GetSledOptions {
481    /// Shape selector. When empty, the server returns its default detail shape.
482    #[builder(into)]
483    pub shape: Option<String>,
484    /// Flatten nested objects into dot-separated keys.
485    #[builder(default)]
486    pub flat: bool,
487    /// When `flat=true`, also flatten list-valued nested fields.
488    #[builder(default)]
489    pub flat_lists: bool,
490}
491
492impl GetSledOptions {
493    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
494        let mut q = Vec::new();
495        push_opt(&mut q, "shape", self.shape.as_deref());
496        if self.flat {
497            q.push(("flat".into(), "true".into()));
498        }
499        if self.flat_lists {
500            q.push(("flat_lists".into(), "true".into()));
501        }
502        q
503    }
504}
505
506impl Client {
507    /// `GET /api/sled/opportunities/` — one page of state, local and education
508    /// solicitations.
509    ///
510    /// Leaving both `status` and `active` unset returns open solicitations only.
511    /// That default is the API's, and this method deliberately does not
512    /// synthesize one: sending `status=open` here would make `active = false`
513    /// unreachable, since it is the complement of open rather than an
514    /// independent value.
515    pub async fn list_sled_opportunities(
516        &self,
517        opts: ListSledOpportunitiesOptions,
518    ) -> Result<Page<Record>> {
519        let q = opts.to_query();
520        self.get_page("/api/sled/opportunities/", &q).await
521    }
522
523    /// `GET /api/sled/opportunities/{opportunity_id}/` — one solicitation,
524    /// whatever its status. The open-only default applies to the list endpoint,
525    /// not here.
526    ///
527    /// A document's body is available as the `attachments(extracted_text)` leaf
528    /// on a Small plan or above, from API version 4.25.1. It must be NAMED — no
529    /// suggested shape includes it and `attachments(*)` does not carry it — and
530    /// its key is ABSENT rather than null whenever the text is not being served:
531    /// below Small (where it is withheld and named in `meta.upgrade_hints`), on a
532    /// contested document, or where it could not be resolved. A contested
533    /// document never returns text at any plan, because its stored bytes
534    /// disagree with what the record advertised.
535    ///
536    /// Searching document text and reading it are separate: `search` matches
537    /// inside attachment text on every plan and returns no fragment of it.
538    pub async fn get_sled_opportunity(
539        &self,
540        opportunity_id: &str,
541        opts: Option<GetSledOptions>,
542    ) -> Result<Record> {
543        if opportunity_id.is_empty() {
544            return Err(Error::Validation {
545                message: "get_sled_opportunity: opportunity_id is required".into(),
546                response: None,
547            });
548        }
549        let q = opts.unwrap_or_default().to_query();
550        let path = format!("/api/sled/opportunities/{}/", urlencoding(opportunity_id));
551        self.get_json::<Record>(&path, &q).await
552    }
553
554    /// `GET /api/sled/opportunities/{opportunity_id}/revisions/` — one
555    /// solicitation's observed revision history.
556    ///
557    /// `observed_at` is the scrape that saw the change, not the date the agency
558    /// made it: no state portal emits amendment notices, so `kind` is Tango's
559    /// inference from the diff on about 95% of revisions, resolution is that
560    /// state's crawl cadence, and history starts when Tango began reading the
561    /// jurisdiction rather than when the solicitation was posted.
562    ///
563    /// Unlike the `revisions(*)` expand, this route serves `enrichment` rows —
564    /// Tango's own detail fetch filling in coverage rather than an agency
565    /// amendment. Pass `kind = "enrichment"` for only those. The per-field
566    /// before/after (`changes`) requires a Small plan; `changed_fields` names
567    /// what moved at every plan.
568    pub async fn list_sled_opportunity_revisions(
569        &self,
570        opportunity_id: &str,
571        opts: ListSledOpportunityRevisionsOptions,
572    ) -> Result<Page<Record>> {
573        if opportunity_id.is_empty() {
574            return Err(Error::Validation {
575                message: "list_sled_opportunity_revisions: opportunity_id is required".into(),
576                response: None,
577            });
578        }
579        let q = opts.to_query();
580        let path = format!(
581            "/api/sled/opportunities/{}/revisions/",
582            urlencoding(opportunity_id)
583        );
584        self.get_page(&path, &q).await
585    }
586
587    /// `GET /api/sled/opportunities/coverage/` — the per-state coverage rollup.
588    ///
589    /// Returns corpus totals plus one row per jurisdiction: the total, the count
590    /// in each of the five statuses, the jurisdiction levels present, and when a
591    /// solicitation there last changed. It answers one question — whether a thin
592    /// result for a state is a thin market or a portal Tango does not read.
593    ///
594    /// Every state row carries all five status buckets whether or not they have
595    /// rows, so a total and two buckets never invite subtraction. Takes no
596    /// parameters and is neither shaped nor paginated.
597    pub async fn get_sled_coverage(&self) -> Result<Record> {
598        self.get_json::<Record>("/api/sled/opportunities/coverage/", &[])
599            .await
600    }
601
602    /// `GET /api/sled/forecasts/` — one page of planned state procurements.
603    pub async fn list_sled_forecasts(
604        &self,
605        opts: ListSledForecastsOptions,
606    ) -> Result<Page<Record>> {
607        let q = opts.to_query();
608        self.get_page("/api/sled/forecasts/", &q).await
609    }
610
611    /// `GET /api/sled/forecasts/{forecast_id}/` — one forecast.
612    pub async fn get_sled_forecast(
613        &self,
614        forecast_id: &str,
615        opts: Option<GetSledOptions>,
616    ) -> Result<Record> {
617        if forecast_id.is_empty() {
618            return Err(Error::Validation {
619                message: "get_sled_forecast: forecast_id is required".into(),
620                response: None,
621            });
622        }
623        let q = opts.unwrap_or_default().to_query();
624        let path = format!("/api/sled/forecasts/{}/", urlencoding(forecast_id));
625        self.get_json::<Record>(&path, &q).await
626    }
627
628    /// Stream every SLED solicitation matching `opts`.
629    pub fn iterate_sled_opportunities(
630        &self,
631        opts: ListSledOpportunitiesOptions,
632    ) -> PageStream<Record> {
633        let opts = Arc::new(opts);
634        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
635            let mut next = (*opts).clone();
636            next.page = page;
637            next.cursor = cursor;
638            Box::pin(async move { client.list_sled_opportunities(next).await })
639        });
640        PageStream::new(self.clone(), fetch)
641    }
642
643    /// Stream every SLED forecast matching `opts`.
644    pub fn iterate_sled_forecasts(&self, opts: ListSledForecastsOptions) -> PageStream<Record> {
645        let opts = Arc::new(opts);
646        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
647            let mut next = (*opts).clone();
648            next.page = page;
649            next.cursor = cursor;
650            Box::pin(async move { client.list_sled_forecasts(next).await })
651        });
652        PageStream::new(self.clone(), fetch)
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
661        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
662    }
663
664    #[test]
665    fn list_sled_opportunities_all_filters_emit() {
666        let opts = ListSledOpportunitiesOptions::builder()
667            .state("TX|OK")
668            .jurisdiction("local|education")
669            .status("open|unknown")
670            .active(true)
671            .agency("Texas Commission")
672            .solicitation_number("RFP-2026-001")
673            .solicitation_type("rfp")
674            .has_documents(true)
675            .revision_kind("deadline_change")
676            .naics("541620")
677            .nigp("962-47")
678            .unspsc("77101500")
679            .category("Environmental Services")
680            .category_code("541620")
681            .posted_after("2026-01-01")
682            .posted_before("2026-12-31")
683            .response_deadline_after("2026-09-01")
684            .response_deadline_before("2026-10-01")
685            .first_seen_after("2026-09-01")
686            .first_seen_before("2026-09-30")
687            .change_seen_after("2026-09-05")
688            .modified_after("2026-09-01")
689            .modified_before("2026-09-30")
690            .platform("custom")
691            .native_id("abc-123")
692            .external_id("tx:custom:abc-123")
693            .search("environmental mitigation")
694            .ordering("response_deadline")
695            .build();
696        let q = opts.to_query();
697        for (key, want) in [
698            ("state", "TX|OK"),
699            ("jurisdiction", "local|education"),
700            ("status", "open|unknown"),
701            ("active", "true"),
702            ("agency", "Texas Commission"),
703            ("solicitation_number", "RFP-2026-001"),
704            ("solicitation_type", "rfp"),
705            ("has_documents", "true"),
706            ("revision_kind", "deadline_change"),
707            ("naics", "541620"),
708            ("nigp", "962-47"),
709            ("unspsc", "77101500"),
710            ("category", "Environmental Services"),
711            ("category_code", "541620"),
712            ("posted_after", "2026-01-01"),
713            ("posted_before", "2026-12-31"),
714            ("response_deadline_after", "2026-09-01"),
715            ("response_deadline_before", "2026-10-01"),
716            ("first_seen_after", "2026-09-01"),
717            ("first_seen_before", "2026-09-30"),
718            ("change_seen_after", "2026-09-05"),
719            ("modified_after", "2026-09-01"),
720            ("modified_before", "2026-09-30"),
721            ("platform", "custom"),
722            ("native_id", "abc-123"),
723            ("external_id", "tx:custom:abc-123"),
724            ("search", "environmental mitigation"),
725            ("ordering", "response_deadline"),
726        ] {
727            assert_eq!(get_q(&q, key).as_deref(), Some(want), "param {key}");
728        }
729    }
730
731    /// The open-only default is the API's. Synthesizing `status=open` here would
732    /// make `active = false` unreachable, since it is the complement of open.
733    #[test]
734    fn no_liveness_filter_is_sent_when_none_requested() {
735        let opts = ListSledOpportunitiesOptions::builder().state("TX").build();
736        let q = opts.to_query();
737        assert_eq!(get_q(&q, "state").as_deref(), Some("TX"));
738        assert_eq!(get_q(&q, "status"), None);
739        assert_eq!(get_q(&q, "active"), None);
740    }
741
742    #[test]
743    fn active_false_is_sent_not_dropped() {
744        let opts = ListSledOpportunitiesOptions::builder()
745            .active(false)
746            .build();
747        assert_eq!(get_q(&opts.to_query(), "active").as_deref(), Some("false"));
748    }
749
750    #[test]
751    fn has_documents_false_is_sent_not_dropped() {
752        let opts = ListSledOpportunitiesOptions::builder()
753            .has_documents(false)
754            .build();
755        assert_eq!(
756            get_q(&opts.to_query(), "has_documents").as_deref(),
757            Some("false")
758        );
759    }
760
761    /// `status` is Tango-derived liveness; the portal's frozen word is served but
762    /// not filterable.
763    #[test]
764    fn source_status_is_not_a_filter() {
765        let opts = ListSledOpportunitiesOptions::builder()
766            .status("closed")
767            .build();
768        let q = opts.to_query();
769        assert_eq!(get_q(&q, "status").as_deref(), Some("closed"));
770        assert_eq!(get_q(&q, "source_status"), None);
771    }
772
773    #[test]
774    fn list_sled_opportunities_zero_value_omitted() {
775        let opts = ListSledOpportunitiesOptions::builder().build();
776        let q = opts.to_query();
777        assert!(q.is_empty(), "expected empty query, got {q:?}");
778    }
779
780    #[test]
781    fn list_sled_opportunities_extra_emits() {
782        let mut extra = BTreeMap::new();
783        extra.insert("verbose".to_string(), "true".to_string());
784        let opts = ListSledOpportunitiesOptions::builder().extra(extra).build();
785        assert!(opts.to_query().contains(&("verbose".into(), "true".into())));
786    }
787
788    #[test]
789    fn revision_filters_emit() {
790        let opts = ListSledOpportunityRevisionsOptions::builder()
791            .kind("deadline_change")
792            .source_declared(true)
793            .observed_after("2026-09-01")
794            .observed_before("2026-09-30")
795            .build();
796        let q = opts.to_query();
797        assert_eq!(get_q(&q, "kind").as_deref(), Some("deadline_change"));
798        assert_eq!(get_q(&q, "source_declared").as_deref(), Some("true"));
799        assert_eq!(get_q(&q, "observed_after").as_deref(), Some("2026-09-01"));
800        assert_eq!(get_q(&q, "observed_before").as_deref(), Some("2026-09-30"));
801    }
802
803    /// The `revisions(*)` expand excludes enrichment rows; this route is how a
804    /// caller reaches them.
805    #[test]
806    fn revision_kind_reaches_enrichment() {
807        let opts = ListSledOpportunityRevisionsOptions::builder()
808            .kind("enrichment")
809            .build();
810        assert_eq!(
811            get_q(&opts.to_query(), "kind").as_deref(),
812            Some("enrichment")
813        );
814    }
815
816    /// `changes` needs a Small plan, so naming it in the suggested revision shape
817    /// would 403 a Free caller on a field they never asked to gate.
818    #[test]
819    fn revisions_shape_omits_plan_gated_changes() {
820        let shape = crate::SHAPE_SLED_REVISIONS_MINIMAL;
821        assert!(
822            !shape.split(',').any(|f| f == "changes"),
823            "SHAPE_SLED_REVISIONS_MINIMAL must not name the Small-gated `changes` leaf: {shape}"
824        );
825        assert!(
826            shape.contains("changed_fields"),
827            "SHAPE_SLED_REVISIONS_MINIMAL should carry `changed_fields`, which every plan can read: {shape}"
828        );
829    }
830
831    /// The API resolves the document body only for a caller who names it, so a
832    /// suggested shape naming it would make every detail fetch pay for it.
833    #[test]
834    fn suggested_shapes_do_not_name_the_paid_document_body() {
835        for (name, shape) in [
836            (
837                "SHAPE_SLED_OPPORTUNITIES_MINIMAL",
838                crate::SHAPE_SLED_OPPORTUNITIES_MINIMAL,
839            ),
840            (
841                "SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE",
842                crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE,
843            ),
844        ] {
845            assert!(
846                !shape.contains("extracted_text"),
847                "{name} must not name the Small-gated `extracted_text` leaf: {shape}"
848            );
849        }
850    }
851
852    /// The API's list and detail default shapes both carry `delisted_at`, the field that explains `status_reason = "delisted"`.
853    #[test]
854    fn suggested_opportunity_shapes_carry_delisted_at() {
855        for (name, shape) in [
856            (
857                "SHAPE_SLED_OPPORTUNITIES_MINIMAL",
858                crate::SHAPE_SLED_OPPORTUNITIES_MINIMAL,
859            ),
860            (
861                "SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE",
862                crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE,
863            ),
864        ] {
865            assert!(
866                shape.split(',').any(|f| f == "delisted_at"),
867                "{name} should carry `delisted_at`: {shape}"
868            );
869        }
870    }
871
872    #[test]
873    fn list_sled_forecasts_filters_emit() {
874        let opts = ListSledForecastsOptions::builder()
875            .state("MD")
876            .agency("Department of Transportation")
877            .procurement_category("Services")
878            .procurement_method("Competitive Sealed Proposals")
879            .contract_number("K-2026-001")
880            .incumbent_name("Acme")
881            .advertisement_after("2026-10-01")
882            .advertisement_before("2027-03-31")
883            .first_seen_after("2026-09-01")
884            .first_seen_before("2026-09-30")
885            .modified_after("2026-09-01")
886            .modified_before("2026-09-30")
887            .search("data center")
888            .ordering("estimated_advertisement_date")
889            .build();
890        let q = opts.to_query();
891        assert_eq!(get_q(&q, "state").as_deref(), Some("MD"));
892        assert_eq!(
893            get_q(&q, "procurement_method").as_deref(),
894            Some("Competitive Sealed Proposals")
895        );
896        assert_eq!(
897            get_q(&q, "advertisement_after").as_deref(),
898            Some("2026-10-01")
899        );
900        assert_eq!(get_q(&q, "search").as_deref(), Some("data center"));
901        // A forecast has no deadline, so there is no liveness filter to send.
902        assert_eq!(get_q(&q, "status"), None);
903        assert_eq!(get_q(&q, "active"), None);
904    }
905
906    #[test]
907    fn get_sled_options_emit() {
908        let opts = GetSledOptions::builder()
909            .shape(crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE)
910            .flat(true)
911            .flat_lists(true)
912            .build();
913        let q = opts.to_query();
914        assert_eq!(
915            get_q(&q, "shape").as_deref(),
916            Some(crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE)
917        );
918        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
919        assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
920    }
921
922    #[test]
923    fn cursor_wins_over_page() {
924        let opts = ListSledOpportunitiesOptions::builder()
925            .page(2u32)
926            .cursor("xyz".to_string())
927            .build();
928        let q = opts.to_query();
929        assert_eq!(get_q(&q, "cursor").as_deref(), Some("xyz"));
930        assert_eq!(get_q(&q, "page"), None);
931    }
932
933    #[tokio::test]
934    async fn get_sled_opportunity_validates_empty_id() {
935        let client = Client::builder().api_key("x").build().expect("client");
936        let err = client.get_sled_opportunity("", None).await.unwrap_err();
937        match err {
938            Error::Validation { message, .. } => {
939                assert!(message.contains("opportunity_id is required"));
940            }
941            other => panic!("expected Validation, got {other:?}"),
942        }
943    }
944
945    #[tokio::test]
946    async fn list_sled_opportunity_revisions_validates_empty_id() {
947        let client = Client::builder().api_key("x").build().expect("client");
948        let err = client
949            .list_sled_opportunity_revisions("", ListSledOpportunityRevisionsOptions::default())
950            .await
951            .unwrap_err();
952        match err {
953            Error::Validation { message, .. } => {
954                assert!(message.contains("opportunity_id is required"));
955            }
956            other => panic!("expected Validation, got {other:?}"),
957        }
958    }
959
960    #[tokio::test]
961    async fn get_sled_forecast_validates_empty_id() {
962        let client = Client::builder().api_key("x").build().expect("client");
963        let err = client.get_sled_forecast("", None).await.unwrap_err();
964        match err {
965            Error::Validation { message, .. } => {
966                assert!(message.contains("forecast_id is required"));
967            }
968            other => panic!("expected Validation, got {other:?}"),
969        }
970    }
971
972    #[test]
973    fn list_sled_opportunities_verbose_emits() {
974        let q = ListSledOpportunitiesOptions::builder()
975            .verbose(true)
976            .build()
977            .to_query();
978        assert_eq!(get_q(&q, "verbose").as_deref(), Some("true"));
979        let q = ListSledOpportunitiesOptions::builder().build().to_query();
980        assert_eq!(get_q(&q, "verbose"), None);
981    }
982}