1use crate::client::Client;
6use crate::error::{Error, Result};
7use crate::internal::{apply_pagination, push_opt, push_opt_bool};
8use crate::pagination::{FetchFn, Page, PageStream};
9use crate::resources::agencies::urlencoding;
10use crate::Record;
11use bon::Builder;
12use std::collections::BTreeMap;
13use std::sync::Arc;
14
15#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
17#[non_exhaustive]
18pub struct ListSbirTopicsOptions {
19 #[builder(into)]
21 pub page: Option<u32>,
22 #[builder(into)]
24 pub limit: Option<u32>,
25 #[builder(into)]
27 pub cursor: Option<String>,
28 #[builder(into)]
30 pub shape: Option<String>,
31 #[builder(default)]
33 pub flat: bool,
34 #[builder(default)]
36 pub flat_lists: bool,
37
38 #[builder(into)]
40 pub activity: Option<String>,
41 #[builder(into)]
43 pub agency: Option<String>,
44 #[builder(into)]
46 pub topic_number: Option<String>,
47 #[builder(into)]
49 pub solicitation_number: Option<String>,
50 #[builder(into)]
52 pub year: Option<String>,
53 #[builder(into)]
55 pub doc_source: Option<String>,
56 #[builder(into)]
58 pub close_date_after: Option<String>,
59 #[builder(into)]
61 pub close_date_before: Option<String>,
62 #[builder(into)]
64 pub open_date_after: Option<String>,
65 #[builder(into)]
67 pub open_date_before: Option<String>,
68 #[builder(into)]
70 pub release_date_after: Option<String>,
71 #[builder(into)]
73 pub release_date_before: Option<String>,
74 #[builder(into)]
76 pub search: Option<String>,
77 #[builder(into)]
79 pub ordering: Option<String>,
80
81 #[builder(default)]
83 pub extra: BTreeMap<String, String>,
84}
85
86impl ListSbirTopicsOptions {
87 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
88 let mut q = Vec::new();
89 apply_pagination(
90 &mut q,
91 self.page,
92 self.limit,
93 self.cursor.as_deref(),
94 self.shape.as_deref(),
95 self.flat,
96 self.flat_lists,
97 );
98 push_opt(&mut q, "activity", self.activity.as_deref());
99 push_opt(&mut q, "agency", self.agency.as_deref());
100 push_opt(&mut q, "topic_number", self.topic_number.as_deref());
101 push_opt(
102 &mut q,
103 "solicitation_number",
104 self.solicitation_number.as_deref(),
105 );
106 push_opt(&mut q, "year", self.year.as_deref());
107 push_opt(&mut q, "doc_source", self.doc_source.as_deref());
108 push_opt(&mut q, "close_date_after", self.close_date_after.as_deref());
109 push_opt(
110 &mut q,
111 "close_date_before",
112 self.close_date_before.as_deref(),
113 );
114 push_opt(&mut q, "open_date_after", self.open_date_after.as_deref());
115 push_opt(&mut q, "open_date_before", self.open_date_before.as_deref());
116 push_opt(
117 &mut q,
118 "release_date_after",
119 self.release_date_after.as_deref(),
120 );
121 push_opt(
122 &mut q,
123 "release_date_before",
124 self.release_date_before.as_deref(),
125 );
126 push_opt(&mut q, "search", self.search.as_deref());
127 push_opt(&mut q, "ordering", self.ordering.as_deref());
128 for (k, v) in &self.extra {
129 if !v.is_empty() {
130 q.push((k.clone(), v.clone()));
131 }
132 }
133 q
134 }
135}
136
137#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
139#[non_exhaustive]
140pub struct ListSbirSolicitationsOptions {
141 #[builder(into)]
143 pub page: Option<u32>,
144 #[builder(into)]
146 pub limit: Option<u32>,
147 #[builder(into)]
149 pub cursor: Option<String>,
150 #[builder(into)]
152 pub shape: Option<String>,
153 #[builder(default)]
155 pub flat: bool,
156 #[builder(default)]
158 pub flat_lists: bool,
159
160 #[builder(into)]
162 pub activity: Option<String>,
163 #[builder(into)]
165 pub program: Option<String>,
166 #[builder(into)]
168 pub solicitation_number: Option<String>,
169 #[builder(into)]
171 pub cycle_name: Option<String>,
172 #[builder(into)]
174 pub solicitation_status: Option<String>,
175 pub out_of_cycle: Option<bool>,
177 #[builder(into)]
179 pub year: Option<String>,
180 #[builder(into)]
182 pub start_date_after: Option<String>,
183 #[builder(into)]
185 pub start_date_before: Option<String>,
186 #[builder(into)]
188 pub end_date_after: Option<String>,
189 #[builder(into)]
191 pub end_date_before: Option<String>,
192 #[builder(into)]
194 pub search: Option<String>,
195 #[builder(into)]
197 pub ordering: Option<String>,
198
199 #[builder(default)]
201 pub extra: BTreeMap<String, String>,
202}
203
204impl ListSbirSolicitationsOptions {
205 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
206 let mut q = Vec::new();
207 apply_pagination(
208 &mut q,
209 self.page,
210 self.limit,
211 self.cursor.as_deref(),
212 self.shape.as_deref(),
213 self.flat,
214 self.flat_lists,
215 );
216 push_opt(&mut q, "activity", self.activity.as_deref());
217 push_opt(&mut q, "program", self.program.as_deref());
218 push_opt(
219 &mut q,
220 "solicitation_number",
221 self.solicitation_number.as_deref(),
222 );
223 push_opt(&mut q, "cycle_name", self.cycle_name.as_deref());
224 push_opt(
225 &mut q,
226 "solicitation_status",
227 self.solicitation_status.as_deref(),
228 );
229 push_opt_bool(&mut q, "out_of_cycle", self.out_of_cycle);
230 push_opt(&mut q, "year", self.year.as_deref());
231 push_opt(&mut q, "start_date_after", self.start_date_after.as_deref());
232 push_opt(
233 &mut q,
234 "start_date_before",
235 self.start_date_before.as_deref(),
236 );
237 push_opt(&mut q, "end_date_after", self.end_date_after.as_deref());
238 push_opt(&mut q, "end_date_before", self.end_date_before.as_deref());
239 push_opt(&mut q, "search", self.search.as_deref());
240 push_opt(&mut q, "ordering", self.ordering.as_deref());
241 for (k, v) in &self.extra {
242 if !v.is_empty() {
243 q.push((k.clone(), v.clone()));
244 }
245 }
246 q
247 }
248}
249
250#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
252#[non_exhaustive]
253pub struct GetSbirOptions {
254 #[builder(into)]
256 pub shape: Option<String>,
257 #[builder(default)]
259 pub flat: bool,
260 #[builder(default)]
262 pub flat_lists: bool,
263}
264
265impl GetSbirOptions {
266 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
267 let mut q = Vec::new();
268 push_opt(&mut q, "shape", self.shape.as_deref());
269 if self.flat {
270 q.push(("flat".into(), "true".into()));
271 }
272 if self.flat_lists {
273 q.push(("flat_lists".into(), "true".into()));
274 }
275 q
276 }
277}
278
279impl Client {
280 pub async fn list_sbir_topics(&self, opts: ListSbirTopicsOptions) -> Result<Page<Record>> {
282 let q = opts.to_query();
283 let bytes = self.get_bytes("/api/sbir/topics/", &q).await?;
284 Page::decode(&bytes)
285 }
286
287 pub async fn get_sbir_topic(
289 &self,
290 topic_id: &str,
291 opts: Option<GetSbirOptions>,
292 ) -> Result<Record> {
293 if topic_id.is_empty() {
294 return Err(Error::Validation {
295 message: "get_sbir_topic: topic_id is required".into(),
296 response: None,
297 });
298 }
299 let q = opts.unwrap_or_default().to_query();
300 let path = format!("/api/sbir/topics/{}/", urlencoding(topic_id));
301 self.get_json::<Record>(&path, &q).await
302 }
303
304 pub fn iterate_sbir_topics(&self, opts: ListSbirTopicsOptions) -> PageStream<Record> {
306 let opts = Arc::new(opts);
307 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
308 let mut next = (*opts).clone();
309 next.page = page;
310 next.cursor = cursor;
311 Box::pin(async move { client.list_sbir_topics(next).await })
312 });
313 PageStream::new(self.clone(), fetch)
314 }
315
316 pub async fn list_sbir_solicitations(
318 &self,
319 opts: ListSbirSolicitationsOptions,
320 ) -> Result<Page<Record>> {
321 let q = opts.to_query();
322 let bytes = self.get_bytes("/api/sbir/solicitations/", &q).await?;
323 Page::decode(&bytes)
324 }
325
326 pub async fn get_sbir_solicitation(
328 &self,
329 solicitation_id: &str,
330 opts: Option<GetSbirOptions>,
331 ) -> Result<Record> {
332 if solicitation_id.is_empty() {
333 return Err(Error::Validation {
334 message: "get_sbir_solicitation: solicitation_id is required".into(),
335 response: None,
336 });
337 }
338 let q = opts.unwrap_or_default().to_query();
339 let path = format!("/api/sbir/solicitations/{}/", urlencoding(solicitation_id));
340 self.get_json::<Record>(&path, &q).await
341 }
342
343 pub fn iterate_sbir_solicitations(
345 &self,
346 opts: ListSbirSolicitationsOptions,
347 ) -> PageStream<Record> {
348 let opts = Arc::new(opts);
349 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
350 let mut next = (*opts).clone();
351 next.page = page;
352 next.cursor = cursor;
353 Box::pin(async move { client.list_sbir_solicitations(next).await })
354 });
355 PageStream::new(self.clone(), fetch)
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
364 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
365 }
366
367 #[test]
368 fn list_sbir_topics_all_filters_emit() {
369 let opts = ListSbirTopicsOptions::builder()
370 .activity("v-activity")
371 .agency("v-agency")
372 .topic_number("v-topic_number")
373 .solicitation_number("v-solicitation_number")
374 .year("v-year")
375 .doc_source("v-doc_source")
376 .close_date_after("v-close_date_after")
377 .close_date_before("v-close_date_before")
378 .open_date_after("v-open_date_after")
379 .open_date_before("v-open_date_before")
380 .release_date_after("v-release_date_after")
381 .release_date_before("v-release_date_before")
382 .search("v-search")
383 .ordering("v-ordering")
384 .build();
385 let q = opts.to_query();
386 assert_eq!(get_q(&q, "activity").as_deref(), Some("v-activity"));
387 assert_eq!(get_q(&q, "agency").as_deref(), Some("v-agency"));
388 assert_eq!(get_q(&q, "topic_number").as_deref(), Some("v-topic_number"));
389 assert_eq!(
390 get_q(&q, "solicitation_number").as_deref(),
391 Some("v-solicitation_number")
392 );
393 assert_eq!(get_q(&q, "year").as_deref(), Some("v-year"));
394 assert_eq!(get_q(&q, "doc_source").as_deref(), Some("v-doc_source"));
395 assert_eq!(
396 get_q(&q, "close_date_after").as_deref(),
397 Some("v-close_date_after")
398 );
399 assert_eq!(
400 get_q(&q, "close_date_before").as_deref(),
401 Some("v-close_date_before")
402 );
403 assert_eq!(
404 get_q(&q, "open_date_after").as_deref(),
405 Some("v-open_date_after")
406 );
407 assert_eq!(
408 get_q(&q, "open_date_before").as_deref(),
409 Some("v-open_date_before")
410 );
411 assert_eq!(
412 get_q(&q, "release_date_after").as_deref(),
413 Some("v-release_date_after")
414 );
415 assert_eq!(
416 get_q(&q, "release_date_before").as_deref(),
417 Some("v-release_date_before")
418 );
419 assert_eq!(get_q(&q, "search").as_deref(), Some("v-search"));
420 assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering"));
421 assert_eq!(q.len(), 14);
422 }
423
424 #[test]
425 fn list_sbir_topics_zero_value_omitted() {
426 assert!(ListSbirTopicsOptions::builder()
427 .build()
428 .to_query()
429 .is_empty());
430 }
431
432 #[test]
433 fn list_sbir_topics_cursor_wins_over_page_and_shape_emits() {
434 let opts = ListSbirTopicsOptions::builder()
435 .page(3u32)
436 .cursor("c0".to_string())
437 .shape(crate::SHAPE_SBIR_TOPICS_MINIMAL)
438 .build();
439 let q = opts.to_query();
440 assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0"));
441 assert_eq!(get_q(&q, "page"), None);
442 assert_eq!(
443 get_q(&q, "shape").as_deref(),
444 Some(crate::SHAPE_SBIR_TOPICS_MINIMAL)
445 );
446 }
447
448 #[test]
449 fn list_sbir_topics_extra_emits() {
450 let mut extra = BTreeMap::new();
451 extra.insert("custom_x".to_string(), "xv".to_string());
452 let q = ListSbirTopicsOptions::builder()
453 .extra(extra)
454 .build()
455 .to_query();
456 assert!(q.contains(&("custom_x".into(), "xv".into())));
457 }
458
459 #[tokio::test]
460 async fn get_sbir_topic_validates_empty_topic_id() {
461 let client = Client::builder().api_key("x").build().expect("client");
462 let err = client.get_sbir_topic("", None).await.unwrap_err();
463 match err {
464 Error::Validation { message, .. } => {
465 assert!(message.contains("topic_id is required"));
466 }
467 other => panic!("expected Validation, got {other:?}"),
468 }
469 }
470
471 #[test]
472 fn list_sbir_solicitations_all_filters_emit() {
473 let opts = ListSbirSolicitationsOptions::builder()
474 .activity("v-activity")
475 .program("v-program")
476 .solicitation_number("v-solicitation_number")
477 .cycle_name("v-cycle_name")
478 .solicitation_status("v-solicitation_status")
479 .out_of_cycle(true)
480 .year("v-year")
481 .start_date_after("v-start_date_after")
482 .start_date_before("v-start_date_before")
483 .end_date_after("v-end_date_after")
484 .end_date_before("v-end_date_before")
485 .search("v-search")
486 .ordering("v-ordering")
487 .build();
488 let q = opts.to_query();
489 assert_eq!(get_q(&q, "activity").as_deref(), Some("v-activity"));
490 assert_eq!(get_q(&q, "program").as_deref(), Some("v-program"));
491 assert_eq!(
492 get_q(&q, "solicitation_number").as_deref(),
493 Some("v-solicitation_number")
494 );
495 assert_eq!(get_q(&q, "cycle_name").as_deref(), Some("v-cycle_name"));
496 assert_eq!(
497 get_q(&q, "solicitation_status").as_deref(),
498 Some("v-solicitation_status")
499 );
500 assert_eq!(get_q(&q, "out_of_cycle").as_deref(), Some("true"));
501 assert_eq!(get_q(&q, "year").as_deref(), Some("v-year"));
502 assert_eq!(
503 get_q(&q, "start_date_after").as_deref(),
504 Some("v-start_date_after")
505 );
506 assert_eq!(
507 get_q(&q, "start_date_before").as_deref(),
508 Some("v-start_date_before")
509 );
510 assert_eq!(
511 get_q(&q, "end_date_after").as_deref(),
512 Some("v-end_date_after")
513 );
514 assert_eq!(
515 get_q(&q, "end_date_before").as_deref(),
516 Some("v-end_date_before")
517 );
518 assert_eq!(get_q(&q, "search").as_deref(), Some("v-search"));
519 assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering"));
520 assert_eq!(q.len(), 13);
521 }
522
523 #[test]
524 fn list_sbir_solicitations_out_of_cycle_false_is_a_filter_not_an_absence() {
525 let opts = ListSbirSolicitationsOptions::builder()
526 .out_of_cycle(false)
527 .build();
528 assert_eq!(
529 get_q(&opts.to_query(), "out_of_cycle").as_deref(),
530 Some("false")
531 );
532 }
533
534 #[test]
535 fn list_sbir_solicitations_zero_value_omitted() {
536 assert!(ListSbirSolicitationsOptions::builder()
537 .build()
538 .to_query()
539 .is_empty());
540 }
541
542 #[test]
543 fn list_sbir_solicitations_cursor_wins_over_page_and_shape_emits() {
544 let opts = ListSbirSolicitationsOptions::builder()
545 .page(3u32)
546 .cursor("c0".to_string())
547 .shape(crate::SHAPE_SBIR_SOLICITATIONS_MINIMAL)
548 .build();
549 let q = opts.to_query();
550 assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0"));
551 assert_eq!(get_q(&q, "page"), None);
552 assert_eq!(
553 get_q(&q, "shape").as_deref(),
554 Some(crate::SHAPE_SBIR_SOLICITATIONS_MINIMAL)
555 );
556 }
557
558 #[test]
559 fn list_sbir_solicitations_extra_emits() {
560 let mut extra = BTreeMap::new();
561 extra.insert("custom_x".to_string(), "xv".to_string());
562 let q = ListSbirSolicitationsOptions::builder()
563 .extra(extra)
564 .build()
565 .to_query();
566 assert!(q.contains(&("custom_x".into(), "xv".into())));
567 }
568
569 #[tokio::test]
570 async fn get_sbir_solicitation_validates_empty_solicitation_id() {
571 let client = Client::builder().api_key("x").build().expect("client");
572 let err = client.get_sbir_solicitation("", None).await.unwrap_err();
573 match err {
574 Error::Validation { message, .. } => {
575 assert!(message.contains("solicitation_id is required"));
576 }
577 other => panic!("expected Validation, got {other:?}"),
578 }
579 }
580
581 #[test]
582 fn get_options_emits() {
583 let q = GetSbirOptions::builder()
584 .shape("a,b")
585 .flat(true)
586 .flat_lists(true)
587 .build()
588 .to_query();
589 assert_eq!(get_q(&q, "shape").as_deref(), Some("a,b"));
590 assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
591 assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
592 }
593}