Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ This project follows [Semantic Versioning](https://semver.org/).

#### Added

- **`naics_code` on `ListProtestsOptions`.** Filters protests by the NAICS code at issue, which only SBA OHA size and NAICS appeals carry; GAO and COFC records never match it.

- **`attachments(extracted_text)` — SLED document bodies on the Small plan and above** (Tango API 4.25.1; parity with tango-python, tango-node and tango-go). This SDK returns `Record`, so the leaf needs no type change — what it needed was saying so. Documented on `get_sled_opportunity`, on `SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE` and in `docs/API_REFERENCE.md`: the leaf must be **named** (no `SHAPE_SLED_*` constant includes it, and `attachments(*)` does not carry it, because the API resolves the body only for a caller who asked); the **key is absent rather than null** when the text is not being served; and a **contested document never returns text at any plan**. `suggested_shapes_do_not_name_the_paid_document_body` pins the constants. Searching document text stays ungated on every plan and returns no fragment of it.

- **State, local and education (SLED) procurement** (Tango API 4.25.0; parity with tango-python, tango-node and tango-go). `resources/sled.rs` adds `list_sled_opportunities` / `get_sled_opportunity`, `list_sled_opportunity_revisions`, `get_sled_coverage`, `list_sled_forecasts` / `get_sled_forecast`, plus `iterate_sled_opportunities` and `iterate_sled_forecasts`. Four `bon`-derived options builders (`ListSledOpportunitiesOptions`, `ListSledOpportunityRevisionsOptions`, `ListSledForecastsOptions`, `GetSledOptions`) name every one of the API's 27 solicitation filters and 13 forecast filters, and five `SHAPE_SLED_*` constants land in `shapes.rs`.
Expand All @@ -21,6 +23,15 @@ This project follows [Semantic Versioning](https://semver.org/).

`SHAPE_SLED_REVISIONS_MINIMAL` omits `changes` on purpose: the per-field before/after needs a Small plan, so naming it in a suggested shape would 403 a Free caller. `changed_fields` is in the shape and available at every plan.

#### Changed

- **`SHAPE_SLED_OPPORTUNITIES_MINIMAL` and `SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE` now include `delisted_at`**, matching the API's own default shapes. It is when the portal stopped listing a solicitation before its deadline, and it is what `status_reason = "delisted"` refers to.

#### Fixed

- **Protest enum values are documented in the casing the API returns.** `source_system` is lowercase (`gao`, `cofc`, `sba_oha`) and `outcome` is title-case (`Sustained`, `Denied`, …), not `"GAO"` / `"sustained"` as the rustdoc on `ListProtestsOptions` and `ProtestRecord` previously said. Filters were always case-insensitive, but code comparing returned values against the old examples would miss every match. The protests docs now also name SBA OHA as a source and list its extra outcomes (`Granted`, `Remanded`, `Reversed`, `Vacated`).
- The `ListProtestsOptions::agency` rustdoc now describes what the filter accepts: a name, abbreviation or code, with `|` for multiple values.

#### Documentation

- New **State & Local — SLED** section in `docs/API_REFERENCE.md` covering all six methods and both defaults that surprise people.
Expand Down
4 changes: 2 additions & 2 deletions crates/tango/src/models/protest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ pub struct ProtestRecord {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,

/// Source system (`"GAO"`, `"COFC"`, etc.).
/// Source system: `"gao"`, `"cofc"` or `"sba_oha"` (lowercase).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_system: Option<String>,

/// Outcome label (`"sustained"`, `"denied"`, …).
/// Outcome: `"Denied"`, `"Dismissed"`, `"Withdrawn"` or `"Sustained"`; SBA OHA adds `"Granted"`, `"Remanded"`, `"Reversed"` and `"Vacated"`. `None` while a case is pending.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<String>,

Expand Down
40 changes: 27 additions & 13 deletions crates/tango/src/resources/protests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
//! `GET /api/protests/` — bid-protest records from GAO and the U.S. Court of
//! Federal Claims.
//! `GET /api/protests/` — bid-protest records from GAO, the U.S. Court of Federal Claims (COFC), and the SBA Office of Hearings and Appeals (SBA OHA).

use crate::client::Client;
use crate::error::{Error, Result};
Expand Down Expand Up @@ -41,16 +40,20 @@ pub struct ListProtestsOptions {
#[builder(default)]
pub flat_lists: bool,

/// Source system filter (`"GAO"` or `"COFC"`).
/// Source system filter: `"gao"`, `"cofc"` or `"sba_oha"`.
///
/// The API returns these lowercase; the filter itself is case-insensitive.
#[builder(into)]
pub source_system: Option<String>,
/// Outcome label (`"sustained"`, `"denied"`, …).
/// Outcome filter: `"Denied"`, `"Dismissed"`, `"Withdrawn"` or `"Sustained"`; SBA OHA adds `"Granted"`, `"Remanded"`, `"Reversed"` and `"Vacated"`.
///
/// The API returns these title-case; the filter itself is case-insensitive.
#[builder(into)]
pub outcome: Option<String>,
/// Case type filter.
#[builder(into)]
pub case_type: Option<String>,
/// Agency filter (CGAC code or name, depending on source system).
/// Agency filter: a name, abbreviation, or code (CGAC, FPDS, AAC). Multi-value OR via `|`.
#[builder(into)]
pub agency: Option<String>,
/// Source-system case number filter.
Expand All @@ -59,6 +62,9 @@ pub struct ListProtestsOptions {
/// Solicitation number filter.
#[builder(into)]
pub solicitation_number: Option<String>,
/// NAICS code at issue. Only SBA OHA size and NAICS appeals carry one, so GAO and COFC records never match.
#[builder(into)]
pub naics_code: Option<String>,
/// Protester name filter.
#[builder(into)]
pub protester: Option<String>,
Expand Down Expand Up @@ -106,6 +112,7 @@ impl ListProtestsOptions {
"solicitation_number",
self.solicitation_number.as_deref(),
);
push_opt(&mut q, "naics_code", self.naics_code.as_deref());
push_opt(&mut q, "protester", self.protester.as_deref());
push_opt(&mut q, "search", self.search.as_deref());
push_opt(&mut q, "filed_date_after", self.filed_date_after.as_deref());
Expand Down Expand Up @@ -215,8 +222,8 @@ mod tests {
#[test]
fn list_protests_all_filters_emit() {
let opts = ListProtestsOptions::builder()
.source_system("GAO")
.outcome("sustained")
.source_system("gao")
.outcome("Sustained")
.case_type("Bid Protest")
.agency("9700")
.case_number("B-12345.1")
Expand All @@ -229,8 +236,8 @@ mod tests {
.decision_date_before("2024-12-31")
.build();
let q = opts.to_query();
assert_eq!(get_q(&q, "source_system").as_deref(), Some("GAO"));
assert_eq!(get_q(&q, "outcome").as_deref(), Some("sustained"));
assert_eq!(get_q(&q, "source_system").as_deref(), Some("gao"));
assert_eq!(get_q(&q, "outcome").as_deref(), Some("Sustained"));
assert_eq!(get_q(&q, "case_type").as_deref(), Some("Bid Protest"));
assert_eq!(get_q(&q, "agency").as_deref(), Some("9700"));
assert_eq!(get_q(&q, "case_number").as_deref(), Some("B-12345.1"));
Expand All @@ -252,6 +259,13 @@ mod tests {
);
}

#[test]
fn list_protests_naics_code_emits() {
let opts = ListProtestsOptions::builder().naics_code("541512").build();
let q = opts.to_query();
assert_eq!(get_q(&q, "naics_code").as_deref(), Some("541512"));
}

#[test]
fn list_protests_zero_value_omitted() {
let opts = ListProtestsOptions::builder().build();
Expand Down Expand Up @@ -312,8 +326,8 @@ mod tests {
"case_id": "b-12345-1",
"case_number": "B-12345.1",
"title": "Acme Corp Protest",
"source_system": "GAO",
"outcome": "sustained",
"source_system": "gao",
"outcome": "Sustained",
"filed_date": "2024-01-15",
"decision_date": "2024-04-20",
"agency": {"code": "9700", "name": "DoD"},
Expand All @@ -323,8 +337,8 @@ mod tests {
assert_eq!(rec.case_id.as_deref(), Some("b-12345-1"));
assert_eq!(rec.case_number.as_deref(), Some("B-12345.1"));
assert_eq!(rec.title.as_deref(), Some("Acme Corp Protest"));
assert_eq!(rec.source_system.as_deref(), Some("GAO"));
assert_eq!(rec.outcome.as_deref(), Some("sustained"));
assert_eq!(rec.source_system.as_deref(), Some("gao"));
assert_eq!(rec.outcome.as_deref(), Some("Sustained"));
assert_eq!(rec.filed_date.as_deref(), Some("2024-01-15"));
assert_eq!(rec.decision_date.as_deref(), Some("2024-04-20"));
// Unknown / not-first-classed fields land in `extra` via #[serde(flatten)].
Expand Down
20 changes: 20 additions & 0 deletions crates/tango/src/resources/sled.rs
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,26 @@ mod tests {
}
}

/// The API's list and detail default shapes both carry `delisted_at`, the field that explains `status_reason = "delisted"`.
#[test]
fn suggested_opportunity_shapes_carry_delisted_at() {
for (name, shape) in [
(
"SHAPE_SLED_OPPORTUNITIES_MINIMAL",
crate::SHAPE_SLED_OPPORTUNITIES_MINIMAL,
),
(
"SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE",
crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE,
),
] {
assert!(
shape.split(',').any(|f| f == "delisted_at"),
"{name} should carry `delisted_at`: {shape}"
);
}
}

#[test]
fn list_sled_forecasts_filters_emit() {
let opts = ListSledForecastsOptions::builder()
Expand Down
6 changes: 4 additions & 2 deletions crates/tango/src/shapes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,11 @@ pub const SHAPE_ITDASHBOARD_INVESTMENTS_COMPREHENSIVE: &str = concat!(
/// `description` is detail-only on the API — its median is around 550
/// characters and its tail runs past 120,000 — so it is deliberately absent
/// here. Name it explicitly, or pass `verbose=true` via `extra`.
///
/// `delisted_at` is when the portal stopped listing the solicitation before its deadline; when set, `status` is `closed` and `status_reason` is `delisted`, unless the portal itself already called it closed, awarded or cancelled. It is null for a solicitation never delisted, or seen again since.
pub const SHAPE_SLED_OPPORTUNITIES_MINIMAL: &str = concat!(
"opportunity_id,solicitation_number,solicitation_type,title,state,",
"jurisdiction,agency,status,status_reason,posted_date,response_deadline,",
"jurisdiction,agency,status,status_reason,delisted_at,posted_date,response_deadline,",
"source_url,has_documents,first_seen_at,last_change_seen_at",
);

Expand All @@ -161,7 +163,7 @@ pub const SHAPE_SLED_OPPORTUNITIES_MINIMAL: &str = concat!(
pub const SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE: &str = concat!(
"opportunity_id,solicitation_number,solicitation_type,",
"solicitation_type_source,title,description,state,jurisdiction,agency,",
"status,status_reason,status_computed_at,source_status,source_url,",
"status,status_reason,delisted_at,status_computed_at,source_status,source_url,",
"posted_date,response_deadline,response_deadline_original,",
"bid_opening_date,bid_opening_raw,category_codes,has_documents,",
"first_seen_at,last_seen_at,last_change_seen_at,",
Expand Down
4 changes: 3 additions & 1 deletion docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ Options: `ListGsaElibraryContractsOptions`, `GetGsaElibraryContractOptions`.
| `list_protests(opts)` / `iterate_protests(opts)` | `GET /api/protests/` | `Page<Record>` / `PageStream<Record>` |
| `get_protest(case_id, opts)` | `GET /api/protests/{case_id}/` | **`ProtestRecord`** (typed) |

Records come from GAO, the U.S. Court of Federal Claims and the SBA Office of Hearings and Appeals. `source_system` is returned lowercase (`gao`, `cofc`, `sba_oha`) and `outcome` title-case (`Denied`, `Dismissed`, `Withdrawn`, `Sustained`; SBA OHA adds `Granted`, `Remanded`, `Reversed`, `Vacated`). Both filters are case-insensitive, but compare returned values in the API's casing. `naics_code` matches the NAICS code at issue in an SBA OHA size or NAICS appeal; GAO and COFC records carry none.

Options: `ListProtestsOptions`, `GetProtestOptions`. No `ordering` (server rejects it for this resource).

### State & Local — SLED (`sled.rs`) — **Beta**
Expand All @@ -147,7 +149,7 @@ Options: `ListSledOpportunitiesOptions`, `ListSledOpportunityRevisionsOptions`,

`list_sled_opportunities` deliberately does **not** synthesize `status=open` client-side: doing so would make `active = Some(false)` unreachable, since `active=false` is the complement of open rather than an independent value. `active`, `has_documents` and `source_declared` are `Option<bool>` for the same reason — `false` has to be distinguishable from unset.

**`status` is Tango's answer, not the portal's.** Derived from the portal's word, the deadline and the clock, and refreshed every fifteen minutes. The portal's own word is served as `source_status`, is frozen at last capture, and is **not** filterable — most of what it calls open already has a passed deadline.
**`status` is Tango's answer, not the portal's.** Derived from the portal's word, the delisting, the deadline and the clock, and refreshed every fifteen minutes. A solicitation the portal stopped listing before its deadline carries `delisted_at`, reads `status = "closed"` and `status_reason = "delisted"` (a portal's own closed, awarded or cancelled still takes precedence); both suggested opportunity shapes include `delisted_at`. The portal's own word is served as `source_status`, is frozen at last capture, and is **not** filterable — most of what it calls open already has a passed deadline.

**Category scheme tagging is mid-migration**, so `naics` matches only the small tagged share. Use `category_code` to match a code under any scheme, including the untagged pre-migration strings.

Expand Down
Loading