1use serde_json::Value;
8use thiserror::Error;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum NameMatch {
13 #[default]
15 Exact,
16 Fuzzy,
18}
19
20#[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#[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#[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#[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#[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
108pub 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}