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