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        let bytes = self.get_bytes("/api/sled/opportunities/", &q).await?;
521        Page::decode(&bytes)
522    }
523
524    /// `GET /api/sled/opportunities/{opportunity_id}/` — one solicitation,
525    /// whatever its status. The open-only default applies to the list endpoint,
526    /// not here.
527    ///
528    /// A document's body is available as the `attachments(extracted_text)` leaf
529    /// on a Small plan or above, from API version 4.25.1. It must be NAMED — no
530    /// suggested shape includes it and `attachments(*)` does not carry it — and
531    /// its key is ABSENT rather than null whenever the text is not being served:
532    /// below Small (where it is withheld and named in `meta.upgrade_hints`), on a
533    /// contested document, or where it could not be resolved. A contested
534    /// document never returns text at any plan, because its stored bytes
535    /// disagree with what the record advertised.
536    ///
537    /// Searching document text and reading it are separate: `search` matches
538    /// inside attachment text on every plan and returns no fragment of it.
539    pub async fn get_sled_opportunity(
540        &self,
541        opportunity_id: &str,
542        opts: Option<GetSledOptions>,
543    ) -> Result<Record> {
544        if opportunity_id.is_empty() {
545            return Err(Error::Validation {
546                message: "get_sled_opportunity: opportunity_id is required".into(),
547                response: None,
548            });
549        }
550        let q = opts.unwrap_or_default().to_query();
551        let path = format!("/api/sled/opportunities/{}/", urlencoding(opportunity_id));
552        self.get_json::<Record>(&path, &q).await
553    }
554
555    /// `GET /api/sled/opportunities/{opportunity_id}/revisions/` — one
556    /// solicitation's observed revision history.
557    ///
558    /// `observed_at` is the scrape that saw the change, not the date the agency
559    /// made it: no state portal emits amendment notices, so `kind` is Tango's
560    /// inference from the diff on about 95% of revisions, resolution is that
561    /// state's crawl cadence, and history starts when Tango began reading the
562    /// jurisdiction rather than when the solicitation was posted.
563    ///
564    /// Unlike the `revisions(*)` expand, this route serves `enrichment` rows —
565    /// Tango's own detail fetch filling in coverage rather than an agency
566    /// amendment. Pass `kind = "enrichment"` for only those. The per-field
567    /// before/after (`changes`) requires a Small plan; `changed_fields` names
568    /// what moved at every plan.
569    pub async fn list_sled_opportunity_revisions(
570        &self,
571        opportunity_id: &str,
572        opts: ListSledOpportunityRevisionsOptions,
573    ) -> Result<Page<Record>> {
574        if opportunity_id.is_empty() {
575            return Err(Error::Validation {
576                message: "list_sled_opportunity_revisions: opportunity_id is required".into(),
577                response: None,
578            });
579        }
580        let q = opts.to_query();
581        let path = format!(
582            "/api/sled/opportunities/{}/revisions/",
583            urlencoding(opportunity_id)
584        );
585        let bytes = self.get_bytes(&path, &q).await?;
586        Page::decode(&bytes)
587    }
588
589    /// `GET /api/sled/opportunities/coverage/` — the per-state coverage rollup.
590    ///
591    /// Returns corpus totals plus one row per jurisdiction: the total, the count
592    /// in each of the five statuses, the jurisdiction levels present, and when a
593    /// solicitation there last changed. It answers one question — whether a thin
594    /// result for a state is a thin market or a portal Tango does not read.
595    ///
596    /// Every state row carries all five status buckets whether or not they have
597    /// rows, so a total and two buckets never invite subtraction. Takes no
598    /// parameters and is neither shaped nor paginated.
599    pub async fn get_sled_coverage(&self) -> Result<Record> {
600        self.get_json::<Record>("/api/sled/opportunities/coverage/", &[])
601            .await
602    }
603
604    /// `GET /api/sled/forecasts/` — one page of planned state procurements.
605    pub async fn list_sled_forecasts(
606        &self,
607        opts: ListSledForecastsOptions,
608    ) -> Result<Page<Record>> {
609        let q = opts.to_query();
610        let bytes = self.get_bytes("/api/sled/forecasts/", &q).await?;
611        Page::decode(&bytes)
612    }
613
614    /// `GET /api/sled/forecasts/{forecast_id}/` — one forecast.
615    pub async fn get_sled_forecast(
616        &self,
617        forecast_id: &str,
618        opts: Option<GetSledOptions>,
619    ) -> Result<Record> {
620        if forecast_id.is_empty() {
621            return Err(Error::Validation {
622                message: "get_sled_forecast: forecast_id is required".into(),
623                response: None,
624            });
625        }
626        let q = opts.unwrap_or_default().to_query();
627        let path = format!("/api/sled/forecasts/{}/", urlencoding(forecast_id));
628        self.get_json::<Record>(&path, &q).await
629    }
630
631    /// Stream every SLED solicitation matching `opts`.
632    pub fn iterate_sled_opportunities(
633        &self,
634        opts: ListSledOpportunitiesOptions,
635    ) -> 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_sled_opportunities(next).await })
642        });
643        PageStream::new(self.clone(), fetch)
644    }
645
646    /// Stream every SLED forecast matching `opts`.
647    pub fn iterate_sled_forecasts(&self, opts: ListSledForecastsOptions) -> PageStream<Record> {
648        let opts = Arc::new(opts);
649        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
650            let mut next = (*opts).clone();
651            next.page = page;
652            next.cursor = cursor;
653            Box::pin(async move { client.list_sled_forecasts(next).await })
654        });
655        PageStream::new(self.clone(), fetch)
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
664        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
665    }
666
667    #[test]
668    fn list_sled_opportunities_all_filters_emit() {
669        let opts = ListSledOpportunitiesOptions::builder()
670            .state("TX|OK")
671            .jurisdiction("local|education")
672            .status("open|unknown")
673            .active(true)
674            .agency("Texas Commission")
675            .solicitation_number("RFP-2026-001")
676            .solicitation_type("rfp")
677            .has_documents(true)
678            .revision_kind("deadline_change")
679            .naics("541620")
680            .nigp("962-47")
681            .unspsc("77101500")
682            .category("Environmental Services")
683            .category_code("541620")
684            .posted_after("2026-01-01")
685            .posted_before("2026-12-31")
686            .response_deadline_after("2026-09-01")
687            .response_deadline_before("2026-10-01")
688            .first_seen_after("2026-09-01")
689            .first_seen_before("2026-09-30")
690            .change_seen_after("2026-09-05")
691            .modified_after("2026-09-01")
692            .modified_before("2026-09-30")
693            .platform("custom")
694            .native_id("abc-123")
695            .external_id("tx:custom:abc-123")
696            .search("environmental mitigation")
697            .ordering("response_deadline")
698            .build();
699        let q = opts.to_query();
700        for (key, want) in [
701            ("state", "TX|OK"),
702            ("jurisdiction", "local|education"),
703            ("status", "open|unknown"),
704            ("active", "true"),
705            ("agency", "Texas Commission"),
706            ("solicitation_number", "RFP-2026-001"),
707            ("solicitation_type", "rfp"),
708            ("has_documents", "true"),
709            ("revision_kind", "deadline_change"),
710            ("naics", "541620"),
711            ("nigp", "962-47"),
712            ("unspsc", "77101500"),
713            ("category", "Environmental Services"),
714            ("category_code", "541620"),
715            ("posted_after", "2026-01-01"),
716            ("posted_before", "2026-12-31"),
717            ("response_deadline_after", "2026-09-01"),
718            ("response_deadline_before", "2026-10-01"),
719            ("first_seen_after", "2026-09-01"),
720            ("first_seen_before", "2026-09-30"),
721            ("change_seen_after", "2026-09-05"),
722            ("modified_after", "2026-09-01"),
723            ("modified_before", "2026-09-30"),
724            ("platform", "custom"),
725            ("native_id", "abc-123"),
726            ("external_id", "tx:custom:abc-123"),
727            ("search", "environmental mitigation"),
728            ("ordering", "response_deadline"),
729        ] {
730            assert_eq!(get_q(&q, key).as_deref(), Some(want), "param {key}");
731        }
732    }
733
734    /// The open-only default is the API's. Synthesizing `status=open` here would
735    /// make `active = false` unreachable, since it is the complement of open.
736    #[test]
737    fn no_liveness_filter_is_sent_when_none_requested() {
738        let opts = ListSledOpportunitiesOptions::builder().state("TX").build();
739        let q = opts.to_query();
740        assert_eq!(get_q(&q, "state").as_deref(), Some("TX"));
741        assert_eq!(get_q(&q, "status"), None);
742        assert_eq!(get_q(&q, "active"), None);
743    }
744
745    #[test]
746    fn active_false_is_sent_not_dropped() {
747        let opts = ListSledOpportunitiesOptions::builder()
748            .active(false)
749            .build();
750        assert_eq!(get_q(&opts.to_query(), "active").as_deref(), Some("false"));
751    }
752
753    #[test]
754    fn has_documents_false_is_sent_not_dropped() {
755        let opts = ListSledOpportunitiesOptions::builder()
756            .has_documents(false)
757            .build();
758        assert_eq!(
759            get_q(&opts.to_query(), "has_documents").as_deref(),
760            Some("false")
761        );
762    }
763
764    /// `status` is Tango-derived liveness; the portal's frozen word is served but
765    /// not filterable.
766    #[test]
767    fn source_status_is_not_a_filter() {
768        let opts = ListSledOpportunitiesOptions::builder()
769            .status("closed")
770            .build();
771        let q = opts.to_query();
772        assert_eq!(get_q(&q, "status").as_deref(), Some("closed"));
773        assert_eq!(get_q(&q, "source_status"), None);
774    }
775
776    #[test]
777    fn list_sled_opportunities_zero_value_omitted() {
778        let opts = ListSledOpportunitiesOptions::builder().build();
779        let q = opts.to_query();
780        assert!(q.is_empty(), "expected empty query, got {q:?}");
781    }
782
783    #[test]
784    fn list_sled_opportunities_extra_emits() {
785        let mut extra = BTreeMap::new();
786        extra.insert("verbose".to_string(), "true".to_string());
787        let opts = ListSledOpportunitiesOptions::builder().extra(extra).build();
788        assert!(opts.to_query().contains(&("verbose".into(), "true".into())));
789    }
790
791    #[test]
792    fn revision_filters_emit() {
793        let opts = ListSledOpportunityRevisionsOptions::builder()
794            .kind("deadline_change")
795            .source_declared(true)
796            .observed_after("2026-09-01")
797            .observed_before("2026-09-30")
798            .build();
799        let q = opts.to_query();
800        assert_eq!(get_q(&q, "kind").as_deref(), Some("deadline_change"));
801        assert_eq!(get_q(&q, "source_declared").as_deref(), Some("true"));
802        assert_eq!(get_q(&q, "observed_after").as_deref(), Some("2026-09-01"));
803        assert_eq!(get_q(&q, "observed_before").as_deref(), Some("2026-09-30"));
804    }
805
806    /// The `revisions(*)` expand excludes enrichment rows; this route is how a
807    /// caller reaches them.
808    #[test]
809    fn revision_kind_reaches_enrichment() {
810        let opts = ListSledOpportunityRevisionsOptions::builder()
811            .kind("enrichment")
812            .build();
813        assert_eq!(
814            get_q(&opts.to_query(), "kind").as_deref(),
815            Some("enrichment")
816        );
817    }
818
819    /// `changes` needs a Small plan, so naming it in the suggested revision shape
820    /// would 403 a Free caller on a field they never asked to gate.
821    #[test]
822    fn revisions_shape_omits_plan_gated_changes() {
823        let shape = crate::SHAPE_SLED_REVISIONS_MINIMAL;
824        assert!(
825            !shape.split(',').any(|f| f == "changes"),
826            "SHAPE_SLED_REVISIONS_MINIMAL must not name the Small-gated `changes` leaf: {shape}"
827        );
828        assert!(
829            shape.contains("changed_fields"),
830            "SHAPE_SLED_REVISIONS_MINIMAL should carry `changed_fields`, which every plan can read: {shape}"
831        );
832    }
833
834    /// The API resolves the document body only for a caller who names it, so a
835    /// suggested shape naming it would make every detail fetch pay for it.
836    #[test]
837    fn suggested_shapes_do_not_name_the_paid_document_body() {
838        for (name, shape) in [
839            (
840                "SHAPE_SLED_OPPORTUNITIES_MINIMAL",
841                crate::SHAPE_SLED_OPPORTUNITIES_MINIMAL,
842            ),
843            (
844                "SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE",
845                crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE,
846            ),
847        ] {
848            assert!(
849                !shape.contains("extracted_text"),
850                "{name} must not name the Small-gated `extracted_text` leaf: {shape}"
851            );
852        }
853    }
854
855    /// The API's list and detail default shapes both carry `delisted_at`, the field that explains `status_reason = "delisted"`.
856    #[test]
857    fn suggested_opportunity_shapes_carry_delisted_at() {
858        for (name, shape) in [
859            (
860                "SHAPE_SLED_OPPORTUNITIES_MINIMAL",
861                crate::SHAPE_SLED_OPPORTUNITIES_MINIMAL,
862            ),
863            (
864                "SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE",
865                crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE,
866            ),
867        ] {
868            assert!(
869                shape.split(',').any(|f| f == "delisted_at"),
870                "{name} should carry `delisted_at`: {shape}"
871            );
872        }
873    }
874
875    #[test]
876    fn list_sled_forecasts_filters_emit() {
877        let opts = ListSledForecastsOptions::builder()
878            .state("MD")
879            .agency("Department of Transportation")
880            .procurement_category("Services")
881            .procurement_method("Competitive Sealed Proposals")
882            .contract_number("K-2026-001")
883            .incumbent_name("Acme")
884            .advertisement_after("2026-10-01")
885            .advertisement_before("2027-03-31")
886            .first_seen_after("2026-09-01")
887            .first_seen_before("2026-09-30")
888            .modified_after("2026-09-01")
889            .modified_before("2026-09-30")
890            .search("data center")
891            .ordering("estimated_advertisement_date")
892            .build();
893        let q = opts.to_query();
894        assert_eq!(get_q(&q, "state").as_deref(), Some("MD"));
895        assert_eq!(
896            get_q(&q, "procurement_method").as_deref(),
897            Some("Competitive Sealed Proposals")
898        );
899        assert_eq!(
900            get_q(&q, "advertisement_after").as_deref(),
901            Some("2026-10-01")
902        );
903        assert_eq!(get_q(&q, "search").as_deref(), Some("data center"));
904        // A forecast has no deadline, so there is no liveness filter to send.
905        assert_eq!(get_q(&q, "status"), None);
906        assert_eq!(get_q(&q, "active"), None);
907    }
908
909    #[test]
910    fn get_sled_options_emit() {
911        let opts = GetSledOptions::builder()
912            .shape(crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE)
913            .flat(true)
914            .flat_lists(true)
915            .build();
916        let q = opts.to_query();
917        assert_eq!(
918            get_q(&q, "shape").as_deref(),
919            Some(crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE)
920        );
921        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
922        assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
923    }
924
925    #[test]
926    fn cursor_wins_over_page() {
927        let opts = ListSledOpportunitiesOptions::builder()
928            .page(2u32)
929            .cursor("xyz".to_string())
930            .build();
931        let q = opts.to_query();
932        assert_eq!(get_q(&q, "cursor").as_deref(), Some("xyz"));
933        assert_eq!(get_q(&q, "page"), None);
934    }
935
936    #[tokio::test]
937    async fn get_sled_opportunity_validates_empty_id() {
938        let client = Client::builder().api_key("x").build().expect("client");
939        let err = client.get_sled_opportunity("", None).await.unwrap_err();
940        match err {
941            Error::Validation { message, .. } => {
942                assert!(message.contains("opportunity_id is required"));
943            }
944            other => panic!("expected Validation, got {other:?}"),
945        }
946    }
947
948    #[tokio::test]
949    async fn list_sled_opportunity_revisions_validates_empty_id() {
950        let client = Client::builder().api_key("x").build().expect("client");
951        let err = client
952            .list_sled_opportunity_revisions("", ListSledOpportunityRevisionsOptions::default())
953            .await
954            .unwrap_err();
955        match err {
956            Error::Validation { message, .. } => {
957                assert!(message.contains("opportunity_id is required"));
958            }
959            other => panic!("expected Validation, got {other:?}"),
960        }
961    }
962
963    #[tokio::test]
964    async fn get_sled_forecast_validates_empty_id() {
965        let client = Client::builder().api_key("x").build().expect("client");
966        let err = client.get_sled_forecast("", None).await.unwrap_err();
967        match err {
968            Error::Validation { message, .. } => {
969                assert!(message.contains("forecast_id is required"));
970            }
971            other => panic!("expected Validation, got {other:?}"),
972        }
973    }
974
975    #[test]
976    fn list_sled_opportunities_verbose_emits() {
977        let q = ListSledOpportunitiesOptions::builder()
978            .verbose(true)
979            .build()
980            .to_query();
981        assert_eq!(get_q(&q, "verbose").as_deref(), Some("true"));
982        let q = ListSledOpportunitiesOptions::builder().build().to_query();
983        assert_eq!(get_q(&q, "verbose"), None);
984    }
985}