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 self.get_page("/api/contract_appeals/", &q).await
167 }
168
169 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 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 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 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}