Skip to main content

osp_cli/
ports.rs

1//! Small service-layer ports and helpers.
2//!
3//! This module exists to define the narrow interfaces the embeddable service
4//! layer needs from the outside world.
5//!
6//! Contract:
7//!
8//! - ports should stay small and easy to mock
9//! - transport- or host-specific concerns belong in adapters, not in these
10//!   traits
11//! - convenience mocks and fixtures belong under the owning port namespace,
12//!   not as top-level crate modules
13
14/// Test and example doubles for the port traits.
15pub mod mock;
16
17use crate::core::row::Row;
18use anyhow::{Result, anyhow};
19use serde_json::Value;
20
21/// Minimal LDAP lookup port used by the service layer.
22pub trait LdapDirectory {
23    /// Looks up one or more user rows.
24    fn user(
25        &self,
26        uid: &str,
27        filter: Option<&str>,
28        attributes: Option<&[String]>,
29    ) -> Result<Vec<Row>>;
30
31    /// Looks up one or more netgroup rows.
32    fn netgroup(
33        &self,
34        name: &str,
35        filter: Option<&str>,
36        attributes: Option<&[String]>,
37    ) -> Result<Vec<Row>>;
38}
39
40/// Parses the lightweight comma-separated attribute override syntax.
41///
42/// `None` means the caller did not request projection, while empty or
43/// whitespace-only lists are rejected so the service layer never has to guess.
44///
45/// # Examples
46///
47/// ```
48/// use osp_cli::ports::parse_attributes;
49///
50/// assert_eq!(
51///     parse_attributes(Some("uid, cn ,mail")).unwrap(),
52///     Some(vec!["uid".to_string(), "cn".to_string(), "mail".to_string()])
53/// );
54/// assert_eq!(parse_attributes(None).unwrap(), None);
55/// ```
56pub fn parse_attributes(raw: Option<&str>) -> Result<Option<Vec<String>>> {
57    let Some(raw) = raw else {
58        return Ok(None);
59    };
60    let attrs = raw
61        .split(',')
62        .map(str::trim)
63        .filter(|s| !s.is_empty())
64        .map(ToOwned::to_owned)
65        .collect::<Vec<String>>();
66    if attrs.is_empty() {
67        return Err(anyhow!("--attributes must include at least one key"));
68    }
69    Ok(Some(attrs))
70}
71
72/// Applies the lightweight LDAP filter/projection semantics used by the demo
73/// service layer.
74///
75/// Filtering happens first, followed by attribute projection when an explicit
76/// attribute list is provided.
77///
78/// # Examples
79///
80/// ```
81/// use osp_cli::ports::apply_filter_and_projection;
82/// use osp_cli::row;
83///
84/// let rows = vec![
85///     row! { "uid" => "alice", "mail" => "alice@example.com" },
86///     row! { "uid" => "bob", "mail" => "bob@example.com" },
87/// ];
88/// let attrs = vec!["mail".to_string()];
89///
90/// let projected = apply_filter_and_projection(rows, Some("uid=alice"), Some(&attrs));
91///
92/// assert_eq!(projected.len(), 1);
93/// assert_eq!(projected[0].get("mail").unwrap(), "alice@example.com");
94/// assert!(!projected[0].contains_key("uid"));
95/// ```
96pub fn apply_filter_and_projection(
97    rows: Vec<Row>,
98    filter: Option<&str>,
99    attributes: Option<&[String]>,
100) -> Vec<Row> {
101    let filtered = match filter {
102        Some(spec) => rows
103            .into_iter()
104            .filter(|row| row_matches_filter(row, spec))
105            .collect::<Vec<Row>>(),
106        None => rows,
107    };
108
109    match attributes {
110        Some(attrs) => filtered
111            .into_iter()
112            .map(|row| project_attributes(&row, attrs))
113            .collect::<Vec<Row>>(),
114        None => filtered,
115    }
116}
117
118fn row_matches_filter(row: &Row, spec: &str) -> bool {
119    let spec = spec.trim();
120    if spec.is_empty() {
121        return true;
122    }
123
124    if let Some((key, value)) = spec.split_once('=') {
125        return field_equals(row, key.trim(), value.trim());
126    }
127
128    let query = spec.to_ascii_lowercase();
129    let serial = Value::Object(row.clone()).to_string().to_ascii_lowercase();
130    serial.contains(&query)
131}
132
133fn field_equals(row: &Row, key: &str, expected: &str) -> bool {
134    let Some(value) = row.get(key) else {
135        return false;
136    };
137    value_matches(value, expected)
138}
139
140fn value_matches(value: &Value, expected: &str) -> bool {
141    match value {
142        Value::Array(items) => items.iter().any(|item| value_matches(item, expected)),
143        Value::String(s) => string_matches(s, expected),
144        other => string_matches(&other.to_string(), expected),
145    }
146}
147
148fn string_matches(actual: &str, expected: &str) -> bool {
149    if expected.contains('*') {
150        return wildcard_match(expected, actual);
151    }
152    actual.eq_ignore_ascii_case(expected)
153}
154
155fn project_attributes(row: &Row, attrs: &[String]) -> Row {
156    let mut selected = Row::new();
157    for key in attrs {
158        if let Some(value) = row.get(key) {
159            selected.insert(key.clone(), value.clone());
160        }
161    }
162    selected
163}
164
165fn wildcard_match(pattern: &str, value: &str) -> bool {
166    let escaped = regex::escape(pattern).replace("\\*", ".*");
167    let re = regex::Regex::new(&format!("^{escaped}$"));
168    match re {
169        Ok(re) => re.is_match(value),
170        Err(_) => false,
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use serde_json::json;
177
178    use super::{Row, apply_filter_and_projection, parse_attributes};
179
180    fn user_row() -> Row {
181        json!({
182            "uid": "oistes",
183            "cn": "Øistein Søvik",
184            "netgroups": ["ucore", "usit"]
185        })
186        .as_object()
187        .cloned()
188        .expect("fixture must be object")
189    }
190
191    #[test]
192    fn filter_supports_key_value_match() {
193        let rows = vec![user_row()];
194        let result = apply_filter_and_projection(rows, Some("uid=oistes"), None);
195        assert_eq!(result.len(), 1);
196    }
197
198    #[test]
199    fn projection_keeps_selected_keys_only() {
200        let rows = vec![user_row()];
201        let attrs = vec!["uid".to_string()];
202        let result = apply_filter_and_projection(rows, None, Some(&attrs));
203        assert_eq!(result[0].len(), 1);
204        assert!(result[0].contains_key("uid"));
205    }
206
207    #[test]
208    fn parse_attributes_trims_and_rejects_empty_lists() {
209        let attrs = parse_attributes(Some(" uid , cn ,, mail "))
210            .expect("attribute list should parse")
211            .expect("attribute list should be present");
212        assert_eq!(attrs, vec!["uid", "cn", "mail"]);
213
214        assert!(
215            parse_attributes(None)
216                .expect("missing list is allowed")
217                .is_none()
218        );
219
220        let err = parse_attributes(Some(" , ,, ")).expect_err("empty attribute list should fail");
221        assert!(
222            err.to_string()
223                .contains("--attributes must include at least one key")
224        );
225    }
226
227    #[test]
228    fn filter_supports_case_insensitive_substring_and_wildcard_matching() {
229        let rows = vec![user_row()];
230
231        let substring = apply_filter_and_projection(rows.clone(), Some("søvik"), None);
232        assert_eq!(substring.len(), 1);
233
234        let wildcard = apply_filter_and_projection(rows, Some("uid=*tes"), None);
235        assert_eq!(wildcard.len(), 1);
236    }
237
238    #[test]
239    fn filter_matches_arrays_and_missing_fields_fail_cleanly() {
240        let rows = vec![user_row()];
241
242        let array_match = apply_filter_and_projection(rows.clone(), Some("netgroups=usit"), None);
243        assert_eq!(array_match.len(), 1);
244
245        let missing = apply_filter_and_projection(rows, Some("mail=oistes@example.org"), None);
246        assert!(missing.is_empty());
247    }
248
249    #[test]
250    fn projection_runs_after_filtering() {
251        let rows = vec![user_row()];
252        let attrs = vec!["uid".to_string()];
253        let result = apply_filter_and_projection(rows, Some("uid=oistes"), Some(&attrs));
254        assert_eq!(result.len(), 1);
255        assert_eq!(result[0].len(), 1);
256        assert_eq!(
257            result[0].get("uid").and_then(|value| value.as_str()),
258            Some("oistes")
259        );
260    }
261}