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        let bytes = self.get_bytes("/api/contract_appeals/", &q).await?;
167        Page::decode(&bytes)
168    }
169
170    /// `GET /api/contract_appeals/{uuid}/` — fetch a single decision by UUID.
171    ///
172    /// Returns a typed [`ContractAppealRecord`]; forward-compatible server fields land in
173    /// [`ContractAppealRecord::extra`].
174    ///
175    /// `decision_text` — the full text of the decision — needs an Enterprise plan, and below that its key is ABSENT rather than null.
176    /// 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.
177    pub async fn get_contract_appeal(
178        &self,
179        uuid: &str,
180        opts: Option<GetContractAppealOptions>,
181    ) -> Result<ContractAppealRecord> {
182        if uuid.is_empty() {
183            return Err(Error::Validation {
184                message: "get_contract_appeal: uuid is required".into(),
185                response: None,
186            });
187        }
188        let q = opts.unwrap_or_default().to_query();
189        let path = format!("/api/contract_appeals/{}/", urlencoding(uuid));
190        self.get_json::<ContractAppealRecord>(&path, &q).await
191    }
192
193    /// Stream every contract-appeal decision matching `opts`.
194    pub fn iterate_contract_appeals(&self, opts: ListContractAppealsOptions) -> PageStream<Record> {
195        let opts = Arc::new(opts);
196        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
197            let mut next = (*opts).clone();
198            next.page = page;
199            next.cursor = cursor;
200            Box::pin(async move { client.list_contract_appeals(next).await })
201        });
202        PageStream::new(self.clone(), fetch)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
211        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
212    }
213
214    #[test]
215    fn list_contract_appeals_all_filters_emit() {
216        let opts = ListContractAppealsOptions::builder()
217            .search("differing site conditions")
218            .board("cbca")
219            .docket("CBCA 1234")
220            .appellant("Acme Construction")
221            .judge("Smith")
222            .decision_type("decision")
223            .listed(true)
224            .document_id("doc-9912")
225            .decision_date_after("2024-01-01")
226            .decision_date_before("2024-12-31")
227            .ordering("-decision_date")
228            .build();
229        let q = opts.to_query();
230        assert_eq!(
231            get_q(&q, "search").as_deref(),
232            Some("differing site conditions")
233        );
234        assert_eq!(get_q(&q, "board").as_deref(), Some("cbca"));
235        assert_eq!(get_q(&q, "docket").as_deref(), Some("CBCA 1234"));
236        assert_eq!(get_q(&q, "appellant").as_deref(), Some("Acme Construction"));
237        assert_eq!(get_q(&q, "judge").as_deref(), Some("Smith"));
238        assert_eq!(get_q(&q, "decision_type").as_deref(), Some("decision"));
239        assert_eq!(get_q(&q, "listed").as_deref(), Some("true"));
240        assert_eq!(get_q(&q, "document_id").as_deref(), Some("doc-9912"));
241        assert_eq!(
242            get_q(&q, "decision_date_after").as_deref(),
243            Some("2024-01-01")
244        );
245        assert_eq!(
246            get_q(&q, "decision_date_before").as_deref(),
247            Some("2024-12-31")
248        );
249        assert_eq!(get_q(&q, "ordering").as_deref(), Some("-decision_date"));
250    }
251
252    #[test]
253    fn list_contract_appeals_listed_false_is_a_filter_not_an_absence() {
254        let opts = ListContractAppealsOptions::builder().listed(false).build();
255        let q = opts.to_query();
256        assert_eq!(get_q(&q, "listed").as_deref(), Some("false"));
257    }
258
259    #[test]
260    fn list_contract_appeals_zero_value_omitted() {
261        let opts = ListContractAppealsOptions::builder().build();
262        let q = opts.to_query();
263        assert!(q.is_empty(), "expected empty query, got {q:?}");
264    }
265
266    #[test]
267    fn list_contract_appeals_cursor_wins_over_page() {
268        let opts = ListContractAppealsOptions::builder()
269            .page(2u32)
270            .cursor("xyz".to_string())
271            .build();
272        let q = opts.to_query();
273        assert_eq!(get_q(&q, "cursor").as_deref(), Some("xyz"));
274        assert_eq!(get_q(&q, "page"), None);
275    }
276
277    #[test]
278    fn list_contract_appeals_shape_emits() {
279        let opts = ListContractAppealsOptions::builder()
280            .shape(crate::SHAPE_CONTRACT_APPEALS_MINIMAL)
281            .flat(true)
282            .build();
283        let q = opts.to_query();
284        assert_eq!(
285            get_q(&q, "shape").as_deref(),
286            Some(crate::SHAPE_CONTRACT_APPEALS_MINIMAL)
287        );
288        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
289    }
290
291    #[test]
292    fn list_contract_appeals_extra_emits() {
293        let mut extra = BTreeMap::new();
294        extra.insert("custom_x".to_string(), "xv".to_string());
295        let opts = ListContractAppealsOptions::builder().extra(extra).build();
296        let q = opts.to_query();
297        assert!(q.contains(&("custom_x".into(), "xv".into())));
298    }
299
300    #[test]
301    fn minimal_shape_does_not_name_the_paid_decision_text() {
302        assert!(!crate::SHAPE_CONTRACT_APPEALS_MINIMAL.contains("decision_text"));
303    }
304
305    #[test]
306    fn get_contract_appeal_options_emit() {
307        let opts = GetContractAppealOptions::builder()
308            .shape("uuid,decision_text")
309            .flat(true)
310            .flat_lists(true)
311            .build();
312        let q = opts.to_query();
313        assert_eq!(get_q(&q, "shape").as_deref(), Some("uuid,decision_text"));
314        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
315        assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
316    }
317
318    #[test]
319    fn contract_appeal_record_decodes_from_sample_json() {
320        let value = serde_json::json!({
321            "uuid": "3f2a6c1e-8b7d-4a21-9f00-0d1c2e3b4a55",
322            "board": "cbca",
323            "docket_numbers": ["CBCA 1234", "CBCA 1235"],
324            "docket_source": "listing",
325            "docket_raw": "CBCA 1234, 1235",
326            "decision_date": "2024-04-20",
327            "decision_date_raw": "April 20, 2024",
328            "decision_date_repaired": false,
329            "appellant": "Acme Construction",
330            "judge": "Smith",
331            "decision_type": "decision",
332            "decision_type_raw": "DECISION",
333            "url": "https://example.gov/decisions/1234.pdf",
334            "document_id": "doc-9912",
335            "listing_url": "https://example.gov/decisions/2024",
336            "listing_year": 2024,
337            "first_listed_at": "2024-04-21T06:15:00Z",
338            "listed": true,
339            "text_status": "extracted",
340            "text_char_count": 48213,
341            "decision_text": "The appeal is sustained.",
342            "future_field": "still here"
343        });
344        let rec: ContractAppealRecord = serde_json::from_value(value).expect("decode");
345        assert_eq!(
346            rec.uuid.as_deref(),
347            Some("3f2a6c1e-8b7d-4a21-9f00-0d1c2e3b4a55")
348        );
349        assert_eq!(rec.board.as_deref(), Some("cbca"));
350        assert_eq!(
351            rec.docket_numbers.as_deref(),
352            Some(["CBCA 1234".to_string(), "CBCA 1235".to_string()].as_slice())
353        );
354        assert_eq!(rec.docket_source.as_deref(), Some("listing"));
355        assert_eq!(rec.docket_raw.as_deref(), Some("CBCA 1234, 1235"));
356        assert_eq!(rec.decision_date.as_deref(), Some("2024-04-20"));
357        assert_eq!(rec.decision_date_raw.as_deref(), Some("April 20, 2024"));
358        assert_eq!(rec.decision_date_repaired, Some(false));
359        assert_eq!(rec.appellant.as_deref(), Some("Acme Construction"));
360        assert_eq!(rec.judge.as_deref(), Some("Smith"));
361        assert_eq!(rec.decision_type.as_deref(), Some("decision"));
362        assert_eq!(rec.decision_type_raw.as_deref(), Some("DECISION"));
363        assert_eq!(
364            rec.url.as_deref(),
365            Some("https://example.gov/decisions/1234.pdf")
366        );
367        assert_eq!(rec.document_id.as_deref(), Some("doc-9912"));
368        assert_eq!(
369            rec.listing_url.as_deref(),
370            Some("https://example.gov/decisions/2024")
371        );
372        assert_eq!(rec.listing_year, Some(2024));
373        assert_eq!(rec.first_listed_at.as_deref(), Some("2024-04-21T06:15:00Z"));
374        assert_eq!(rec.listed, Some(true));
375        assert_eq!(rec.text_status.as_deref(), Some("extracted"));
376        assert_eq!(rec.text_char_count, Some(48213));
377        assert_eq!(
378            rec.decision_text.as_deref(),
379            Some("The appeal is sustained.")
380        );
381        // Unknown / not-first-classed fields land in `extra` via #[serde(flatten)].
382        assert_eq!(
383            rec.extra.get("future_field").and_then(|v| v.as_str()),
384            Some("still here")
385        );
386    }
387
388    #[test]
389    fn contract_appeal_record_decodes_without_decision_text() {
390        let value = serde_json::json!({
391            "uuid": "3f2a6c1e-8b7d-4a21-9f00-0d1c2e3b4a55",
392            "board": "asbca",
393            "decision_date": "2024-04-20",
394            "appellant": "Acme Construction",
395            "text_status": "extracted",
396            "text_char_count": 48213
397        });
398        let rec: ContractAppealRecord = serde_json::from_value(value).expect("decode");
399        // Below Enterprise the key is absent, not null — and the record still reports that text exists.
400        assert_eq!(rec.decision_text, None);
401        assert!(!rec.extra.contains_key("decision_text"));
402        assert_eq!(rec.text_status.as_deref(), Some("extracted"));
403        assert_eq!(rec.text_char_count, Some(48213));
404        assert_eq!(rec.docket_numbers, None);
405    }
406
407    #[test]
408    fn contract_appeal_record_round_trips_without_reintroducing_absent_keys() {
409        let rec = ContractAppealRecord {
410            uuid: Some("abc".into()),
411            board: Some("cbca".into()),
412            ..Default::default()
413        };
414        let value = serde_json::to_value(&rec).expect("serialize");
415        let obj = value.as_object().expect("object");
416        assert_eq!(obj.len(), 2);
417        assert!(!obj.contains_key("decision_text"));
418    }
419
420    #[tokio::test]
421    async fn get_contract_appeal_validates_empty_uuid() {
422        let client = Client::builder().api_key("x").build().expect("client");
423        let err = client.get_contract_appeal("", None).await.unwrap_err();
424        match err {
425            Error::Validation { message, .. } => {
426                assert!(message.contains("uuid is required"));
427            }
428            other => panic!("expected Validation, got {other:?}"),
429        }
430    }
431}