Skip to main content

tango/resources/
exclusions.rs

1//! `GET /api/exclusions/` — SAM.gov exclusions (debarments, suspensions and other ineligibility records).
2//!
3//! Whether an exclusion is in force is derived at query time from its dates, so filter with `active` rather than trusting a stored flag.
4//! Most exclusions are individuals and carry no UEI.
5
6use crate::client::Client;
7use crate::error::{Error, Result};
8use crate::internal::{apply_pagination, push_opt, push_opt_bool};
9use crate::pagination::{FetchFn, Page, PageStream};
10use crate::resources::agencies::urlencoding;
11use crate::Record;
12use bon::Builder;
13use std::collections::BTreeMap;
14use std::sync::Arc;
15
16/// Options for [`Client::list_exclusions`] and [`Client::iterate_exclusions`].
17#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
18#[non_exhaustive]
19pub struct ListExclusionsOptions {
20    /// 1-based page number.
21    #[builder(into)]
22    pub page: Option<u32>,
23    /// Page size (server caps at 100).
24    #[builder(into)]
25    pub limit: Option<u32>,
26    /// Keyset cursor.
27    #[builder(into)]
28    pub cursor: Option<String>,
29    /// Comma-separated field selector. Use [`SHAPE_EXCLUSIONS_MINIMAL`](crate::SHAPE_EXCLUSIONS_MINIMAL) or roll your own.
30    #[builder(into)]
31    pub shape: Option<String>,
32    /// Collapse nested objects into dot-separated keys.
33    #[builder(default)]
34    pub flat: bool,
35    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
36    #[builder(default)]
37    pub flat_lists: bool,
38
39    /// Whether the exclusion is in force as of today: not delisted, already activated, and not yet terminated. Derived at query time. `Some(false)` reaches the server as a filter value.
40    pub active: Option<bool>,
41    /// Whether the exclusion has dropped out of the SAM catalog because SAM lifted or withdrew it. Distinct from a natural expiration, which leaves the record listed.
42    pub delisted: Option<bool>,
43    /// Classification type (e.g. `Firm|Individual`).
44    #[builder(into)]
45    pub classification_type: Option<String>,
46    /// Exclusion type. Supports OR via `|`.
47    #[builder(into)]
48    pub exclusion_type: Option<String>,
49    /// Exclusion program (e.g. `Reciprocal`).
50    #[builder(into)]
51    pub exclusion_program: Option<String>,
52    /// Code of the agency that issued the exclusion.
53    #[builder(into)]
54    pub excluding_agency_code: Option<String>,
55    /// Name of the agency that issued the exclusion.
56    #[builder(into)]
57    pub excluding_agency_name: Option<String>,
58    /// SAM UEI on the exclusion record.
59    #[builder(into)]
60    pub uei: Option<String>,
61    /// CAGE code. Supports OR via `|`.
62    #[builder(into)]
63    pub cage_code: Option<String>,
64    /// National Provider Identifier. Supports OR via `|`.
65    #[builder(into)]
66    pub npi: Option<String>,
67    /// UEI of the linked Tango entity. Set only when the exclusion's UEI matches a registered entity.
68    #[builder(into)]
69    pub entity_uei: Option<String>,
70    /// Activation date on or after (`YYYY-MM-DD`).
71    #[builder(into)]
72    pub activate_date_after: Option<String>,
73    /// Activation date on or before (`YYYY-MM-DD`).
74    #[builder(into)]
75    pub activate_date_before: Option<String>,
76    /// Termination date on or after (`YYYY-MM-DD`).
77    #[builder(into)]
78    pub termination_date_after: Option<String>,
79    /// Termination date on or before (`YYYY-MM-DD`).
80    #[builder(into)]
81    pub termination_date_before: Option<String>,
82    /// SAM update date on or after (`YYYY-MM-DD`).
83    #[builder(into)]
84    pub update_date_after: Option<String>,
85    /// SAM update date on or before (`YYYY-MM-DD`).
86    #[builder(into)]
87    pub update_date_before: Option<String>,
88    /// Full-text search across names, excluding agency, and comments.
89    #[builder(into)]
90    pub search: Option<String>,
91    /// One of `activate_date`, `termination_date`, `create_date`, `update_date`, `modified`, each optionally `-` prefixed.
92    #[builder(into)]
93    pub ordering: Option<String>,
94
95    /// Escape hatch for filter keys not yet first-classed on this struct.
96    #[builder(default)]
97    pub extra: BTreeMap<String, String>,
98}
99
100impl ListExclusionsOptions {
101    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
102        let mut q = Vec::new();
103        apply_pagination(
104            &mut q,
105            self.page,
106            self.limit,
107            self.cursor.as_deref(),
108            self.shape.as_deref(),
109            self.flat,
110            self.flat_lists,
111        );
112        push_opt_bool(&mut q, "active", self.active);
113        push_opt_bool(&mut q, "delisted", self.delisted);
114        push_opt(
115            &mut q,
116            "classification_type",
117            self.classification_type.as_deref(),
118        );
119        push_opt(&mut q, "exclusion_type", self.exclusion_type.as_deref());
120        push_opt(
121            &mut q,
122            "exclusion_program",
123            self.exclusion_program.as_deref(),
124        );
125        push_opt(
126            &mut q,
127            "excluding_agency_code",
128            self.excluding_agency_code.as_deref(),
129        );
130        push_opt(
131            &mut q,
132            "excluding_agency_name",
133            self.excluding_agency_name.as_deref(),
134        );
135        push_opt(&mut q, "uei", self.uei.as_deref());
136        push_opt(&mut q, "cage_code", self.cage_code.as_deref());
137        push_opt(&mut q, "npi", self.npi.as_deref());
138        push_opt(&mut q, "entity_uei", self.entity_uei.as_deref());
139        push_opt(
140            &mut q,
141            "activate_date_after",
142            self.activate_date_after.as_deref(),
143        );
144        push_opt(
145            &mut q,
146            "activate_date_before",
147            self.activate_date_before.as_deref(),
148        );
149        push_opt(
150            &mut q,
151            "termination_date_after",
152            self.termination_date_after.as_deref(),
153        );
154        push_opt(
155            &mut q,
156            "termination_date_before",
157            self.termination_date_before.as_deref(),
158        );
159        push_opt(
160            &mut q,
161            "update_date_after",
162            self.update_date_after.as_deref(),
163        );
164        push_opt(
165            &mut q,
166            "update_date_before",
167            self.update_date_before.as_deref(),
168        );
169        push_opt(&mut q, "search", self.search.as_deref());
170        push_opt(&mut q, "ordering", self.ordering.as_deref());
171        for (k, v) in &self.extra {
172            if !v.is_empty() {
173                q.push((k.clone(), v.clone()));
174            }
175        }
176        q
177    }
178}
179
180/// Options for [`Client::get_exclusion`].
181#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
182#[non_exhaustive]
183pub struct GetExclusionOptions {
184    /// Shape selector. When empty, the server returns its default detail shape.
185    #[builder(into)]
186    pub shape: Option<String>,
187    /// Flatten nested objects into dot-separated keys.
188    #[builder(default)]
189    pub flat: bool,
190    /// When `flat=true`, also flatten list-valued nested fields.
191    #[builder(default)]
192    pub flat_lists: bool,
193}
194
195impl GetExclusionOptions {
196    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
197        let mut q = Vec::new();
198        push_opt(&mut q, "shape", self.shape.as_deref());
199        if self.flat {
200            q.push(("flat".into(), "true".into()));
201        }
202        if self.flat_lists {
203            q.push(("flat_lists".into(), "true".into()));
204        }
205        q
206    }
207}
208
209impl Client {
210    /// `GET /api/exclusions/` — one page of exclusion records.
211    pub async fn list_exclusions(&self, opts: ListExclusionsOptions) -> Result<Page<Record>> {
212        let q = opts.to_query();
213        let bytes = self.get_bytes("/api/exclusions/", &q).await?;
214        Page::decode(&bytes)
215    }
216
217    /// `GET /api/exclusions/{exclusion_key}/` — a single exclusion.
218    pub async fn get_exclusion(
219        &self,
220        exclusion_key: &str,
221        opts: Option<GetExclusionOptions>,
222    ) -> Result<Record> {
223        if exclusion_key.is_empty() {
224            return Err(Error::Validation {
225                message: "get_exclusion: exclusion_key is required".into(),
226                response: None,
227            });
228        }
229        let q = opts.unwrap_or_default().to_query();
230        let path = format!("/api/exclusions/{}/", urlencoding(exclusion_key));
231        self.get_json::<Record>(&path, &q).await
232    }
233
234    /// Stream every exclusion matching `opts`.
235    pub fn iterate_exclusions(&self, opts: ListExclusionsOptions) -> PageStream<Record> {
236        let opts = Arc::new(opts);
237        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
238            let mut next = (*opts).clone();
239            next.page = page;
240            next.cursor = cursor;
241            Box::pin(async move { client.list_exclusions(next).await })
242        });
243        PageStream::new(self.clone(), fetch)
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
252        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
253    }
254
255    #[test]
256    fn list_exclusions_all_filters_emit() {
257        let opts = ListExclusionsOptions::builder()
258            .active(true)
259            .delisted(true)
260            .classification_type("v-classification_type")
261            .exclusion_type("v-exclusion_type")
262            .exclusion_program("v-exclusion_program")
263            .excluding_agency_code("v-excluding_agency_code")
264            .excluding_agency_name("v-excluding_agency_name")
265            .uei("v-uei")
266            .cage_code("v-cage_code")
267            .npi("v-npi")
268            .entity_uei("v-entity_uei")
269            .activate_date_after("v-activate_date_after")
270            .activate_date_before("v-activate_date_before")
271            .termination_date_after("v-termination_date_after")
272            .termination_date_before("v-termination_date_before")
273            .update_date_after("v-update_date_after")
274            .update_date_before("v-update_date_before")
275            .search("v-search")
276            .ordering("v-ordering")
277            .build();
278        let q = opts.to_query();
279        assert_eq!(get_q(&q, "active").as_deref(), Some("true"));
280        assert_eq!(get_q(&q, "delisted").as_deref(), Some("true"));
281        assert_eq!(
282            get_q(&q, "classification_type").as_deref(),
283            Some("v-classification_type")
284        );
285        assert_eq!(
286            get_q(&q, "exclusion_type").as_deref(),
287            Some("v-exclusion_type")
288        );
289        assert_eq!(
290            get_q(&q, "exclusion_program").as_deref(),
291            Some("v-exclusion_program")
292        );
293        assert_eq!(
294            get_q(&q, "excluding_agency_code").as_deref(),
295            Some("v-excluding_agency_code")
296        );
297        assert_eq!(
298            get_q(&q, "excluding_agency_name").as_deref(),
299            Some("v-excluding_agency_name")
300        );
301        assert_eq!(get_q(&q, "uei").as_deref(), Some("v-uei"));
302        assert_eq!(get_q(&q, "cage_code").as_deref(), Some("v-cage_code"));
303        assert_eq!(get_q(&q, "npi").as_deref(), Some("v-npi"));
304        assert_eq!(get_q(&q, "entity_uei").as_deref(), Some("v-entity_uei"));
305        assert_eq!(
306            get_q(&q, "activate_date_after").as_deref(),
307            Some("v-activate_date_after")
308        );
309        assert_eq!(
310            get_q(&q, "activate_date_before").as_deref(),
311            Some("v-activate_date_before")
312        );
313        assert_eq!(
314            get_q(&q, "termination_date_after").as_deref(),
315            Some("v-termination_date_after")
316        );
317        assert_eq!(
318            get_q(&q, "termination_date_before").as_deref(),
319            Some("v-termination_date_before")
320        );
321        assert_eq!(
322            get_q(&q, "update_date_after").as_deref(),
323            Some("v-update_date_after")
324        );
325        assert_eq!(
326            get_q(&q, "update_date_before").as_deref(),
327            Some("v-update_date_before")
328        );
329        assert_eq!(get_q(&q, "search").as_deref(), Some("v-search"));
330        assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering"));
331        assert_eq!(q.len(), 19);
332    }
333
334    #[test]
335    fn list_exclusions_active_false_is_a_filter_not_an_absence() {
336        let opts = ListExclusionsOptions::builder().active(false).build();
337        assert_eq!(get_q(&opts.to_query(), "active").as_deref(), Some("false"));
338    }
339
340    #[test]
341    fn list_exclusions_zero_value_omitted() {
342        assert!(ListExclusionsOptions::builder()
343            .build()
344            .to_query()
345            .is_empty());
346    }
347
348    #[test]
349    fn list_exclusions_cursor_wins_over_page_and_shape_emits() {
350        let opts = ListExclusionsOptions::builder()
351            .page(3u32)
352            .cursor("c0".to_string())
353            .shape(crate::SHAPE_EXCLUSIONS_MINIMAL)
354            .build();
355        let q = opts.to_query();
356        assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0"));
357        assert_eq!(get_q(&q, "page"), None);
358        assert_eq!(
359            get_q(&q, "shape").as_deref(),
360            Some(crate::SHAPE_EXCLUSIONS_MINIMAL)
361        );
362    }
363
364    #[test]
365    fn list_exclusions_extra_emits() {
366        let mut extra = BTreeMap::new();
367        extra.insert("custom_x".to_string(), "xv".to_string());
368        let q = ListExclusionsOptions::builder()
369            .extra(extra)
370            .build()
371            .to_query();
372        assert!(q.contains(&("custom_x".into(), "xv".into())));
373    }
374
375    #[tokio::test]
376    async fn get_exclusion_validates_empty_exclusion_key() {
377        let client = Client::builder().api_key("x").build().expect("client");
378        let err = client.get_exclusion("", None).await.unwrap_err();
379        match err {
380            Error::Validation { message, .. } => {
381                assert!(message.contains("exclusion_key is required"));
382            }
383            other => panic!("expected Validation, got {other:?}"),
384        }
385    }
386
387    #[test]
388    fn get_options_emits() {
389        let q = GetExclusionOptions::builder()
390            .shape("a,b")
391            .flat(true)
392            .flat_lists(true)
393            .build()
394            .to_query();
395        assert_eq!(get_q(&q, "shape").as_deref(), Some("a,b"));
396        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
397        assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
398    }
399}