1use crate::client::Client;
15use crate::error::{Error, Result};
16use crate::internal::{apply_pagination, push_opt, push_opt_bool};
17use crate::pagination::{FetchFn, Page, PageStream};
18use crate::resources::agencies::urlencoding;
19use crate::Record;
20use bon::Builder;
21use std::collections::BTreeMap;
22use std::sync::Arc;
23
24#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
41#[non_exhaustive]
42pub struct ListSledOpportunitiesOptions {
43 #[builder(into)]
45 pub page: Option<u32>,
46 #[builder(into)]
48 pub limit: Option<u32>,
49 #[builder(into)]
51 pub cursor: Option<String>,
52 #[builder(into)]
56 pub shape: Option<String>,
57 #[builder(default)]
59 pub flat: bool,
60 #[builder(default)]
62 pub flat_lists: bool,
63
64 #[builder(into)]
66 pub state: Option<String>,
67 #[builder(into)]
70 pub jurisdiction: Option<String>,
71 #[builder(into)]
76 pub status: Option<String>,
77 #[builder(into)]
81 pub active: Option<bool>,
82 #[builder(into)]
86 pub agency: Option<String>,
87 #[builder(into)]
90 pub solicitation_number: Option<String>,
91 #[builder(into)]
95 pub solicitation_type: Option<String>,
96 #[builder(into)]
98 pub has_documents: Option<bool>,
99 #[builder(into)]
103 pub revision_kind: Option<String>,
104
105 #[builder(into)]
110 pub naics: Option<String>,
111 #[builder(into)]
113 pub nigp: Option<String>,
114 #[builder(into)]
116 pub unspsc: Option<String>,
117 #[builder(into)]
120 pub category: Option<String>,
121 #[builder(into)]
125 pub category_code: Option<String>,
126
127 #[builder(into)]
129 pub posted_after: Option<String>,
130 #[builder(into)]
132 pub posted_before: Option<String>,
133 #[builder(into)]
135 pub response_deadline_after: Option<String>,
136 #[builder(into)]
138 pub response_deadline_before: Option<String>,
139 #[builder(into)]
142 pub first_seen_after: Option<String>,
143 #[builder(into)]
145 pub first_seen_before: Option<String>,
146 #[builder(into)]
151 pub change_seen_after: Option<String>,
152 #[builder(into)]
154 pub modified_after: Option<String>,
155 #[builder(into)]
157 pub modified_before: Option<String>,
158
159 #[builder(into)]
162 pub platform: Option<String>,
163 #[builder(into)]
165 pub native_id: Option<String>,
166 #[builder(into)]
168 pub external_id: Option<String>,
169
170 #[builder(into)]
175 pub search: Option<String>,
176 #[builder(into)]
180 pub ordering: Option<String>,
181
182 pub verbose: Option<bool>,
184
185 #[builder(default)]
187 pub extra: BTreeMap<String, String>,
188}
189
190impl ListSledOpportunitiesOptions {
191 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
192 let mut q = Vec::new();
193 apply_pagination(
194 &mut q,
195 self.page,
196 self.limit,
197 self.cursor.as_deref(),
198 self.shape.as_deref(),
199 self.flat,
200 self.flat_lists,
201 );
202 push_opt(&mut q, "state", self.state.as_deref());
203 push_opt(&mut q, "jurisdiction", self.jurisdiction.as_deref());
204 push_opt(&mut q, "status", self.status.as_deref());
205 push_opt_bool(&mut q, "active", self.active);
206 push_opt(&mut q, "agency", self.agency.as_deref());
207 push_opt(
208 &mut q,
209 "solicitation_number",
210 self.solicitation_number.as_deref(),
211 );
212 push_opt(
213 &mut q,
214 "solicitation_type",
215 self.solicitation_type.as_deref(),
216 );
217 push_opt_bool(&mut q, "has_documents", self.has_documents);
218 push_opt(&mut q, "revision_kind", self.revision_kind.as_deref());
219 push_opt(&mut q, "naics", self.naics.as_deref());
220 push_opt(&mut q, "nigp", self.nigp.as_deref());
221 push_opt(&mut q, "unspsc", self.unspsc.as_deref());
222 push_opt(&mut q, "category", self.category.as_deref());
223 push_opt(&mut q, "category_code", self.category_code.as_deref());
224 push_opt(&mut q, "posted_after", self.posted_after.as_deref());
225 push_opt(&mut q, "posted_before", self.posted_before.as_deref());
226 push_opt(
227 &mut q,
228 "response_deadline_after",
229 self.response_deadline_after.as_deref(),
230 );
231 push_opt(
232 &mut q,
233 "response_deadline_before",
234 self.response_deadline_before.as_deref(),
235 );
236 push_opt(&mut q, "first_seen_after", self.first_seen_after.as_deref());
237 push_opt(
238 &mut q,
239 "first_seen_before",
240 self.first_seen_before.as_deref(),
241 );
242 push_opt(
243 &mut q,
244 "change_seen_after",
245 self.change_seen_after.as_deref(),
246 );
247 push_opt(&mut q, "modified_after", self.modified_after.as_deref());
248 push_opt(&mut q, "modified_before", self.modified_before.as_deref());
249 push_opt(&mut q, "platform", self.platform.as_deref());
250 push_opt(&mut q, "native_id", self.native_id.as_deref());
251 push_opt(&mut q, "external_id", self.external_id.as_deref());
252 push_opt(&mut q, "search", self.search.as_deref());
253 push_opt_bool(&mut q, "verbose", self.verbose);
254 push_opt(&mut q, "ordering", self.ordering.as_deref());
255 for (k, v) in &self.extra {
256 if !v.is_empty() {
257 q.push((k.clone(), v.clone()));
258 }
259 }
260 q
261 }
262}
263
264#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
267#[non_exhaustive]
268pub struct ListSledOpportunityRevisionsOptions {
269 #[builder(into)]
271 pub page: Option<u32>,
272 #[builder(into)]
274 pub limit: Option<u32>,
275 #[builder(into)]
277 pub cursor: Option<String>,
278 #[builder(into)]
282 pub shape: Option<String>,
283 #[builder(default)]
285 pub flat: bool,
286 #[builder(default)]
288 pub flat_lists: bool,
289
290 #[builder(into)]
293 pub kind: Option<String>,
294 #[builder(into)]
298 pub source_declared: Option<bool>,
299 #[builder(into)]
301 pub observed_after: Option<String>,
302 #[builder(into)]
304 pub observed_before: Option<String>,
305
306 #[builder(default)]
308 pub extra: BTreeMap<String, String>,
309}
310
311impl ListSledOpportunityRevisionsOptions {
312 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
313 let mut q = Vec::new();
314 apply_pagination(
315 &mut q,
316 self.page,
317 self.limit,
318 self.cursor.as_deref(),
319 self.shape.as_deref(),
320 self.flat,
321 self.flat_lists,
322 );
323 push_opt(&mut q, "kind", self.kind.as_deref());
324 push_opt_bool(&mut q, "source_declared", self.source_declared);
325 push_opt(&mut q, "observed_after", self.observed_after.as_deref());
326 push_opt(&mut q, "observed_before", self.observed_before.as_deref());
327 for (k, v) in &self.extra {
328 if !v.is_empty() {
329 q.push((k.clone(), v.clone()));
330 }
331 }
332 q
333 }
334}
335
336#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
344#[non_exhaustive]
345pub struct ListSledForecastsOptions {
346 #[builder(into)]
348 pub page: Option<u32>,
349 #[builder(into)]
351 pub limit: Option<u32>,
352 #[builder(into)]
354 pub cursor: Option<String>,
355 #[builder(into)]
359 pub shape: Option<String>,
360 #[builder(default)]
362 pub flat: bool,
363 #[builder(default)]
365 pub flat_lists: bool,
366
367 #[builder(into)]
369 pub state: Option<String>,
370 #[builder(into)]
372 pub agency: Option<String>,
373 #[builder(into)]
375 pub procurement_category: Option<String>,
376 #[builder(into)]
378 pub procurement_method: Option<String>,
379 #[builder(into)]
382 pub contract_number: Option<String>,
383 #[builder(into)]
386 pub incumbent_name: Option<String>,
387 #[builder(into)]
390 pub advertisement_after: Option<String>,
391 #[builder(into)]
393 pub advertisement_before: Option<String>,
394 #[builder(into)]
396 pub first_seen_after: Option<String>,
397 #[builder(into)]
399 pub first_seen_before: Option<String>,
400 #[builder(into)]
402 pub modified_after: Option<String>,
403 #[builder(into)]
405 pub modified_before: Option<String>,
406 #[builder(into)]
409 pub search: Option<String>,
410 #[builder(into)]
414 pub ordering: Option<String>,
415
416 #[builder(default)]
418 pub extra: BTreeMap<String, String>,
419}
420
421impl ListSledForecastsOptions {
422 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
423 let mut q = Vec::new();
424 apply_pagination(
425 &mut q,
426 self.page,
427 self.limit,
428 self.cursor.as_deref(),
429 self.shape.as_deref(),
430 self.flat,
431 self.flat_lists,
432 );
433 push_opt(&mut q, "state", self.state.as_deref());
434 push_opt(&mut q, "agency", self.agency.as_deref());
435 push_opt(
436 &mut q,
437 "procurement_category",
438 self.procurement_category.as_deref(),
439 );
440 push_opt(
441 &mut q,
442 "procurement_method",
443 self.procurement_method.as_deref(),
444 );
445 push_opt(&mut q, "contract_number", self.contract_number.as_deref());
446 push_opt(&mut q, "incumbent_name", self.incumbent_name.as_deref());
447 push_opt(
448 &mut q,
449 "advertisement_after",
450 self.advertisement_after.as_deref(),
451 );
452 push_opt(
453 &mut q,
454 "advertisement_before",
455 self.advertisement_before.as_deref(),
456 );
457 push_opt(&mut q, "first_seen_after", self.first_seen_after.as_deref());
458 push_opt(
459 &mut q,
460 "first_seen_before",
461 self.first_seen_before.as_deref(),
462 );
463 push_opt(&mut q, "modified_after", self.modified_after.as_deref());
464 push_opt(&mut q, "modified_before", self.modified_before.as_deref());
465 push_opt(&mut q, "search", self.search.as_deref());
466 push_opt(&mut q, "ordering", self.ordering.as_deref());
467 for (k, v) in &self.extra {
468 if !v.is_empty() {
469 q.push((k.clone(), v.clone()));
470 }
471 }
472 q
473 }
474}
475
476#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
479#[non_exhaustive]
480pub struct GetSledOptions {
481 #[builder(into)]
483 pub shape: Option<String>,
484 #[builder(default)]
486 pub flat: bool,
487 #[builder(default)]
489 pub flat_lists: bool,
490}
491
492impl GetSledOptions {
493 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
494 let mut q = Vec::new();
495 push_opt(&mut q, "shape", self.shape.as_deref());
496 if self.flat {
497 q.push(("flat".into(), "true".into()));
498 }
499 if self.flat_lists {
500 q.push(("flat_lists".into(), "true".into()));
501 }
502 q
503 }
504}
505
506impl Client {
507 pub async fn list_sled_opportunities(
516 &self,
517 opts: ListSledOpportunitiesOptions,
518 ) -> Result<Page<Record>> {
519 let q = opts.to_query();
520 let bytes = self.get_bytes("/api/sled/opportunities/", &q).await?;
521 Page::decode(&bytes)
522 }
523
524 pub async fn get_sled_opportunity(
540 &self,
541 opportunity_id: &str,
542 opts: Option<GetSledOptions>,
543 ) -> Result<Record> {
544 if opportunity_id.is_empty() {
545 return Err(Error::Validation {
546 message: "get_sled_opportunity: opportunity_id is required".into(),
547 response: None,
548 });
549 }
550 let q = opts.unwrap_or_default().to_query();
551 let path = format!("/api/sled/opportunities/{}/", urlencoding(opportunity_id));
552 self.get_json::<Record>(&path, &q).await
553 }
554
555 pub async fn list_sled_opportunity_revisions(
570 &self,
571 opportunity_id: &str,
572 opts: ListSledOpportunityRevisionsOptions,
573 ) -> Result<Page<Record>> {
574 if opportunity_id.is_empty() {
575 return Err(Error::Validation {
576 message: "list_sled_opportunity_revisions: opportunity_id is required".into(),
577 response: None,
578 });
579 }
580 let q = opts.to_query();
581 let path = format!(
582 "/api/sled/opportunities/{}/revisions/",
583 urlencoding(opportunity_id)
584 );
585 let bytes = self.get_bytes(&path, &q).await?;
586 Page::decode(&bytes)
587 }
588
589 pub async fn get_sled_coverage(&self) -> Result<Record> {
600 self.get_json::<Record>("/api/sled/opportunities/coverage/", &[])
601 .await
602 }
603
604 pub async fn list_sled_forecasts(
606 &self,
607 opts: ListSledForecastsOptions,
608 ) -> Result<Page<Record>> {
609 let q = opts.to_query();
610 let bytes = self.get_bytes("/api/sled/forecasts/", &q).await?;
611 Page::decode(&bytes)
612 }
613
614 pub async fn get_sled_forecast(
616 &self,
617 forecast_id: &str,
618 opts: Option<GetSledOptions>,
619 ) -> Result<Record> {
620 if forecast_id.is_empty() {
621 return Err(Error::Validation {
622 message: "get_sled_forecast: forecast_id is required".into(),
623 response: None,
624 });
625 }
626 let q = opts.unwrap_or_default().to_query();
627 let path = format!("/api/sled/forecasts/{}/", urlencoding(forecast_id));
628 self.get_json::<Record>(&path, &q).await
629 }
630
631 pub fn iterate_sled_opportunities(
633 &self,
634 opts: ListSledOpportunitiesOptions,
635 ) -> PageStream<Record> {
636 let opts = Arc::new(opts);
637 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
638 let mut next = (*opts).clone();
639 next.page = page;
640 next.cursor = cursor;
641 Box::pin(async move { client.list_sled_opportunities(next).await })
642 });
643 PageStream::new(self.clone(), fetch)
644 }
645
646 pub fn iterate_sled_forecasts(&self, opts: ListSledForecastsOptions) -> PageStream<Record> {
648 let opts = Arc::new(opts);
649 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
650 let mut next = (*opts).clone();
651 next.page = page;
652 next.cursor = cursor;
653 Box::pin(async move { client.list_sled_forecasts(next).await })
654 });
655 PageStream::new(self.clone(), fetch)
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662
663 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
664 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
665 }
666
667 #[test]
668 fn list_sled_opportunities_all_filters_emit() {
669 let opts = ListSledOpportunitiesOptions::builder()
670 .state("TX|OK")
671 .jurisdiction("local|education")
672 .status("open|unknown")
673 .active(true)
674 .agency("Texas Commission")
675 .solicitation_number("RFP-2026-001")
676 .solicitation_type("rfp")
677 .has_documents(true)
678 .revision_kind("deadline_change")
679 .naics("541620")
680 .nigp("962-47")
681 .unspsc("77101500")
682 .category("Environmental Services")
683 .category_code("541620")
684 .posted_after("2026-01-01")
685 .posted_before("2026-12-31")
686 .response_deadline_after("2026-09-01")
687 .response_deadline_before("2026-10-01")
688 .first_seen_after("2026-09-01")
689 .first_seen_before("2026-09-30")
690 .change_seen_after("2026-09-05")
691 .modified_after("2026-09-01")
692 .modified_before("2026-09-30")
693 .platform("custom")
694 .native_id("abc-123")
695 .external_id("tx:custom:abc-123")
696 .search("environmental mitigation")
697 .ordering("response_deadline")
698 .build();
699 let q = opts.to_query();
700 for (key, want) in [
701 ("state", "TX|OK"),
702 ("jurisdiction", "local|education"),
703 ("status", "open|unknown"),
704 ("active", "true"),
705 ("agency", "Texas Commission"),
706 ("solicitation_number", "RFP-2026-001"),
707 ("solicitation_type", "rfp"),
708 ("has_documents", "true"),
709 ("revision_kind", "deadline_change"),
710 ("naics", "541620"),
711 ("nigp", "962-47"),
712 ("unspsc", "77101500"),
713 ("category", "Environmental Services"),
714 ("category_code", "541620"),
715 ("posted_after", "2026-01-01"),
716 ("posted_before", "2026-12-31"),
717 ("response_deadline_after", "2026-09-01"),
718 ("response_deadline_before", "2026-10-01"),
719 ("first_seen_after", "2026-09-01"),
720 ("first_seen_before", "2026-09-30"),
721 ("change_seen_after", "2026-09-05"),
722 ("modified_after", "2026-09-01"),
723 ("modified_before", "2026-09-30"),
724 ("platform", "custom"),
725 ("native_id", "abc-123"),
726 ("external_id", "tx:custom:abc-123"),
727 ("search", "environmental mitigation"),
728 ("ordering", "response_deadline"),
729 ] {
730 assert_eq!(get_q(&q, key).as_deref(), Some(want), "param {key}");
731 }
732 }
733
734 #[test]
737 fn no_liveness_filter_is_sent_when_none_requested() {
738 let opts = ListSledOpportunitiesOptions::builder().state("TX").build();
739 let q = opts.to_query();
740 assert_eq!(get_q(&q, "state").as_deref(), Some("TX"));
741 assert_eq!(get_q(&q, "status"), None);
742 assert_eq!(get_q(&q, "active"), None);
743 }
744
745 #[test]
746 fn active_false_is_sent_not_dropped() {
747 let opts = ListSledOpportunitiesOptions::builder()
748 .active(false)
749 .build();
750 assert_eq!(get_q(&opts.to_query(), "active").as_deref(), Some("false"));
751 }
752
753 #[test]
754 fn has_documents_false_is_sent_not_dropped() {
755 let opts = ListSledOpportunitiesOptions::builder()
756 .has_documents(false)
757 .build();
758 assert_eq!(
759 get_q(&opts.to_query(), "has_documents").as_deref(),
760 Some("false")
761 );
762 }
763
764 #[test]
767 fn source_status_is_not_a_filter() {
768 let opts = ListSledOpportunitiesOptions::builder()
769 .status("closed")
770 .build();
771 let q = opts.to_query();
772 assert_eq!(get_q(&q, "status").as_deref(), Some("closed"));
773 assert_eq!(get_q(&q, "source_status"), None);
774 }
775
776 #[test]
777 fn list_sled_opportunities_zero_value_omitted() {
778 let opts = ListSledOpportunitiesOptions::builder().build();
779 let q = opts.to_query();
780 assert!(q.is_empty(), "expected empty query, got {q:?}");
781 }
782
783 #[test]
784 fn list_sled_opportunities_extra_emits() {
785 let mut extra = BTreeMap::new();
786 extra.insert("verbose".to_string(), "true".to_string());
787 let opts = ListSledOpportunitiesOptions::builder().extra(extra).build();
788 assert!(opts.to_query().contains(&("verbose".into(), "true".into())));
789 }
790
791 #[test]
792 fn revision_filters_emit() {
793 let opts = ListSledOpportunityRevisionsOptions::builder()
794 .kind("deadline_change")
795 .source_declared(true)
796 .observed_after("2026-09-01")
797 .observed_before("2026-09-30")
798 .build();
799 let q = opts.to_query();
800 assert_eq!(get_q(&q, "kind").as_deref(), Some("deadline_change"));
801 assert_eq!(get_q(&q, "source_declared").as_deref(), Some("true"));
802 assert_eq!(get_q(&q, "observed_after").as_deref(), Some("2026-09-01"));
803 assert_eq!(get_q(&q, "observed_before").as_deref(), Some("2026-09-30"));
804 }
805
806 #[test]
809 fn revision_kind_reaches_enrichment() {
810 let opts = ListSledOpportunityRevisionsOptions::builder()
811 .kind("enrichment")
812 .build();
813 assert_eq!(
814 get_q(&opts.to_query(), "kind").as_deref(),
815 Some("enrichment")
816 );
817 }
818
819 #[test]
822 fn revisions_shape_omits_plan_gated_changes() {
823 let shape = crate::SHAPE_SLED_REVISIONS_MINIMAL;
824 assert!(
825 !shape.split(',').any(|f| f == "changes"),
826 "SHAPE_SLED_REVISIONS_MINIMAL must not name the Small-gated `changes` leaf: {shape}"
827 );
828 assert!(
829 shape.contains("changed_fields"),
830 "SHAPE_SLED_REVISIONS_MINIMAL should carry `changed_fields`, which every plan can read: {shape}"
831 );
832 }
833
834 #[test]
837 fn suggested_shapes_do_not_name_the_paid_document_body() {
838 for (name, shape) in [
839 (
840 "SHAPE_SLED_OPPORTUNITIES_MINIMAL",
841 crate::SHAPE_SLED_OPPORTUNITIES_MINIMAL,
842 ),
843 (
844 "SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE",
845 crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE,
846 ),
847 ] {
848 assert!(
849 !shape.contains("extracted_text"),
850 "{name} must not name the Small-gated `extracted_text` leaf: {shape}"
851 );
852 }
853 }
854
855 #[test]
857 fn suggested_opportunity_shapes_carry_delisted_at() {
858 for (name, shape) in [
859 (
860 "SHAPE_SLED_OPPORTUNITIES_MINIMAL",
861 crate::SHAPE_SLED_OPPORTUNITIES_MINIMAL,
862 ),
863 (
864 "SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE",
865 crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE,
866 ),
867 ] {
868 assert!(
869 shape.split(',').any(|f| f == "delisted_at"),
870 "{name} should carry `delisted_at`: {shape}"
871 );
872 }
873 }
874
875 #[test]
876 fn list_sled_forecasts_filters_emit() {
877 let opts = ListSledForecastsOptions::builder()
878 .state("MD")
879 .agency("Department of Transportation")
880 .procurement_category("Services")
881 .procurement_method("Competitive Sealed Proposals")
882 .contract_number("K-2026-001")
883 .incumbent_name("Acme")
884 .advertisement_after("2026-10-01")
885 .advertisement_before("2027-03-31")
886 .first_seen_after("2026-09-01")
887 .first_seen_before("2026-09-30")
888 .modified_after("2026-09-01")
889 .modified_before("2026-09-30")
890 .search("data center")
891 .ordering("estimated_advertisement_date")
892 .build();
893 let q = opts.to_query();
894 assert_eq!(get_q(&q, "state").as_deref(), Some("MD"));
895 assert_eq!(
896 get_q(&q, "procurement_method").as_deref(),
897 Some("Competitive Sealed Proposals")
898 );
899 assert_eq!(
900 get_q(&q, "advertisement_after").as_deref(),
901 Some("2026-10-01")
902 );
903 assert_eq!(get_q(&q, "search").as_deref(), Some("data center"));
904 assert_eq!(get_q(&q, "status"), None);
906 assert_eq!(get_q(&q, "active"), None);
907 }
908
909 #[test]
910 fn get_sled_options_emit() {
911 let opts = GetSledOptions::builder()
912 .shape(crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE)
913 .flat(true)
914 .flat_lists(true)
915 .build();
916 let q = opts.to_query();
917 assert_eq!(
918 get_q(&q, "shape").as_deref(),
919 Some(crate::SHAPE_SLED_OPPORTUNITIES_COMPREHENSIVE)
920 );
921 assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
922 assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
923 }
924
925 #[test]
926 fn cursor_wins_over_page() {
927 let opts = ListSledOpportunitiesOptions::builder()
928 .page(2u32)
929 .cursor("xyz".to_string())
930 .build();
931 let q = opts.to_query();
932 assert_eq!(get_q(&q, "cursor").as_deref(), Some("xyz"));
933 assert_eq!(get_q(&q, "page"), None);
934 }
935
936 #[tokio::test]
937 async fn get_sled_opportunity_validates_empty_id() {
938 let client = Client::builder().api_key("x").build().expect("client");
939 let err = client.get_sled_opportunity("", None).await.unwrap_err();
940 match err {
941 Error::Validation { message, .. } => {
942 assert!(message.contains("opportunity_id is required"));
943 }
944 other => panic!("expected Validation, got {other:?}"),
945 }
946 }
947
948 #[tokio::test]
949 async fn list_sled_opportunity_revisions_validates_empty_id() {
950 let client = Client::builder().api_key("x").build().expect("client");
951 let err = client
952 .list_sled_opportunity_revisions("", ListSledOpportunityRevisionsOptions::default())
953 .await
954 .unwrap_err();
955 match err {
956 Error::Validation { message, .. } => {
957 assert!(message.contains("opportunity_id is required"));
958 }
959 other => panic!("expected Validation, got {other:?}"),
960 }
961 }
962
963 #[tokio::test]
964 async fn get_sled_forecast_validates_empty_id() {
965 let client = Client::builder().api_key("x").build().expect("client");
966 let err = client.get_sled_forecast("", None).await.unwrap_err();
967 match err {
968 Error::Validation { message, .. } => {
969 assert!(message.contains("forecast_id is required"));
970 }
971 other => panic!("expected Validation, got {other:?}"),
972 }
973 }
974
975 #[test]
976 fn list_sled_opportunities_verbose_emits() {
977 let q = ListSledOpportunitiesOptions::builder()
978 .verbose(true)
979 .build()
980 .to_query();
981 assert_eq!(get_q(&q, "verbose").as_deref(), Some("true"));
982 let q = ListSledOpportunitiesOptions::builder().build().to_query();
983 assert_eq!(get_q(&q, "verbose"), None);
984 }
985}