diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d345c..f2e48d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. @@ -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. diff --git a/crates/tango/src/models/protest.rs b/crates/tango/src/models/protest.rs index ecf2c93..5831a54 100644 --- a/crates/tango/src/models/protest.rs +++ b/crates/tango/src/models/protest.rs @@ -23,11 +23,11 @@ pub struct ProtestRecord { #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, - /// 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, - /// 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, diff --git a/crates/tango/src/resources/protests.rs b/crates/tango/src/resources/protests.rs index ee04a05..e50b2f7 100644 --- a/crates/tango/src/resources/protests.rs +++ b/crates/tango/src/resources/protests.rs @@ -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}; @@ -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, - /// 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, /// Case type filter. #[builder(into)] pub case_type: Option, - /// 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, /// Source-system case number filter. @@ -59,6 +62,9 @@ pub struct ListProtestsOptions { /// Solicitation number filter. #[builder(into)] pub solicitation_number: Option, + /// 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, /// Protester name filter. #[builder(into)] pub protester: Option, @@ -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()); @@ -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") @@ -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")); @@ -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(); @@ -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"}, @@ -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)]. diff --git a/crates/tango/src/resources/sled.rs b/crates/tango/src/resources/sled.rs index 1f80827..e4367fc 100644 --- a/crates/tango/src/resources/sled.rs +++ b/crates/tango/src/resources/sled.rs @@ -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() diff --git a/crates/tango/src/shapes.rs b/crates/tango/src/shapes.rs index dfeb91f..08a6d55 100644 --- a/crates/tango/src/shapes.rs +++ b/crates/tango/src/shapes.rs @@ -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", ); @@ -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,", diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index df6e256..04d7f96 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -124,6 +124,8 @@ Options: `ListGsaElibraryContractsOptions`, `GetGsaElibraryContractOptions`. | `list_protests(opts)` / `iterate_protests(opts)` | `GET /api/protests/` | `Page` / `PageStream` | | `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** @@ -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` 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.