1use crate::client::Client;
4use crate::error::{Error, Result};
5use crate::internal::{apply_pagination, push_opt, push_opt_u32};
6use crate::models::AgencyRecord;
7use crate::pagination::{FetchFn, Page, PageStream};
8use crate::Record;
9use bon::Builder;
10use std::sync::Arc;
11
12#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
14#[non_exhaustive]
15pub struct ListAgenciesOptions {
16 #[builder(into)]
18 pub page: Option<u32>,
19 #[builder(into)]
21 pub limit: Option<u32>,
22 #[builder(into)]
24 pub search: Option<String>,
25}
26
27impl ListAgenciesOptions {
28 fn to_query(&self) -> Vec<(String, String)> {
29 let mut q = Vec::new();
30 push_opt_u32(&mut q, "page", self.page);
31 if let Some(limit) = self.limit.filter(|n| *n > 0) {
32 q.push(("limit".into(), limit.min(100).to_string()));
33 }
34 push_opt(&mut q, "search", self.search.as_deref());
35 q
36 }
37}
38
39#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
44#[non_exhaustive]
45pub struct AgencyContractsOptions {
46 #[builder(into)]
48 pub page: Option<u32>,
49 #[builder(into)]
51 pub limit: Option<u32>,
52 #[builder(into)]
54 pub cursor: Option<String>,
55 #[builder(into)]
57 pub shape: Option<String>,
58 #[builder(default)]
60 pub flat: bool,
61 #[builder(default)]
63 pub flat_lists: bool,
64 #[builder(into)]
66 pub joiner: Option<String>,
67 #[builder(into)]
69 pub ordering: Option<String>,
70 #[builder(into)]
72 pub search: Option<String>,
73}
74
75impl AgencyContractsOptions {
76 fn to_query(&self) -> Vec<(String, String)> {
77 let mut q = Vec::new();
78 apply_pagination(
79 &mut q,
80 self.page,
81 self.limit,
82 self.cursor.as_deref(),
83 self.shape.as_deref(),
84 self.flat,
85 self.flat_lists,
86 );
87 if self.flat {
88 if let Some(j) = self.joiner.as_deref().filter(|s| !s.is_empty()) {
89 q.push(("joiner".into(), j.into()));
90 }
91 }
92 push_opt(&mut q, "ordering", self.ordering.as_deref());
93 push_opt(&mut q, "search", self.search.as_deref());
94 q
95 }
96}
97
98pub type ListAgencyAwardingContractsOptions = AgencyContractsOptions;
100pub type ListAgencyFundingContractsOptions = AgencyContractsOptions;
102
103#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
105#[non_exhaustive]
106pub struct GetAgencyOptions {
107 #[builder(into)]
109 pub shape: Option<String>,
110 #[builder(default)]
112 pub flat: bool,
113 #[builder(default)]
115 pub flat_lists: bool,
116}
117
118impl GetAgencyOptions {
119 fn to_query(&self) -> Vec<(String, String)> {
120 let mut q = Vec::new();
121 push_opt(&mut q, "shape", self.shape.as_deref());
122 if self.flat {
123 q.push(("flat".into(), "true".into()));
124 }
125 if self.flat_lists {
126 q.push(("flat_lists".into(), "true".into()));
127 }
128 q
129 }
130}
131
132impl Client {
133 pub async fn list_agencies(&self, opts: ListAgenciesOptions) -> Result<Page<Record>> {
135 let q = opts.to_query();
136 let bytes = self.get_bytes("/api/agencies/", &q).await?;
137 Page::decode(&bytes)
138 }
139
140 pub async fn get_agency(
146 &self,
147 code: &str,
148 opts: Option<GetAgencyOptions>,
149 ) -> Result<AgencyRecord> {
150 if code.is_empty() {
151 return Err(Error::Validation {
152 message: "agency code is required".into(),
153 response: None,
154 });
155 }
156 let q = opts.unwrap_or_default().to_query();
157 let path = format!("/api/agencies/{}/", urlencoding(code));
158 self.get_json::<AgencyRecord>(&path, &q).await
159 }
160
161 pub async fn list_agency_awarding_contracts(
164 &self,
165 code: &str,
166 opts: AgencyContractsOptions,
167 ) -> Result<Page<Record>> {
168 list_agency_contracts(self, code, "awarding", opts).await
169 }
170
171 pub async fn list_agency_funding_contracts(
174 &self,
175 code: &str,
176 opts: AgencyContractsOptions,
177 ) -> Result<Page<Record>> {
178 list_agency_contracts(self, code, "funding", opts).await
179 }
180
181 pub fn iterate_agency_awarding_contracts(
183 &self,
184 code: &str,
185 opts: AgencyContractsOptions,
186 ) -> PageStream<Record> {
187 iterate_agency_contracts(self, code.to_string(), "awarding", opts)
188 }
189
190 pub fn iterate_agency_funding_contracts(
192 &self,
193 code: &str,
194 opts: AgencyContractsOptions,
195 ) -> PageStream<Record> {
196 iterate_agency_contracts(self, code.to_string(), "funding", opts)
197 }
198}
199
200async fn list_agency_contracts(
201 client: &Client,
202 code: &str,
203 which: &str,
204 opts: AgencyContractsOptions,
205) -> Result<Page<Record>> {
206 if code.is_empty() {
207 return Err(Error::Validation {
208 message: "agency code is required".into(),
209 response: None,
210 });
211 }
212 let q = opts.to_query();
213 let path = format!("/api/agencies/{}/contracts/{which}/", urlencoding(code));
214 let bytes = client.get_bytes(&path, &q).await?;
215 Page::decode(&bytes)
216}
217
218fn iterate_agency_contracts(
219 client: &Client,
220 code: String,
221 which: &'static str,
222 opts: AgencyContractsOptions,
223) -> PageStream<Record> {
224 let opts = Arc::new(opts);
225 let code = Arc::new(code);
226 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
227 let opts = (*opts).clone();
228 let mut next = opts;
229 next.page = page;
230 next.cursor = cursor;
231 let code = code.clone();
232 Box::pin(async move { list_agency_contracts(&client, &code, which, next).await })
233 });
234 PageStream::new(client.clone(), fetch)
235}
236
237pub(crate) fn urlencoding(s: &str) -> String {
240 use std::fmt::Write;
241 let mut out = String::with_capacity(s.len());
242 for byte in s.bytes() {
243 match byte {
244 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
245 out.push(byte as char);
246 }
247 _ => write!(&mut out, "%{byte:02X}").expect("write to String never fails"),
248 }
249 }
250 out
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn list_agencies_caps_limit_at_100() {
259 let opts = ListAgenciesOptions::builder().limit(500u32).build();
260 let q = opts.to_query();
261 assert_eq!(
262 q.iter()
263 .find(|(k, _)| k == "limit")
264 .map(|(_, v)| v.as_str()),
265 Some("100")
266 );
267 }
268
269 #[test]
270 fn list_agencies_emits_search() {
271 let opts = ListAgenciesOptions::builder().search("defense").build();
272 let q = opts.to_query();
273 assert!(q.contains(&("search".into(), "defense".into())));
274 }
275
276 #[test]
277 fn agency_contracts_emits_joiner_only_when_flat() {
278 let opts = AgencyContractsOptions::builder()
279 .joiner("__".to_string())
280 .build();
281 let q = opts.to_query();
282 assert!(!q.iter().any(|(k, _)| k == "joiner"));
283
284 let opts = AgencyContractsOptions::builder()
285 .flat(true)
286 .joiner("__".to_string())
287 .build();
288 let q = opts.to_query();
289 assert!(q.contains(&("joiner".into(), "__".into())));
290 }
291
292 #[test]
293 fn urlencoding_handles_special_chars() {
294 assert_eq!(urlencoding("9700"), "9700");
295 assert_eq!(urlencoding("AB CD"), "AB%20CD");
296 assert_eq!(urlencoding("a/b"), "a%2Fb");
297 assert_eq!(urlencoding("foo+bar"), "foo%2Bbar");
298 }
299}