Skip to main content

openapp_sdk_common/
localized.rs

1//! Localized display-name matching for SDK resource lookup.
2//!
3//! Dashboard resources store names as plain strings, locale maps, or `OpenAPI`
4//! [`LocalizedString`] envelopes (`{ "value": { "en": "…" } }`). Matching scans
5//! **all** locale values case-insensitively.
6
7use serde_json::Value;
8use thiserror::Error;
9
10/// How a needle is compared against localized name values.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum NameMatch {
13    /// Case-insensitive equality against any locale value.
14    #[default]
15    Exact,
16    /// Case-insensitive substring match against any locale value.
17    Fuzzy,
18}
19
20/// Outcome of resolving a unique resource by localized name.
21#[derive(Debug, Clone, Error, PartialEq)]
22pub enum ResolveError {
23    #[error("no {resource_type} named {name:?}")]
24    NotFound { resource_type: String, name: String },
25    #[error("{resource_type} name {name:?} is ambiguous ({count} matches)")]
26    Ambiguous {
27        resource_type: String,
28        name: String,
29        matches: Vec<Value>,
30        count: usize,
31    },
32}
33
34impl ResolveError {
35    #[must_use]
36    pub fn ambiguous_count(&self) -> Option<usize> {
37        match self {
38            Self::Ambiguous { matches, .. } => Some(matches.len()),
39            Self::NotFound { .. } => None,
40        }
41    }
42}
43
44/// Parse `match_mode` from C bridge / binding conventions (`0` = exact, `1` = fuzzy).
45#[must_use]
46pub fn name_match_from_i32(mode: i32) -> NameMatch {
47    match mode {
48        1 => NameMatch::Fuzzy,
49        _ => NameMatch::Exact,
50    }
51}
52
53/// Collect human-readable strings from a localized name field.
54#[must_use]
55pub fn localized_values(name_field: &Value) -> Vec<String> {
56    match name_field {
57        Value::String(s) => vec![s.clone()],
58        Value::Object(map) => {
59            if let Some(Value::Object(inner)) = map.get("value") {
60                return inner
61                    .values()
62                    .filter_map(|v| v.as_str().map(str::to_owned))
63                    .collect();
64            }
65            map.values()
66                .filter_map(|v| v.as_str().map(str::to_owned))
67                .collect()
68        }
69        _ => Vec::new(),
70    }
71}
72
73fn normalize(s: &str) -> String {
74    s.trim().to_lowercase()
75}
76
77/// Returns `true` when `needle` matches any locale value under `name_field`.
78#[must_use]
79pub fn name_matches(name_field: &Value, needle: &str, mode: NameMatch) -> bool {
80    let needle_norm = normalize(needle);
81    if needle_norm.is_empty() {
82        return false;
83    }
84    localized_values(name_field).iter().any(|v| match mode {
85        NameMatch::Exact => normalize(v) == needle_norm,
86        NameMatch::Fuzzy => normalize(v).contains(needle_norm.as_str()),
87    })
88}
89
90/// Extract the `name_field` from each item and return those that match.
91#[must_use]
92pub fn filter_by_name(
93    items: &[Value],
94    name_field: &str,
95    needle: &str,
96    mode: NameMatch,
97) -> Vec<Value> {
98    items
99        .iter()
100        .filter(|item| {
101            item.get(name_field)
102                .is_some_and(|field| name_matches(field, needle, mode))
103        })
104        .cloned()
105        .collect()
106}
107
108/// Resolve a single item by localized name, or return not-found / ambiguous errors.
109///
110/// # Errors
111/// Returns [`ResolveError::NotFound`] when no item matches, or [`ResolveError::Ambiguous`]
112/// when more than one item matches.
113pub fn resolve_unique(
114    items: &[Value],
115    resource_type: &str,
116    name_field: &str,
117    needle: &str,
118    mode: NameMatch,
119) -> Result<Value, ResolveError> {
120    let matches = filter_by_name(items, name_field, needle, mode);
121    match matches.len() {
122        0 => Err(ResolveError::NotFound {
123            resource_type: resource_type.to_owned(),
124            name: needle.trim().to_owned(),
125        }),
126        1 => Ok(matches[0].clone()),
127        _ => Err(ResolveError::Ambiguous {
128            resource_type: resource_type.to_owned(),
129            name: needle.trim().to_owned(),
130            count: matches.len(),
131            matches,
132        }),
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use serde_json::json;
140
141    #[test]
142    fn plain_string_exact() {
143        assert!(name_matches(&json!("Lobby"), "lobby", NameMatch::Exact));
144        assert!(!name_matches(
145            &json!("Lobby"),
146            "Main Lobby",
147            NameMatch::Exact
148        ));
149    }
150
151    #[test]
152    fn locale_map_any_locale() {
153        let name = json!({"en": "Lobby", "he": "לובי"});
154        assert!(name_matches(&name, "לובי", NameMatch::Exact));
155        assert!(name_matches(&name, "lobby", NameMatch::Exact));
156    }
157
158    #[test]
159    fn localized_string_value_unwrap() {
160        let name = json!({"value": {"en": "Lobby Portal"}});
161        assert_eq!(localized_values(&name), vec!["Lobby Portal"]);
162        assert!(name_matches(&name, "lobby portal", NameMatch::Exact));
163    }
164
165    #[test]
166    fn fuzzy_substring() {
167        let name = json!({"en": "Main Lobby Door"});
168        assert!(name_matches(&name, "lobby", NameMatch::Fuzzy));
169        assert!(!name_matches(&name, "lobby", NameMatch::Exact));
170    }
171
172    #[test]
173    fn resolve_unique_duplicate_names() {
174        let items = vec![
175            json!({"id": "1", "name": {"en": "Lobby"}}),
176            json!({"id": "2", "name": {"en": "Lobby"}}),
177        ];
178        let err = resolve_unique(&items, "portal", "name", "Lobby", NameMatch::Exact).unwrap_err();
179        assert!(matches!(err, ResolveError::Ambiguous { .. }));
180        assert_eq!(err.ambiguous_count(), Some(2));
181    }
182
183    #[test]
184    fn resolve_unique_not_found() {
185        let items = vec![json!({"id": "1", "name": {"en": "Other"}})];
186        assert!(matches!(
187            resolve_unique(&items, "integration", "name", "Lobby", NameMatch::Exact),
188            Err(ResolveError::NotFound { .. })
189        ));
190    }
191
192    #[test]
193    fn empty_needle_never_matches() {
194        assert!(!name_matches(&json!("Lobby"), "  ", NameMatch::Exact));
195    }
196}