osp-cli 1.5.1

CLI and REPL for querying and managing OSP infrastructure data
Documentation
//! Small service-layer ports and helpers.
//!
//! This module exists to define the narrow interfaces the embeddable service
//! layer needs from the outside world.
//!
//! Contract:
//!
//! - ports should stay small and easy to mock
//! - transport- or host-specific concerns belong in adapters, not in these
//!   traits
//! - convenience mocks and fixtures belong under the owning port namespace,
//!   not as top-level crate modules

/// Test and example doubles for the port traits.
pub mod mock;

use crate::core::row::Row;
use anyhow::{Result, anyhow};
use serde_json::Value;

/// Minimal LDAP lookup port used by the service layer.
pub trait LdapDirectory {
    /// Looks up one or more user rows.
    fn user(
        &self,
        uid: &str,
        filter: Option<&str>,
        attributes: Option<&[String]>,
    ) -> Result<Vec<Row>>;

    /// Looks up one or more netgroup rows.
    fn netgroup(
        &self,
        name: &str,
        filter: Option<&str>,
        attributes: Option<&[String]>,
    ) -> Result<Vec<Row>>;
}

/// Parses the lightweight comma-separated attribute override syntax.
///
/// `None` means the caller did not request projection, while empty or
/// whitespace-only lists are rejected so the service layer never has to guess.
///
/// # Examples
///
/// ```
/// use osp_cli::ports::parse_attributes;
///
/// assert_eq!(
///     parse_attributes(Some("uid, cn ,mail")).unwrap(),
///     Some(vec!["uid".to_string(), "cn".to_string(), "mail".to_string()])
/// );
/// assert_eq!(parse_attributes(None).unwrap(), None);
/// ```
pub fn parse_attributes(raw: Option<&str>) -> Result<Option<Vec<String>>> {
    let Some(raw) = raw else {
        return Ok(None);
    };
    let attrs = raw
        .split(',')
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(ToOwned::to_owned)
        .collect::<Vec<String>>();
    if attrs.is_empty() {
        return Err(anyhow!("--attributes must include at least one key"));
    }
    Ok(Some(attrs))
}

/// Applies the lightweight LDAP filter/projection semantics used by the demo
/// service layer.
///
/// Filtering happens first, followed by attribute projection when an explicit
/// attribute list is provided.
///
/// # Examples
///
/// ```
/// use osp_cli::ports::apply_filter_and_projection;
/// use osp_cli::row;
///
/// let rows = vec![
///     row! { "uid" => "alice", "mail" => "alice@example.com" },
///     row! { "uid" => "bob", "mail" => "bob@example.com" },
/// ];
/// let attrs = vec!["mail".to_string()];
///
/// let projected = apply_filter_and_projection(rows, Some("uid=alice"), Some(&attrs));
///
/// assert_eq!(projected.len(), 1);
/// assert_eq!(projected[0].get("mail").unwrap(), "alice@example.com");
/// assert!(!projected[0].contains_key("uid"));
/// ```
pub fn apply_filter_and_projection(
    rows: Vec<Row>,
    filter: Option<&str>,
    attributes: Option<&[String]>,
) -> Vec<Row> {
    let filtered = match filter {
        Some(spec) => rows
            .into_iter()
            .filter(|row| row_matches_filter(row, spec))
            .collect::<Vec<Row>>(),
        None => rows,
    };

    match attributes {
        Some(attrs) => filtered
            .into_iter()
            .map(|row| project_attributes(&row, attrs))
            .collect::<Vec<Row>>(),
        None => filtered,
    }
}

fn row_matches_filter(row: &Row, spec: &str) -> bool {
    let spec = spec.trim();
    if spec.is_empty() {
        return true;
    }

    if let Some((key, value)) = spec.split_once('=') {
        return field_equals(row, key.trim(), value.trim());
    }

    let query = spec.to_ascii_lowercase();
    let serial = Value::Object(row.clone()).to_string().to_ascii_lowercase();
    serial.contains(&query)
}

fn field_equals(row: &Row, key: &str, expected: &str) -> bool {
    let Some(value) = row.get(key) else {
        return false;
    };
    value_matches(value, expected)
}

fn value_matches(value: &Value, expected: &str) -> bool {
    match value {
        Value::Array(items) => items.iter().any(|item| value_matches(item, expected)),
        Value::String(s) => string_matches(s, expected),
        other => string_matches(&other.to_string(), expected),
    }
}

fn string_matches(actual: &str, expected: &str) -> bool {
    if expected.contains('*') {
        return wildcard_match(expected, actual);
    }
    actual.eq_ignore_ascii_case(expected)
}

fn project_attributes(row: &Row, attrs: &[String]) -> Row {
    let mut selected = Row::new();
    for key in attrs {
        if let Some(value) = row.get(key) {
            selected.insert(key.clone(), value.clone());
        }
    }
    selected
}

fn wildcard_match(pattern: &str, value: &str) -> bool {
    let escaped = regex::escape(pattern).replace("\\*", ".*");
    let re = regex::Regex::new(&format!("^{escaped}$"));
    match re {
        Ok(re) => re.is_match(value),
        Err(_) => false,
    }
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::{Row, apply_filter_and_projection, parse_attributes};

    fn user_row() -> Row {
        json!({
            "uid": "oistes",
            "cn": "Øistein Søvik",
            "netgroups": ["ucore", "usit"]
        })
        .as_object()
        .cloned()
        .expect("fixture must be object")
    }

    #[test]
    fn filter_supports_key_value_match() {
        let rows = vec![user_row()];
        let result = apply_filter_and_projection(rows, Some("uid=oistes"), None);
        assert_eq!(result.len(), 1);
    }

    #[test]
    fn projection_keeps_selected_keys_only() {
        let rows = vec![user_row()];
        let attrs = vec!["uid".to_string()];
        let result = apply_filter_and_projection(rows, None, Some(&attrs));
        assert_eq!(result[0].len(), 1);
        assert!(result[0].contains_key("uid"));
    }

    #[test]
    fn parse_attributes_trims_and_rejects_empty_lists() {
        let attrs = parse_attributes(Some(" uid , cn ,, mail "))
            .expect("attribute list should parse")
            .expect("attribute list should be present");
        assert_eq!(attrs, vec!["uid", "cn", "mail"]);

        assert!(
            parse_attributes(None)
                .expect("missing list is allowed")
                .is_none()
        );

        let err = parse_attributes(Some(" , ,, ")).expect_err("empty attribute list should fail");
        assert!(
            err.to_string()
                .contains("--attributes must include at least one key")
        );
    }

    #[test]
    fn filter_supports_case_insensitive_substring_and_wildcard_matching() {
        let rows = vec![user_row()];

        let substring = apply_filter_and_projection(rows.clone(), Some("søvik"), None);
        assert_eq!(substring.len(), 1);

        let wildcard = apply_filter_and_projection(rows, Some("uid=*tes"), None);
        assert_eq!(wildcard.len(), 1);
    }

    #[test]
    fn filter_matches_arrays_and_missing_fields_fail_cleanly() {
        let rows = vec![user_row()];

        let array_match = apply_filter_and_projection(rows.clone(), Some("netgroups=usit"), None);
        assert_eq!(array_match.len(), 1);

        let missing = apply_filter_and_projection(rows, Some("mail=oistes@example.org"), None);
        assert!(missing.is_empty());
    }

    #[test]
    fn projection_runs_after_filtering() {
        let rows = vec![user_row()];
        let attrs = vec!["uid".to_string()];
        let result = apply_filter_and_projection(rows, Some("uid=oistes"), Some(&attrs));
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].len(), 1);
        assert_eq!(
            result[0].get("uid").and_then(|value| value.as_str()),
            Some("oistes")
        );
    }
}