Skip to main content

tango/resources/
contract_appeals.rs

1//! `GET /api/contract_appeals/` — Contract Disputes Act appeal decisions from the Civilian Board of Contract Appeals (CBCA) and the Armed Services Board of Contract Appeals (ASBCA).
2//!
3//! These are disputes under an existing contract — claims, terminations, delays, defective specifications — decided by a board.
4//! They are NOT bid protests, which challenge an award before performance and live on [`Client::list_protests`](crate::Client::list_protests).
5//! A company can appear in both corpora, and nothing joins the two.
6
7use crate::client::Client;
8use crate::error::{Error, Result};
9use crate::internal::{apply_pagination, push_opt, push_opt_bool};
10use crate::models::ContractAppealRecord;
11use crate::pagination::{FetchFn, Page, PageStream};
12use crate::resources::agencies::urlencoding;
13use crate::Record;
14use bon::Builder;
15use std::collections::BTreeMap;
16use std::sync::Arc;
17
18/// Options for [`Client::list_contract_appeals`] and [`Client::iterate_contract_appeals`].
19///
20/// The `_after` / `_before` date suffixes mirror the Python SDK's naming.
21#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
22#[non_exhaustive]
23pub struct ListContractAppealsOptions {
24    /// 1-based page number.
25    #[builder(into)]
26    pub page: Option<u32>,
27    /// Page size (server caps at 100).
28    #[builder(into)]
29    pub limit: Option<u32>,
30    /// Keyset cursor.
31    #[builder(into)]
32    pub cursor: Option<String>,
33    /// Comma-separated field selector. Use
34    /// [`SHAPE_CONTRACT_APPEALS_MINIMAL`](crate::SHAPE_CONTRACT_APPEALS_MINIMAL) or roll your own.
35    #[builder(into)]
36    pub shape: Option<String>,
37    /// Collapse nested objects into dot-separated keys.
38    #[builder(default)]
39    pub flat: bool,
40    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
41    #[builder(default)]
42    pub flat_lists: bool,
43
44    /// Full-text search over the decision.
45    /// Required for `ordering = "rank"`, which is otherwise meaningless.
46    #[builder(into)]
47    pub search: Option<String>,
48    /// Deciding board: `"cbca"` (civilian) or `"asbca"` (defense).
49    #[builder(into)]
50    pub board: Option<String>,
51    /// Docket number.
52    /// A consolidated decision carries several, and this matches any one of them.
53    #[builder(into)]
54    pub docket: Option<String>,
55    /// The contractor bringing the appeal.
56    #[builder(into)]
57    pub appellant: Option<String>,
58    /// The judge who wrote the decision.
59    #[builder(into)]
60    pub judge: Option<String>,
61    /// Normalized decision kind.
62    #[builder(into)]
63    pub decision_type: Option<String>,
64    /// Whether the decision is still on a current board listing.
65    /// `Option<bool>` so that `false` is a real filter value rather than an absent one.
66    #[builder(into)]
67    pub listed: Option<bool>,
68    /// The board's identifier for the decision document.
69    #[builder(into)]
70    pub document_id: Option<String>,
71
72    /// Lower bound on `decision_date` (ISO `YYYY-MM-DD`, inclusive).
73    #[builder(into)]
74    pub decision_date_after: Option<String>,
75    /// Upper bound on `decision_date` (inclusive).
76    #[builder(into)]
77    pub decision_date_before: Option<String>,
78
79    /// One of `decision_date`, `appellant`, `first_listed_at`, `rank`, each optionally `-` prefixed.
80    /// The server defaults to `-decision_date`. `rank` requires a non-empty [`search`](Self::search).
81    #[builder(into)]
82    pub ordering: Option<String>,
83
84    /// Escape hatch for filter keys not yet first-classed on this struct.
85    #[builder(default)]
86    pub extra: BTreeMap<String, String>,
87}
88
89impl ListContractAppealsOptions {
90    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
91        let mut q = Vec::new();
92        apply_pagination(
93            &mut q,
94            self.page,
95            self.limit,
96            self.cursor.as_deref(),
97            self.shape.as_deref(),
98            self.flat,
99            self.flat_lists,
100        );
101        push_opt(&mut q, "search", self.search.as_deref());
102        push_opt(&mut q, "board", self.board.as_deref());
103        push_opt(&mut q, "docket", self.docket.as_deref());
104        push_opt(&mut q, "appellant", self.appellant.as_deref());
105        push_opt(&mut q, "judge", self.judge.as_deref());
106        push_opt(&mut q, "decision_type", self.decision_type.as_deref());
107        push_opt_bool(&mut q, "listed", self.listed);
108        push_opt(&mut q, "document_id", self.document_id.as_deref());
109        push_opt(
110            &mut q,
111            "decision_date_after",
112            self.decision_date_after.as_deref(),
113        );
114        push_opt(
115            &mut q,
116            "decision_date_before",
117            self.decision_date_before.as_deref(),
118        );
119        push_opt(&mut q, "ordering", self.ordering.as_deref());
120        for (k, v) in &self.extra {
121            if !v.is_empty() {
122                q.push((k.clone(), v.clone()));
123            }
124        }
125        q
126    }
127}
128
129/// Options for [`Client::get_contract_appeal`].
130/// The detail endpoint returns a typed [`ContractAppealRecord`]; `shape` lets callers override the server's default.
131#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
132#[non_exhaustive]
133pub struct GetContractAppealOptions {
134    /// Shape selector. When empty, the server returns its default detail shape.
135    #[builder(into)]
136    pub shape: Option<String>,
137    /// Flatten nested objects into dot-separated keys.
138    #[builder(default)]
139    pub flat: bool,
140    /// When `flat=true`, also flatten list-valued nested fields.
141    #[builder(default)]
142    pub flat_lists: bool,
143}
144
145impl GetContractAppealOptions {
146    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
147        let mut q = Vec::new();
148        push_opt(&mut q, "shape", self.shape.as_deref());
149        if self.flat {
150            q.push(("flat".into(), "true".into()));
151        }
152        if self.flat_lists {
153            q.push(("flat_lists".into(), "true".into()));
154        }
155        q
156    }
157}
158
159impl Client {
160    /// `GET /api/contract_appeals/` — one page of board-of-contract-appeals decisions.
161    pub async fn list_contract_appeals(
162        &self,
163        opts: ListContractAppealsOptions,
164    ) -> Result<Page<Record>> {
165        let q = opts.to_query();
166        self.get_page("/api/contract_appeals/", &q).await
167    }
168
169    /// `GET /api/contract_appeals/{uuid}/` — fetch a single decision by UUID.
170    ///
171    /// Returns a typed [`ContractAppealRecord`]; forward-compatible server fields land in
172    /// [`ContractAppealRecord::extra`].
173    ///
174    /// `decision_text` — the full text of the decision — needs an Enterprise plan, and below that its key is ABSENT rather than null.
175    /// So `record.decision_text == None` means "not served to this caller", never "this decision has no text"; `text_status` and `text_char_count` answer that at every plan.
176    pub async fn get_contract_appeal(
177        &self,
178        uuid: &str,
179        opts: Option<GetContractAppealOptions>,
180    ) -> Result<ContractAppealRecord> {
181        if uuid.is_empty() {
182            return Err(Error::Validation {
183                message: "get_contract_appeal: uuid is required".into(),
184                response: None,
185            });
186        }
187        let q = opts.unwrap_or_default().to_query();
188        let path = format!("/api/contract_appeals/{}/", urlencoding(uuid));
189        self.get_json::<ContractAppealRecord>(&path, &q).await
190    }
191
192    /// Stream every contract-appeal decision matching `opts`.
193    pub fn iterate_contract_appeals(&self, opts: ListContractAppealsOptions) -> PageStream<Record> {
194        let opts = Arc::new(opts);
195        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
196            let mut next = (*opts).clone();
197            next.page = page;
198            next.cursor = cursor;
199            Box::pin(async move { client.list_contract_appeals(next).await })
200        });
201        PageStream::new(self.clone(), fetch)
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
210        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
211    }
212
213    #[test]
214    fn list_contract_appeals_all_filters_emit() {
215        let opts = ListContractAppealsOptions::builder()
216            .search("differing site conditions")
217            .board("cbca")
218            .docket("CBCA 1234")
219            .appellant("Acme Construction")
220            .judge("Smith")
221            .decision_type("decision")
222            .listed(true)
223            .document_id("doc-9912")
224            .decision_date_after("2024-01-01")
225            .decision_date_before("2024-12-31")
226            .ordering("-decision_date")
227            .build();
228        let q = opts.to_query();
229        assert_eq!(
230            get_q(&q, "search").as_deref(),
231            Some("differing site conditions")
232        );
233        assert_eq!(get_q(&q, "board").as_deref(), Some("cbca"));
234        assert_eq!(get_q(&q, "docket").as_deref(), Some("CBCA 1234"));
235        assert_eq!(get_q(&q, "appellant").as_deref(), Some("Acme Construction"));
236        assert_eq!(get_q(&q, "judge").as_deref(), Some("Smith"));
237        assert_eq!(get_q(&q, "decision_type").as_deref(), Some("decision"));
238        assert_eq!(get_q(&q, "listed").as_deref(), Some("true"));
239        assert_eq!(get_q(&q, "document_id").as_deref(), Some("doc-9912"));
240        assert_eq!(
241            get_q(&q, "decision_date_after").as_deref(),
242            Some("2024-01-01")
243        );
244        assert_eq!(
245            get_q(&q, "decision_date_before").as_deref(),
246            Some("2024-12-31")
247        );
248        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-decision_date"));
249    }
250
251    #[test]
252    fn list_contract_appeals_listed_false_is_a_filter_not_an_absence() {
253        let opts = ListContractAppealsOptions::builder().listed(false).build();
254        let q = opts.to_query();
255        assert_eq!(get_q(&q, "listed").as_deref(), Some("false"));
256    }
257
258    #[test]
259    fn list_contract_appeals_zero_value_omitted() {
260        let opts = ListContractAppealsOptions::builder().build();
261        let q = opts.to_query();
262        assert!(q.is_empty(), "expected empty query, got {q:?}");
263    }
264
265    #[test]
266    fn list_contract_appeals_cursor_wins_over_page() {
267        let opts = ListContractAppealsOptions::builder()
268            .page(2u32)
269            .cursor("xyz".to_string())
270            .build();
271        let q = opts.to_query();
272        assert_eq!(get_q(&q, "cursor").as_deref(), Some("xyz"));
273        assert_eq!(get_q(&q, "page"), None);
274    }
275
276    #[test]
277    fn list_contract_appeals_shape_emits() {
278        let opts = ListContractAppealsOptions::builder()
279            .shape(crate::SHAPE_CONTRACT_APPEALS_MINIMAL)
280            .flat(true)
281            .build();
282        let q = opts.to_query();
283        assert_eq!(
284            get_q(&q, "shape").as_deref(),
285            Some(crate::SHAPE_CONTRACT_APPEALS_MINIMAL)
286        );
287        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
288    }
289
290    #[test]
291    fn list_contract_appeals_extra_emits() {
292        let mut extra = BTreeMap::new();
293        extra.insert("custom_x".to_string(), "xv".to_string());
294        let opts = ListContractAppealsOptions::builder().extra(extra).build();
295        let q = opts.to_query();
296        assert!(q.contains(&("custom_x".into(), "xv".into())));
297    }
298
299    #[test]
300    fn minimal_shape_does_not_name_the_paid_decision_text() {
301        assert!(!crate::SHAPE_CONTRACT_APPEALS_MINIMAL.contains("decision_text"));
302    }
303
304    #[test]
305    fn get_contract_appeal_options_emit() {
306        let opts = GetContractAppealOptions::builder()
307            .shape("uuid,decision_text")
308            .flat(true)
309            .flat_lists(true)
310            .build();
311        let q = opts.to_query();
312        assert_eq!(get_q(&q, "shape").as_deref(), Some("uuid,decision_text"));
313        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
314        assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
315    }
316
317    #[test]
318    fn contract_appeal_record_decodes_from_sample_json() {
319        let value = serde_json::json!({
320            "uuid": "3f2a6c1e-8b7d-4a21-9f00-0d1c2e3b4a55",
321            "board": "cbca",
322            "docket_numbers": ["CBCA 1234", "CBCA 1235"],
323            "docket_source": "listing",
324            "docket_raw": "CBCA 1234, 1235",
325            "decision_date": "2024-04-20",
326            "decision_date_raw": "April 20, 2024",
327            "decision_date_repaired": false,
328            "appellant": "Acme Construction",
329            "judge": "Smith",
330            "decision_type": "decision",
331            "decision_type_raw": "DECISION",
332            "url": "https://example.gov/decisions/1234.pdf",
333            "document_id": "doc-9912",
334            "listing_url": "https://example.gov/decisions/2024",
335            "listing_year": 2024,
336            "first_listed_at": "2024-04-21T06:15:00Z",
337            "listed": true,
338            "text_status": "extracted",
339            "text_char_count": 48213,
340            "decision_text": "The appeal is sustained.",
341            "future_field": "still here"
342        });
343        let rec: ContractAppealRecord = serde_json::from_value(value).expect("decode");
344        assert_eq!(
345            rec.uuid.as_deref(),
346            Some("3f2a6c1e-8b7d-4a21-9f00-0d1c2e3b4a55")
347        );
348        assert_eq!(rec.board.as_deref(), Some("cbca"));
349        assert_eq!(
350            rec.docket_numbers.as_deref(),
351            Some(["CBCA 1234".to_string(), "CBCA 1235".to_string()].as_slice())
352        );
353        assert_eq!(rec.docket_source.as_deref(), Some("listing"));
354        assert_eq!(rec.docket_raw.as_deref(), Some("CBCA 1234, 1235"));
355        assert_eq!(rec.decision_date.as_deref(), Some("2024-04-20"));
356        assert_eq!(rec.decision_date_raw.as_deref(), Some("April 20, 2024"));
357        assert_eq!(rec.decision_date_repaired, Some(false));
358        assert_eq!(rec.appellant.as_deref(), Some("Acme Construction"));
359        assert_eq!(rec.judge.as_deref(), Some("Smith"));
360        assert_eq!(rec.decision_type.as_deref(), Some("decision"));
361        assert_eq!(rec.decision_type_raw.as_deref(), Some("DECISION"));
362        assert_eq!(
363            rec.url.as_deref(),
364            Some("https://example.gov/decisions/1234.pdf")
365        );
366        assert_eq!(rec.document_id.as_deref(), Some("doc-9912"));
367        assert_eq!(
368            rec.listing_url.as_deref(),
369            Some("https://example.gov/decisions/2024")
370        );
371        assert_eq!(rec.listing_year, Some(2024));
372        assert_eq!(rec.first_listed_at.as_deref(), Some("2024-04-21T06:15:00Z"));
373        assert_eq!(rec.listed, Some(true));
374        assert_eq!(rec.text_status.as_deref(), Some("extracted"));
375        assert_eq!(rec.text_char_count, Some(48213));
376        assert_eq!(
377            rec.decision_text.as_deref(),
378            Some("The appeal is sustained.")
379        );
380        // Unknown / not-first-classed fields land in `extra` via #[serde(flatten)].
381        assert_eq!(
382            rec.extra.get("future_field").and_then(|v| v.as_str()),
383            Some("still here")
384        );
385    }
386
387    #[test]
388    fn contract_appeal_record_decodes_without_decision_text() {
389        let value = serde_json::json!({
390            "uuid": "3f2a6c1e-8b7d-4a21-9f00-0d1c2e3b4a55",
391            "board": "asbca",
392            "decision_date": "2024-04-20",
393            "appellant": "Acme Construction",
394            "text_status": "extracted",
395            "text_char_count": 48213
396        });
397        let rec: ContractAppealRecord = serde_json::from_value(value).expect("decode");
398        // Below Enterprise the key is absent, not null — and the record still reports that text exists.
399        assert_eq!(rec.decision_text, None);
400        assert!(!rec.extra.contains_key("decision_text"));
401        assert_eq!(rec.text_status.as_deref(), Some("extracted"));
402        assert_eq!(rec.text_char_count, Some(48213));
403        assert_eq!(rec.docket_numbers, None);
404    }
405
406    #[test]
407    fn contract_appeal_record_round_trips_without_reintroducing_absent_keys() {
408        let rec = ContractAppealRecord {
409            uuid: Some("abc".into()),
410            board: Some("cbca".into()),
411            ..Default::default()
412        };
413        let value = serde_json::to_value(&rec).expect("serialize");
414        let obj = value.as_object().expect("object");
415        assert_eq!(obj.len(), 2);
416        assert!(!obj.contains_key("decision_text"));
417    }
418
419    #[tokio::test]
420    async fn get_contract_appeal_validates_empty_uuid() {
421        let client = Client::builder().api_key("x").build().expect("client");
422        let err = client.get_contract_appeal("", None).await.unwrap_err();
423        match err {
424            Error::Validation { message, .. } => {
425                assert!(message.contains("uuid is required"));
426            }
427            other => panic!("expected Validation, got {other:?}"),
428        }
429    }
430}