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    /// The excluding agency as a Tango agency name, abbreviation, code or organization key (e.g. `DOD`). Matches by name, abbreviation, or code (CGAC, FPDS, Federal Hierarchy key or AAC) and includes every sub-agency and office beneath it; OR several with `|`. A token that does not resolve falls back to an exact match on `excluding_agency_code`.
53    #[builder(into)]
54    pub agency: Option<String>,
55    /// Code of the agency that issued the exclusion.
56    #[builder(into)]
57    pub excluding_agency_code: Option<String>,
58    /// Name of the agency that issued the exclusion.
59    #[builder(into)]
60    pub excluding_agency_name: Option<String>,
61    /// SAM UEI on the exclusion record.
62    #[builder(into)]
63    pub uei: Option<String>,
64    /// CAGE code. Supports OR via `|`.
65    #[builder(into)]
66    pub cage_code: Option<String>,
67    /// National Provider Identifier. Supports OR via `|`.
68    #[builder(into)]
69    pub npi: Option<String>,
70    /// UEI of the linked Tango entity. Set only when the exclusion's UEI matches a registered entity.
71    #[builder(into)]
72    pub entity_uei: Option<String>,
73    /// Activation date on or after (`YYYY-MM-DD`).
74    #[builder(into)]
75    pub activate_date_after: Option<String>,
76    /// Activation date on or before (`YYYY-MM-DD`).
77    #[builder(into)]
78    pub activate_date_before: Option<String>,
79    /// Termination date on or after (`YYYY-MM-DD`).
80    #[builder(into)]
81    pub termination_date_after: Option<String>,
82    /// Termination date on or before (`YYYY-MM-DD`).
83    #[builder(into)]
84    pub termination_date_before: Option<String>,
85    /// SAM update date on or after (`YYYY-MM-DD`).
86    #[builder(into)]
87    pub update_date_after: Option<String>,
88    /// SAM update date on or before (`YYYY-MM-DD`).
89    #[builder(into)]
90    pub update_date_before: Option<String>,
91    /// Full-text search across names, excluding agency, and comments.
92    #[builder(into)]
93    pub search: Option<String>,
94    /// One of `activate_date`, `termination_date`, `create_date`, `update_date`, `modified`, each optionally `-` prefixed.
95    #[builder(into)]
96    pub ordering: Option<String>,
97
98    /// Escape hatch for filter keys not yet first-classed on this struct.
99    #[builder(default)]
100    pub extra: BTreeMap<String, String>,
101}
102
103impl ListExclusionsOptions {
104    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
105        let mut q = Vec::new();
106        apply_pagination(
107            &mut q,
108            self.page,
109            self.limit,
110            self.cursor.as_deref(),
111            self.shape.as_deref(),
112            self.flat,
113            self.flat_lists,
114        );
115        push_opt_bool(&mut q, "active", self.active);
116        push_opt_bool(&mut q, "delisted", self.delisted);
117        push_opt(
118            &mut q,
119            "classification_type",
120            self.classification_type.as_deref(),
121        );
122        push_opt(&mut q, "exclusion_type", self.exclusion_type.as_deref());
123        push_opt(
124            &mut q,
125            "exclusion_program",
126            self.exclusion_program.as_deref(),
127        );
128        push_opt(&mut q, "agency", self.agency.as_deref());
129        push_opt(
130            &mut q,
131            "excluding_agency_code",
132            self.excluding_agency_code.as_deref(),
133        );
134        push_opt(
135            &mut q,
136            "excluding_agency_name",
137            self.excluding_agency_name.as_deref(),
138        );
139        push_opt(&mut q, "uei", self.uei.as_deref());
140        push_opt(&mut q, "cage_code", self.cage_code.as_deref());
141        push_opt(&mut q, "npi", self.npi.as_deref());
142        push_opt(&mut q, "entity_uei", self.entity_uei.as_deref());
143        push_opt(
144            &mut q,
145            "activate_date_after",
146            self.activate_date_after.as_deref(),
147        );
148        push_opt(
149            &mut q,
150            "activate_date_before",
151            self.activate_date_before.as_deref(),
152        );
153        push_opt(
154            &mut q,
155            "termination_date_after",
156            self.termination_date_after.as_deref(),
157        );
158        push_opt(
159            &mut q,
160            "termination_date_before",
161            self.termination_date_before.as_deref(),
162        );
163        push_opt(
164            &mut q,
165            "update_date_after",
166            self.update_date_after.as_deref(),
167        );
168        push_opt(
169            &mut q,
170            "update_date_before",
171            self.update_date_before.as_deref(),
172        );
173        push_opt(&mut q, "search", self.search.as_deref());
174        push_opt(&mut q, "ordering", self.ordering.as_deref());
175        for (k, v) in &self.extra {
176            if !v.is_empty() {
177                q.push((k.clone(), v.clone()));
178            }
179        }
180        q
181    }
182}
183
184/// Options for [`Client::get_exclusion`].
185#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
186#[non_exhaustive]
187pub struct GetExclusionOptions {
188    /// Shape selector. When empty, the server returns its default detail shape.
189    #[builder(into)]
190    pub shape: Option<String>,
191    /// Flatten nested objects into dot-separated keys.
192    #[builder(default)]
193    pub flat: bool,
194    /// When `flat=true`, also flatten list-valued nested fields.
195    #[builder(default)]
196    pub flat_lists: bool,
197}
198
199impl GetExclusionOptions {
200    pub(crate) fn to_query(&self) -> Vec<(String, String)> {
201        let mut q = Vec::new();
202        push_opt(&mut q, "shape", self.shape.as_deref());
203        if self.flat {
204            q.push(("flat".into(), "true".into()));
205        }
206        if self.flat_lists {
207            q.push(("flat_lists".into(), "true".into()));
208        }
209        q
210    }
211}
212
213impl Client {
214    /// `GET /api/exclusions/` — one page of exclusion records.
215    pub async fn list_exclusions(&self, opts: ListExclusionsOptions) -> Result<Page<Record>> {
216        let q = opts.to_query();
217        self.get_page("/api/exclusions/", &q).await
218    }
219
220    /// `GET /api/exclusions/{exclusion_key}/` — a single exclusion.
221    pub async fn get_exclusion(
222        &self,
223        exclusion_key: &str,
224        opts: Option<GetExclusionOptions>,
225    ) -> Result<Record> {
226        if exclusion_key.is_empty() {
227            return Err(Error::Validation {
228                message: "get_exclusion: exclusion_key is required".into(),
229                response: None,
230            });
231        }
232        let q = opts.unwrap_or_default().to_query();
233        let path = format!("/api/exclusions/{}/", urlencoding(exclusion_key));
234        self.get_json::<Record>(&path, &q).await
235    }
236
237    /// Stream every exclusion matching `opts`.
238    pub fn iterate_exclusions(&self, opts: ListExclusionsOptions) -> PageStream<Record> {
239        let opts = Arc::new(opts);
240        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
241            let mut next = (*opts).clone();
242            next.page = page;
243            next.cursor = cursor;
244            Box::pin(async move { client.list_exclusions(next).await })
245        });
246        PageStream::new(self.clone(), fetch)
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
255        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
256    }
257
258    #[test]
259    fn list_exclusions_all_filters_emit() {
260        let opts = ListExclusionsOptions::builder()
261            .active(true)
262            .delisted(true)
263            .classification_type("v-classification_type")
264            .exclusion_type("v-exclusion_type")
265            .exclusion_program("v-exclusion_program")
266            .excluding_agency_code("v-excluding_agency_code")
267            .excluding_agency_name("v-excluding_agency_name")
268            .uei("v-uei")
269            .cage_code("v-cage_code")
270            .npi("v-npi")
271            .entity_uei("v-entity_uei")
272            .activate_date_after("v-activate_date_after")
273            .activate_date_before("v-activate_date_before")
274            .termination_date_after("v-termination_date_after")
275            .termination_date_before("v-termination_date_before")
276            .update_date_after("v-update_date_after")
277            .update_date_before("v-update_date_before")
278            .search("v-search")
279            .ordering("v-ordering")
280            .build();
281        let q = opts.to_query();
282        assert_eq!(get_q(&q, "active").as_deref(), Some("true"));
283        assert_eq!(get_q(&q, "delisted").as_deref(), Some("true"));
284        assert_eq!(
285            get_q(&q, "classification_type").as_deref(),
286            Some("v-classification_type")
287        );
288        assert_eq!(
289            get_q(&q, "exclusion_type").as_deref(),
290            Some("v-exclusion_type")
291        );
292        assert_eq!(
293            get_q(&q, "exclusion_program").as_deref(),
294            Some("v-exclusion_program")
295        );
296        assert_eq!(
297            get_q(&q, "excluding_agency_code").as_deref(),
298            Some("v-excluding_agency_code")
299        );
300        assert_eq!(
301            get_q(&q, "excluding_agency_name").as_deref(),
302            Some("v-excluding_agency_name")
303        );
304        assert_eq!(get_q(&q, "uei").as_deref(), Some("v-uei"));
305        assert_eq!(get_q(&q, "cage_code").as_deref(), Some("v-cage_code"));
306        assert_eq!(get_q(&q, "npi").as_deref(), Some("v-npi"));
307        assert_eq!(get_q(&q, "entity_uei").as_deref(), Some("v-entity_uei"));
308        assert_eq!(
309            get_q(&q, "activate_date_after").as_deref(),
310            Some("v-activate_date_after")
311        );
312        assert_eq!(
313            get_q(&q, "activate_date_before").as_deref(),
314            Some("v-activate_date_before")
315        );
316        assert_eq!(
317            get_q(&q, "termination_date_after").as_deref(),
318            Some("v-termination_date_after")
319        );
320        assert_eq!(
321            get_q(&q, "termination_date_before").as_deref(),
322            Some("v-termination_date_before")
323        );
324        assert_eq!(
325            get_q(&q, "update_date_after").as_deref(),
326            Some("v-update_date_after")
327        );
328        assert_eq!(
329            get_q(&q, "update_date_before").as_deref(),
330            Some("v-update_date_before")
331        );
332        assert_eq!(get_q(&q, "search").as_deref(), Some("v-search"));
333        assert_eq!(get_q(&q, "ordering").as_deref(), Some("v-ordering"));
334        assert_eq!(q.len(), 19);
335    }
336
337    #[test]
338    fn list_exclusions_active_false_is_a_filter_not_an_absence() {
339        let opts = ListExclusionsOptions::builder().active(false).build();
340        assert_eq!(get_q(&opts.to_query(), "active").as_deref(), Some("false"));
341    }
342
343    #[test]
344    fn list_exclusions_zero_value_omitted() {
345        assert!(ListExclusionsOptions::builder()
346            .build()
347            .to_query()
348            .is_empty());
349    }
350
351    #[test]
352    fn list_exclusions_cursor_wins_over_page_and_shape_emits() {
353        let opts = ListExclusionsOptions::builder()
354            .page(3u32)
355            .cursor("c0".to_string())
356            .shape(crate::SHAPE_EXCLUSIONS_MINIMAL)
357            .build();
358        let q = opts.to_query();
359        assert_eq!(get_q(&q, "cursor").as_deref(), Some("c0"));
360        assert_eq!(get_q(&q, "page"), None);
361        assert_eq!(
362            get_q(&q, "shape").as_deref(),
363            Some(crate::SHAPE_EXCLUSIONS_MINIMAL)
364        );
365    }
366
367    #[test]
368    fn list_exclusions_extra_emits() {
369        let mut extra = BTreeMap::new();
370        extra.insert("custom_x".to_string(), "xv".to_string());
371        let q = ListExclusionsOptions::builder()
372            .extra(extra)
373            .build()
374            .to_query();
375        assert!(q.contains(&("custom_x".into(), "xv".into())));
376    }
377
378    #[tokio::test]
379    async fn get_exclusion_validates_empty_exclusion_key() {
380        let client = Client::builder().api_key("x").build().expect("client");
381        let err = client.get_exclusion("", None).await.unwrap_err();
382        match err {
383            Error::Validation { message, .. } => {
384                assert!(message.contains("exclusion_key is required"));
385            }
386            other => panic!("expected Validation, got {other:?}"),
387        }
388    }
389
390    #[test]
391    fn get_options_emits() {
392        let q = GetExclusionOptions::builder()
393            .shape("a,b")
394            .flat(true)
395            .flat_lists(true)
396            .build()
397            .to_query();
398        assert_eq!(get_q(&q, "shape").as_deref(), Some("a,b"));
399        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
400        assert_eq!(get_q(&q, "flat_lists").as_deref(), Some("true"));
401    }
402
403    #[test]
404    fn list_exclusions_agency_filter_emits() {
405        let q = ListExclusionsOptions::builder()
406            .agency("DOD|HHS")
407            .build()
408            .to_query();
409        assert_eq!(get_q(&q, "agency").as_deref(), Some("DOD|HHS"));
410    }
411}