1use 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#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
22#[non_exhaustive]
23pub struct ListContractAppealsOptions {
24 #[builder(into)]
26 pub page: Option<u32>,
27 #[builder(into)]
29 pub limit: Option<u32>,
30 #[builder(into)]
32 pub cursor: Option<String>,
33 #[builder(into)]
36 pub shape: Option<String>,
37 #[builder(default)]
39 pub flat: bool,
40 #[builder(default)]
42 pub flat_lists: bool,
43
44 #[builder(into)]
47 pub search: Option<String>,
48 #[builder(into)]
50 pub board: Option<String>,
51 #[builder(into)]
54 pub docket: Option<String>,
55 #[builder(into)]
57 pub appellant: Option<String>,
58 #[builder(into)]
60 pub judge: Option<String>,
61 #[builder(into)]
63 pub decision_type: Option<String>,
64 #[builder(into)]
67 pub listed: Option<bool>,
68 #[builder(into)]
70 pub document_id: Option<String>,
71
72 #[builder(into)]
74 pub decision_date_after: Option<String>,
75 #[builder(into)]
77 pub decision_date_before: Option<String>,
78
79 #[builder(into)]
82 pub ordering: Option<String>,
83
84 #[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#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
132#[non_exhaustive]
133pub struct GetContractAppealOptions {
134 #[builder(into)]
136 pub shape: Option<String>,
137 #[builder(default)]
139 pub flat: bool,
140 #[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 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 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 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 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 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}