1use crate::client::Client;
5use crate::error::{Error, Result};
6use crate::internal::{apply_pagination, push_opt, push_opt_u32};
7use crate::pagination::{FetchFn, Page, PageStream};
8use crate::resources::agencies::urlencoding;
9use crate::Record;
10use bon::Builder;
11use std::collections::BTreeMap;
12use std::sync::Arc;
13
14#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
19#[non_exhaustive]
20pub struct ListVehiclesOptions {
21 #[builder(into)]
24 pub page: Option<u32>,
25 #[builder(into)]
27 pub limit: Option<u32>,
28 #[builder(into)]
30 pub cursor: Option<String>,
31 #[builder(into)]
34 pub shape: Option<String>,
35 #[builder(default)]
37 pub flat: bool,
38 #[builder(default)]
40 pub flat_lists: bool,
41
42 #[builder(into)]
45 pub joiner: Option<String>,
46 #[builder(into)]
48 pub search: Option<String>,
49 #[builder(into)]
51 pub vehicle_type: Option<String>,
52 #[builder(into)]
54 pub type_of_idc: Option<String>,
55 #[builder(into)]
57 pub contract_type: Option<String>,
58 #[builder(into)]
60 pub set_aside: Option<String>,
61 #[builder(into)]
63 pub who_can_use: Option<String>,
64 #[builder(into)]
66 pub naics_code: Option<String>,
67 #[builder(into)]
69 pub psc_code: Option<String>,
70 #[builder(into)]
72 pub program_acronym: Option<String>,
73 #[builder(into)]
75 pub agency: Option<String>,
76 #[builder(into)]
78 pub organization_id: Option<String>,
79 #[builder(into)]
81 pub total_obligated_min: Option<String>,
82 #[builder(into)]
84 pub total_obligated_max: Option<String>,
85 #[builder(into)]
87 pub idv_count_min: Option<u32>,
88 #[builder(into)]
90 pub idv_count_max: Option<u32>,
91 #[builder(into)]
93 pub order_count_min: Option<u32>,
94 #[builder(into)]
96 pub order_count_max: Option<u32>,
97 #[builder(into)]
99 pub fiscal_year: Option<String>,
100 #[builder(into)]
102 pub award_date_after: Option<String>,
103 #[builder(into)]
105 pub award_date_before: Option<String>,
106 #[builder(into)]
108 pub last_date_to_order_after: Option<String>,
109 #[builder(into)]
111 pub last_date_to_order_before: Option<String>,
112 #[builder(into)]
114 pub ordering: Option<String>,
115
116 #[builder(default)]
118 pub extra: BTreeMap<String, String>,
119}
120
121impl ListVehiclesOptions {
122 fn to_query(&self) -> Vec<(String, String)> {
123 let mut q = Vec::new();
124 apply_pagination(
125 &mut q,
126 self.page,
127 self.limit,
128 self.cursor.as_deref(),
129 self.shape.as_deref(),
130 self.flat,
131 self.flat_lists,
132 );
133 push_opt(&mut q, "joiner", self.joiner.as_deref());
134 push_opt(&mut q, "search", self.search.as_deref());
135 push_opt(&mut q, "vehicle_type", self.vehicle_type.as_deref());
136 push_opt(&mut q, "type_of_idc", self.type_of_idc.as_deref());
137 push_opt(&mut q, "contract_type", self.contract_type.as_deref());
138 push_opt(&mut q, "set_aside", self.set_aside.as_deref());
139 push_opt(&mut q, "who_can_use", self.who_can_use.as_deref());
140 push_opt(&mut q, "naics_code", self.naics_code.as_deref());
141 push_opt(&mut q, "psc_code", self.psc_code.as_deref());
142 push_opt(&mut q, "program_acronym", self.program_acronym.as_deref());
143 push_opt(&mut q, "agency", self.agency.as_deref());
144 push_opt(&mut q, "organization_id", self.organization_id.as_deref());
145 push_opt(
146 &mut q,
147 "total_obligated_min",
148 self.total_obligated_min.as_deref(),
149 );
150 push_opt(
151 &mut q,
152 "total_obligated_max",
153 self.total_obligated_max.as_deref(),
154 );
155 push_opt_u32(&mut q, "idv_count_min", self.idv_count_min);
156 push_opt_u32(&mut q, "idv_count_max", self.idv_count_max);
157 push_opt_u32(&mut q, "order_count_min", self.order_count_min);
158 push_opt_u32(&mut q, "order_count_max", self.order_count_max);
159 push_opt(&mut q, "fiscal_year", self.fiscal_year.as_deref());
160 push_opt(&mut q, "award_date_after", self.award_date_after.as_deref());
161 push_opt(
162 &mut q,
163 "award_date_before",
164 self.award_date_before.as_deref(),
165 );
166 push_opt(
167 &mut q,
168 "last_date_to_order_after",
169 self.last_date_to_order_after.as_deref(),
170 );
171 push_opt(
172 &mut q,
173 "last_date_to_order_before",
174 self.last_date_to_order_before.as_deref(),
175 );
176 push_opt(&mut q, "ordering", self.ordering.as_deref());
177
178 for (k, v) in &self.extra {
179 if !v.is_empty() {
180 q.push((k.clone(), v.clone()));
181 }
182 }
183 q
184 }
185}
186
187#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
190#[non_exhaustive]
191pub struct GetVehicleOptions {
192 #[builder(into)]
194 pub shape: Option<String>,
195 #[builder(default)]
197 pub flat: bool,
198 #[builder(default)]
200 pub flat_lists: bool,
201}
202
203impl GetVehicleOptions {
204 fn to_query(&self) -> Vec<(String, String)> {
205 let mut q = Vec::new();
206 push_opt(&mut q, "shape", self.shape.as_deref());
207 if self.flat {
208 q.push(("flat".into(), "true".into()));
209 }
210 if self.flat_lists {
211 q.push(("flat_lists".into(), "true".into()));
212 }
213 q
214 }
215}
216
217impl Client {
218 pub async fn list_vehicles(&self, opts: ListVehiclesOptions) -> Result<Page<Record>> {
220 let q = opts.to_query();
221 let bytes = self.get_bytes("/api/vehicles/", &q).await?;
222 Page::decode(&bytes)
223 }
224
225 pub async fn get_vehicle(&self, uuid: &str, opts: Option<GetVehicleOptions>) -> Result<Record> {
227 if uuid.is_empty() {
228 return Err(Error::Validation {
229 message: "get_vehicle: uuid is required".into(),
230 response: None,
231 });
232 }
233 let q = opts.unwrap_or_default().to_query();
234 let path = format!("/api/vehicles/{}/", urlencoding(uuid));
235 self.get_json::<Record>(&path, &q).await
236 }
237
238 pub fn iterate_vehicles(&self, opts: ListVehiclesOptions) -> PageStream<Record> {
240 let opts = Arc::new(opts);
241 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
242 let mut next = (*opts).clone();
243 next.page = page;
244 next.cursor = cursor;
245 Box::pin(async move { client.list_vehicles(next).await })
246 });
247 PageStream::new(self.clone(), fetch)
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
256 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
257 }
258
259 #[test]
260 fn list_vehicles_emits_string_filters() {
261 let opts = ListVehiclesOptions::builder()
262 .search("oasis")
263 .vehicle_type("IDC")
264 .type_of_idc("GWAC")
265 .set_aside("8A")
266 .naics_code("541512")
267 .psc_code("D302")
268 .program_acronym("OASIS")
269 .agency("9700")
270 .fiscal_year("2024")
271 .ordering("total_obligated")
272 .build();
273 let q = opts.to_query();
274 assert_eq!(get_q(&q, "search").as_deref(), Some("oasis"));
275 assert_eq!(get_q(&q, "vehicle_type").as_deref(), Some("IDC"));
276 assert_eq!(get_q(&q, "type_of_idc").as_deref(), Some("GWAC"));
277 assert_eq!(get_q(&q, "set_aside").as_deref(), Some("8A"));
278 assert_eq!(get_q(&q, "naics_code").as_deref(), Some("541512"));
279 assert_eq!(get_q(&q, "psc_code").as_deref(), Some("D302"));
280 assert_eq!(get_q(&q, "program_acronym").as_deref(), Some("OASIS"));
281 assert_eq!(get_q(&q, "agency").as_deref(), Some("9700"));
282 assert_eq!(get_q(&q, "fiscal_year").as_deref(), Some("2024"));
283 assert_eq!(get_q(&q, "ordering").as_deref(), Some("total_obligated"));
284 }
285
286 #[test]
287 fn list_vehicles_emits_int_counts() {
288 let opts = ListVehiclesOptions::builder()
289 .idv_count_min(3u32)
290 .idv_count_max(50u32)
291 .order_count_min(100u32)
292 .order_count_max(9999u32)
293 .build();
294 let q = opts.to_query();
295 assert_eq!(get_q(&q, "idv_count_min").as_deref(), Some("3"));
296 assert_eq!(get_q(&q, "idv_count_max").as_deref(), Some("50"));
297 assert_eq!(get_q(&q, "order_count_min").as_deref(), Some("100"));
298 assert_eq!(get_q(&q, "order_count_max").as_deref(), Some("9999"));
299 }
300
301 #[test]
302 fn list_vehicles_zero_counts_omitted() {
303 let opts = ListVehiclesOptions::builder()
304 .idv_count_min(0u32)
305 .order_count_max(0u32)
306 .build();
307 let q = opts.to_query();
308 assert!(!q.iter().any(|(k, _)| k == "idv_count_min"));
309 assert!(!q.iter().any(|(k, _)| k == "order_count_max"));
310 }
311
312 #[test]
313 fn list_vehicles_extra_map() {
314 let mut extra = BTreeMap::new();
315 extra.insert("version".to_string(), "v2".to_string());
316 let opts = ListVehiclesOptions::builder().extra(extra).build();
317 let q = opts.to_query();
318 assert_eq!(get_q(&q, "version").as_deref(), Some("v2"));
319 }
320
321 #[test]
322 fn get_vehicle_options_emits_shape_and_flat() {
323 let opts = GetVehicleOptions::builder()
324 .shape("vehicles(minimal)")
325 .flat(true)
326 .build();
327 let q = opts.to_query();
328 assert_eq!(get_q(&q, "shape").as_deref(), Some("vehicles(minimal)"));
329 assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
330 assert!(!q.iter().any(|(k, _)| k == "flat_lists"));
331 }
332
333 #[tokio::test]
334 async fn get_vehicle_empty_uuid_returns_validation() {
335 let client = Client::builder().api_key("x").build().expect("build");
336 let err = client.get_vehicle("", None).await.expect_err("must error");
337 match err {
338 Error::Validation { message, .. } => {
339 assert!(message.contains("uuid"));
340 }
341 other => panic!("expected Validation, got {other:?}"),
342 }
343 }
344}