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 self.get_page("/api/agencies/", &q).await
137 }
138
139 pub async fn get_agency(
145 &self,
146 code: &str,
147 opts: Option<GetAgencyOptions>,
148 ) -> Result<AgencyRecord> {
149 if code.is_empty() {
150 return Err(Error::Validation {
151 message: "agency code is required".into(),
152 response: None,
153 });
154 }
155 let q = opts.unwrap_or_default().to_query();
156 let path = format!("/api/agencies/{}/", urlencoding(code));
157 self.get_json::<AgencyRecord>(&path, &q).await
158 }
159
160 pub async fn list_agency_awarding_contracts(
163 &self,
164 code: &str,
165 opts: AgencyContractsOptions,
166 ) -> Result<Page<Record>> {
167 list_agency_contracts(self, code, "awarding", opts).await
168 }
169
170 pub async fn list_agency_funding_contracts(
173 &self,
174 code: &str,
175 opts: AgencyContractsOptions,
176 ) -> Result<Page<Record>> {
177 list_agency_contracts(self, code, "funding", opts).await
178 }
179
180 pub fn iterate_agency_awarding_contracts(
182 &self,
183 code: &str,
184 opts: AgencyContractsOptions,
185 ) -> PageStream<Record> {
186 iterate_agency_contracts(self, code.to_string(), "awarding", opts)
187 }
188
189 pub fn iterate_agency_funding_contracts(
191 &self,
192 code: &str,
193 opts: AgencyContractsOptions,
194 ) -> PageStream<Record> {
195 iterate_agency_contracts(self, code.to_string(), "funding", opts)
196 }
197}
198
199async fn list_agency_contracts(
200 client: &Client,
201 code: &str,
202 which: &str,
203 opts: AgencyContractsOptions,
204) -> Result<Page<Record>> {
205 if code.is_empty() {
206 return Err(Error::Validation {
207 message: "agency code is required".into(),
208 response: None,
209 });
210 }
211 let q = opts.to_query();
212 let path = format!("/api/agencies/{}/contracts/{which}/", urlencoding(code));
213 client.get_page(&path, &q).await
214}
215
216fn iterate_agency_contracts(
217 client: &Client,
218 code: String,
219 which: &'static str,
220 opts: AgencyContractsOptions,
221) -> PageStream<Record> {
222 let opts = Arc::new(opts);
223 let code = Arc::new(code);
224 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
225 let opts = (*opts).clone();
226 let mut next = opts;
227 next.page = page;
228 next.cursor = cursor;
229 let code = code.clone();
230 Box::pin(async move { list_agency_contracts(&client, &code, which, next).await })
231 });
232 PageStream::new(client.clone(), fetch)
233}
234
235pub(crate) fn urlencoding(s: &str) -> String {
238 use std::fmt::Write;
239 let mut out = String::with_capacity(s.len());
240 for byte in s.bytes() {
241 match byte {
242 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
243 out.push(byte as char);
244 }
245 _ => write!(&mut out, "%{byte:02X}").expect("write to String never fails"),
246 }
247 }
248 out
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn list_agencies_caps_limit_at_100() {
257 let opts = ListAgenciesOptions::builder().limit(500u32).build();
258 let q = opts.to_query();
259 assert_eq!(
260 q.iter()
261 .find(|(k, _)| k == "limit")
262 .map(|(_, v)| v.as_str()),
263 Some("100")
264 );
265 }
266
267 #[test]
268 fn list_agencies_emits_search() {
269 let opts = ListAgenciesOptions::builder().search("defense").build();
270 let q = opts.to_query();
271 assert!(q.contains(&("search".into(), "defense".into())));
272 }
273
274 #[test]
275 fn agency_contracts_emits_joiner_only_when_flat() {
276 let opts = AgencyContractsOptions::builder()
277 .joiner("__".to_string())
278 .build();
279 let q = opts.to_query();
280 assert!(!q.iter().any(|(k, _)| k == "joiner"));
281
282 let opts = AgencyContractsOptions::builder()
283 .flat(true)
284 .joiner("__".to_string())
285 .build();
286 let q = opts.to_query();
287 assert!(q.contains(&("joiner".into(), "__".into())));
288 }
289
290 #[test]
291 fn urlencoding_handles_special_chars() {
292 assert_eq!(urlencoding("9700"), "9700");
293 assert_eq!(urlencoding("AB CD"), "AB%20CD");
294 assert_eq!(urlencoding("a/b"), "a%2Fb");
295 assert_eq!(urlencoding("foo+bar"), "foo%2Bbar");
296 }
297}