Skip to main content

tango/resources/
lcats.rs

1//! LCATs (Labor Categories) — owner-scoped sub-resource dispatcher.
2//!
3//! Labor Categories never live at the top level: they're always scoped to
4//! an entity (by UEI) or an IDV (by key). The Tango API exposes them at:
5//!   - `GET /api/entities/{uei}/lcats/`
6//!   - `GET /api/idvs/{key}/lcats/`
7//!
8//! [`Client::list_lcats`] is a thin dispatcher over those two paths — use
9//! it as a single entry point when you want to vary the owner type at
10//! runtime; otherwise call the sub-resource methods directly.
11
12use crate::client::Client;
13use crate::error::{Error, Result};
14use crate::internal::{apply_pagination, push_opt};
15use crate::pagination::{FetchFn, Page, PageStream};
16use crate::resources::entity_subresources::EntitySubresourceOptions;
17use crate::resources::idv_subresources::IdvSubresourceOptions;
18use crate::Record;
19use bon::Builder;
20use std::collections::BTreeMap;
21use std::sync::Arc;
22
23/// Options for [`Client::list_lcats`] and [`Client::iterate_lcats`].
24///
25/// Exactly one of [`uei`](Self::uei) or [`idv_key`](Self::idv_key) must be
26/// set, or the call returns [`Error::Validation`]. If both are set,
27/// [`uei`](Self::uei) wins (mirrors the Go SDK).
28#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
29#[non_exhaustive]
30pub struct ListLcatsOptions {
31    /// Entity UEI — dispatches to `/api/entities/{uei}/lcats/`.
32    #[builder(into)]
33    pub uei: Option<String>,
34    /// IDV key — dispatches to `/api/idvs/{key}/lcats/`.
35    #[builder(into)]
36    pub idv_key: Option<String>,
37
38    // ----- Pagination + shape -----
39    /// 1-based page number.
40    #[builder(into)]
41    pub page: Option<u32>,
42    /// Page size (server caps at 100).
43    #[builder(into)]
44    pub limit: Option<u32>,
45    /// Keyset cursor.
46    #[builder(into)]
47    pub cursor: Option<String>,
48    /// Comma-separated field selector.
49    #[builder(into)]
50    pub shape: Option<String>,
51    /// Collapse nested objects into dot-separated keys.
52    #[builder(default)]
53    pub flat: bool,
54    /// When [`flat`](Self::flat) is also true, flatten list-valued fields.
55    #[builder(default)]
56    pub flat_lists: bool,
57
58    // ----- Sub-resource filters -----
59    /// Free-text search filter (labor category title, description).
60    #[builder(into)]
61    pub search: Option<String>,
62    /// Server-side sort spec (prefix `-` for descending).
63    #[builder(into)]
64    pub ordering: Option<String>,
65
66    /// Escape hatch for filter keys not first-classed here.
67    #[builder(default)]
68    pub extra: BTreeMap<String, String>,
69}
70
71/// Dispatch decision: which sub-resource path this `ListLcatsOptions`
72/// resolves to.
73///
74/// Extracted into a helper so unit tests can verify the dispatch logic
75/// without standing up a mock HTTP server.
76#[derive(Debug, Clone, PartialEq, Eq)]
77enum LcatsTarget<'a> {
78    Entity(&'a str),
79    Idv(&'a str),
80}
81
82impl ListLcatsOptions {
83    /// Resolve which sub-resource the caller's `uei` / `idv_key` selects.
84    /// UEI wins when both are set; returns `None` when neither is set.
85    fn target(&self) -> Option<LcatsTarget<'_>> {
86        if let Some(uei) = self.uei.as_deref().filter(|s| !s.is_empty()) {
87            return Some(LcatsTarget::Entity(uei));
88        }
89        if let Some(key) = self.idv_key.as_deref().filter(|s| !s.is_empty()) {
90            return Some(LcatsTarget::Idv(key));
91        }
92        None
93    }
94
95    /// Project the dispatcher options onto the entity sub-resource shape.
96    fn to_entity_opts(&self) -> EntitySubresourceOptions {
97        EntitySubresourceOptions {
98            page: self.page,
99            limit: self.limit,
100            cursor: self.cursor.clone(),
101            shape: self.shape.clone(),
102            flat: self.flat,
103            flat_lists: self.flat_lists,
104            joiner: None,
105            ordering: self.ordering.clone(),
106            search: self.search.clone(),
107            extra: self.extra.clone(),
108        }
109    }
110
111    /// Project the dispatcher options onto the IDV sub-resource shape.
112    fn to_idv_opts(&self) -> IdvSubresourceOptions {
113        IdvSubresourceOptions {
114            page: self.page,
115            limit: self.limit,
116            cursor: self.cursor.clone(),
117            shape: self.shape.clone(),
118            flat: self.flat,
119            flat_lists: self.flat_lists,
120            joiner: None,
121            ordering: self.ordering.clone(),
122            search: self.search.clone(),
123            extra: self.extra.clone(),
124        }
125    }
126
127    /// Materialise the outgoing query-pair list for the dispatched call.
128    /// Mirrors the entity sub-resource shape (Wave A); used by tests and
129    /// by the iterate stream's fetch closure.
130    fn to_query(&self) -> Vec<(String, String)> {
131        let mut q = Vec::new();
132        apply_pagination(
133            &mut q,
134            self.page,
135            self.limit,
136            self.cursor.as_deref(),
137            self.shape.as_deref(),
138            self.flat,
139            self.flat_lists,
140        );
141        push_opt(&mut q, "search", self.search.as_deref());
142        push_opt(&mut q, "ordering", self.ordering.as_deref());
143        for (k, v) in &self.extra {
144            if !v.is_empty() {
145                q.push((k.clone(), v.clone()));
146            }
147        }
148        q
149    }
150}
151
152fn validation_missing_owner() -> Error {
153    Error::Validation {
154        message: "list_lcats: one of uei or idv_key is required".into(),
155        response: None,
156    }
157}
158
159impl Client {
160    /// Dispatcher: list LCATs for the owner specified by `opts.uei` or
161    /// `opts.idv_key`.
162    ///
163    /// - When `uei` is set: delegates to [`Client::list_entity_lcats`]
164    ///   (`GET /api/entities/{uei}/lcats/`).
165    /// - Else when `idv_key` is set: delegates to [`Client::list_idv_lcats`]
166    ///   (`GET /api/idvs/{key}/lcats/`).
167    /// - Else: returns [`Error::Validation`].
168    ///
169    /// When both are set, `uei` wins (mirrors the Go SDK).
170    pub async fn list_lcats(&self, opts: ListLcatsOptions) -> Result<Page<Record>> {
171        match opts.target().ok_or_else(validation_missing_owner)? {
172            LcatsTarget::Entity(uei) => {
173                let sub_opts = opts.to_entity_opts();
174                self.list_entity_lcats(uei, sub_opts).await
175            }
176            LcatsTarget::Idv(key) => {
177                let sub_opts = opts.to_idv_opts();
178                self.list_idv_lcats(key, sub_opts).await
179            }
180        }
181    }
182
183    /// Stream every LCAT for the owner specified by `opts`. Same dispatch
184    /// rules as [`Client::list_lcats`]; the validation error surfaces on
185    /// the first poll.
186    pub fn iterate_lcats(&self, opts: ListLcatsOptions) -> PageStream<Record> {
187        let opts = Arc::new(opts);
188        let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
189            let mut next = (*opts).clone();
190            next.page = page;
191            next.cursor = cursor;
192            Box::pin(async move { client.list_lcats(next).await })
193        });
194        PageStream::new(self.clone(), fetch)
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
203        q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
204    }
205
206    // ----- Dispatch decision -----
207
208    #[test]
209    fn target_uei_dispatches_to_entity() {
210        let opts = ListLcatsOptions::builder().uei("UEI123").build();
211        assert_eq!(opts.target(), Some(LcatsTarget::Entity("UEI123")));
212    }
213
214    #[test]
215    fn target_idv_key_dispatches_to_idv() {
216        let opts = ListLcatsOptions::builder().idv_key("IDV-001").build();
217        assert_eq!(opts.target(), Some(LcatsTarget::Idv("IDV-001")));
218    }
219
220    #[test]
221    fn target_both_set_uei_wins() {
222        let opts = ListLcatsOptions::builder().uei("U1").idv_key("I1").build();
223        assert_eq!(opts.target(), Some(LcatsTarget::Entity("U1")));
224    }
225
226    #[test]
227    fn target_neither_set_returns_none() {
228        let opts = ListLcatsOptions::default();
229        assert!(opts.target().is_none());
230    }
231
232    #[test]
233    fn target_treats_empty_strings_as_unset() {
234        let opts = ListLcatsOptions::builder()
235            .uei(String::new())
236            .idv_key(String::new())
237            .build();
238        assert!(opts.target().is_none());
239    }
240
241    // ----- Validation surface -----
242
243    #[tokio::test]
244    async fn list_lcats_requires_owner_default() {
245        let c = Client::builder().api_key("k").build().expect("client");
246        let err = c
247            .list_lcats(ListLcatsOptions::default())
248            .await
249            .expect_err("must error");
250        match err {
251            Error::Validation { message, .. } => {
252                assert!(message.contains("uei") && message.contains("idv_key"));
253            }
254            other => panic!("expected Validation, got {other:?}"),
255        }
256    }
257
258    #[tokio::test]
259    async fn list_lcats_requires_owner_explicit_empty() {
260        let c = Client::builder().api_key("k").build().expect("client");
261        let opts = ListLcatsOptions::builder()
262            .uei(String::new())
263            .idv_key(String::new())
264            .build();
265        let err = c.list_lcats(opts).await.expect_err("must error");
266        assert!(matches!(err, Error::Validation { .. }));
267    }
268
269    // ----- Forwarded filters -----
270
271    #[test]
272    fn forwards_search_and_ordering() {
273        let opts = ListLcatsOptions::builder()
274            .uei("UEI123")
275            .search("software engineer")
276            .ordering("labor_category")
277            .build();
278        let q = opts.to_query();
279        assert_eq!(get_q(&q, "search").as_deref(), Some("software engineer"));
280        assert_eq!(get_q(&q, "ordering").as_deref(), Some("labor_category"));
281    }
282
283    #[test]
284    fn forwards_pagination_and_shape() {
285        let opts = ListLcatsOptions::builder()
286            .uei("UEI123")
287            .page(2u32)
288            .limit(50u32)
289            .shape("labor_category,rate")
290            .flat(true)
291            .build();
292        let q = opts.to_query();
293        assert_eq!(get_q(&q, "page").as_deref(), Some("2"));
294        assert_eq!(get_q(&q, "limit").as_deref(), Some("50"));
295        assert_eq!(get_q(&q, "shape").as_deref(), Some("labor_category,rate"));
296        assert_eq!(get_q(&q, "flat").as_deref(), Some("true"));
297    }
298
299    #[test]
300    fn extra_keys_pass_through() {
301        let mut extra = BTreeMap::new();
302        extra.insert("custom".into(), "value".into());
303        let opts = ListLcatsOptions::builder().uei("UEI1").extra(extra).build();
304        let q = opts.to_query();
305        assert_eq!(get_q(&q, "custom").as_deref(), Some("value"));
306    }
307}