1use crate::model::Rule;
9use crate::rules::{self, RuleFilter};
10use elasticctl_core::{Error, ErrorKind, Result, Transport};
11
12pub const UNREADABLE_RULE_ID: &str = "<unreadable rule_id>";
15
16pub const NAME_SEARCH_LIMIT: u32 = 100;
19
20pub fn pick_by_name(found: &[Rule], name: &str) -> Result<String> {
22 let matches: Vec<&Rule> = found.iter().filter(|r| r.name() == name).collect();
23
24 match matches.len() {
25 1 => Ok(matches[0].rule_id()?.to_string()),
26 0 => Err(Error::new(
27 ErrorKind::NotFound,
28 format!("No rule with rule_id or name '{name}'"),
31 )),
32 _ => {
33 let ids: Vec<&str> = matches
36 .iter()
37 .map(|r| r.rule_id().unwrap_or(UNREADABLE_RULE_ID))
38 .collect();
39 Err(Error::new(
40 ErrorKind::Conflict,
41 format!(
42 "{} rules are named '{name}'. Select one by rule_id: {}",
43 matches.len(),
44 ids.join(", ")
45 ),
46 ))
47 }
48 }
49}
50
51pub fn pick_by_name_capped(found: &[Rule], name: &str, total: u64) -> Result<String> {
57 match pick_by_name(found, name) {
58 Err(e) if e.kind == ErrorKind::NotFound && total > found.len() as u64 => Err(Error::new(
59 ErrorKind::NotFound,
60 format!(
61 "No rule with rule_id or name '{name}' among the first {} of {total} \
62 candidates; narrow the name",
63 found.len()
64 ),
65 )),
66 other => other,
67 }
68}
69
70pub async fn to_rule_id(t: &Transport, selector: &str) -> Result<String> {
76 match rules::get(t, selector).await {
77 Ok(r) => return Ok(r.rule_id()?.to_string()),
78 Err(e) if e.kind != ErrorKind::NotFound => return Err(e),
79 Err(_) => {}
80 }
81
82 let filter = RuleFilter {
83 name: Some(selector.to_string()),
84 ..Default::default()
85 };
86 let (candidates, total) = rules::find_page(t, &filter, 1, NAME_SEARCH_LIMIT).await?;
87 pick_by_name_capped(&candidates, selector, total)
88}
89
90fn local_match(local: &[Rule], selector: &str) -> Option<Result<String>> {
95 if local.iter().any(|r| r.rule_id().ok() == Some(selector)) {
96 return Some(Ok(selector.to_string()));
97 }
98
99 let named: Vec<&Rule> = local.iter().filter(|r| r.name() == selector).collect();
100 match named.len() {
101 0 => None,
102 1 => Some(named[0].rule_id().map(str::to_string)),
103 _ => {
104 let ids: Vec<&str> = named
105 .iter()
106 .map(|r| r.rule_id().unwrap_or(UNREADABLE_RULE_ID))
107 .collect();
108 Some(Err(Error::new(
109 ErrorKind::Conflict,
110 format!(
111 "{} local rules are named '{selector}'. Select one by rule_id: {}",
112 named.len(),
113 ids.join(", ")
114 ),
115 )))
116 }
117 }
118}
119
120fn search_matches(rule: &Rule, text: &str) -> bool {
127 let needle = text.to_lowercase();
128 rule.name().to_lowercase().contains(needle.as_str()) || rule.tags().contains(&text)
129}
130
131pub async fn resolve(
142 t: &Transport,
143 selectors: &[String],
144 tag: Option<&str>,
145 search: Option<&str>,
146 local: &[Rule],
147 noun: &str,
148) -> Result<Option<Vec<String>>> {
149 if selectors.is_empty() && tag.is_none() && search.is_none() {
150 return Ok(None);
151 }
152
153 if let Some(text) = search
157 && text.trim().is_empty()
158 {
159 return Err(Error::new(
160 ErrorKind::Error,
161 "the --search text must not be empty or whitespace-only",
162 ));
163 }
164
165 let mut ids: Vec<String> = Vec::new();
166 for s in selectors {
167 match local_match(local, s) {
168 Some(found) => ids.push(found?),
169 None => ids.push(to_rule_id(t, s).await?),
170 }
171 }
172
173 let mut tag_matched = false;
175 if let Some(tag) = tag {
176 for rule in local.iter().filter(|r| r.tags().contains(&tag)) {
177 tag_matched = true;
178 ids.push(rule.rule_id()?.to_string());
179 }
180
181 let filter = RuleFilter {
182 tag: Some(tag.to_string()),
183 ..Default::default()
184 };
185 for rule in rules::find_all(t, &filter).await? {
186 tag_matched = true;
187 ids.push(rule.rule_id()?.to_string());
188 }
189 }
190
191 let mut search_matched = false;
195 if let Some(text) = search {
196 for rule in local.iter().filter(|r| search_matches(r, text)) {
197 search_matched = true;
198 ids.push(rule.rule_id()?.to_string());
199 }
200
201 let filter = RuleFilter {
202 search: Some(text.to_string()),
203 ..Default::default()
204 };
205 for rule in rules::find_all(t, &filter).await? {
206 search_matched = true;
207 ids.push(rule.rule_id()?.to_string());
208 }
209 }
210
211 ids.sort();
212 ids.dedup();
213
214 if let Some(t) = tag
217 && !tag_matched
218 {
219 return Err(Error::new(
220 ErrorKind::NotFound,
221 format!("No rules matched tag '{t}'; nothing to {noun}"),
222 ));
223 }
224
225 if let Some(s) = search
227 && !search_matched
228 {
229 return Err(Error::new(
230 ErrorKind::NotFound,
231 format!("No rules matched search '{s}'; nothing to {noun}"),
232 ));
233 }
234
235 if ids.is_empty() {
238 return Err(Error::new(
239 ErrorKind::NotFound,
240 format!(
241 "No rules matched the selector(s) '{}'; nothing to {noun}",
242 selectors.join("', '")
243 ),
244 ));
245 }
246
247 Ok(Some(ids))
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use serde_json::json;
254
255 fn rule(id: &str, name: &str) -> Rule {
256 Rule::from_value(json!({"rule_id": id, "name": name})).unwrap()
257 }
258
259 #[test]
260 fn a_unique_name_resolves_to_its_rule_id() {
261 let found = vec![rule("a", "Alpha"), rule("b", "Beta")];
262 assert_eq!(pick_by_name(&found, "Beta").unwrap(), "b");
263 }
264
265 #[test]
266 fn an_ambiguous_name_is_a_conflict_listing_every_candidate() {
267 let found = vec![rule("a", "Same"), rule("b", "Same")];
268 let err = pick_by_name(&found, "Same").unwrap_err();
269 assert_eq!(err.kind, ErrorKind::Conflict);
270 assert!(
271 err.message.contains('a') && err.message.contains('b'),
272 "{}",
273 err.message
274 );
275 }
276
277 #[test]
278 fn a_name_with_no_match_is_not_found() {
279 let err = pick_by_name(&[rule("a", "Alpha")], "Ghost").unwrap_err();
280 assert_eq!(err.kind, ErrorKind::NotFound);
281 }
282
283 #[test]
284 fn a_capped_candidate_page_says_so_rather_than_claiming_the_name_is_absent() {
285 let found: Vec<Rule> = (0..NAME_SEARCH_LIMIT)
287 .map(|i| rule(&format!("id-{i}"), "Other"))
288 .collect();
289 let err = pick_by_name_capped(&found, "Ghost", NAME_SEARCH_LIMIT as u64 + 1).unwrap_err();
290 assert_eq!(err.kind, ErrorKind::NotFound);
291 assert!(
292 err.message.contains("first 100"),
293 "the cap must be visible: {}",
294 err.message
295 );
296 }
297
298 #[test]
299 fn name_matching_is_exact_not_substring() {
300 let err = pick_by_name(&[rule("a", "Alpha Rule")], "Alpha").unwrap_err();
301 assert_eq!(err.kind, ErrorKind::NotFound);
302 }
303
304 #[test]
305 fn a_conflict_still_names_every_candidate_when_one_rule_id_is_unreadable() {
306 let readable = rule("a", "Same");
307 let unreadable: Rule =
310 serde_json::from_value(json!({"rule_id": 123, "name": "Same"})).unwrap();
311 let found = vec![readable, unreadable];
312
313 let err = pick_by_name(&found, "Same").unwrap_err();
314
315 assert_eq!(err.kind, ErrorKind::Conflict);
316 assert!(err.message.contains("2 rules"), "{}", err.message);
317 assert!(
318 err.message.contains('a') && err.message.contains(UNREADABLE_RULE_ID),
319 "the count claims 2 candidates, so both must be listed: {}",
320 err.message
321 );
322 }
323
324 #[test]
325 fn a_local_rule_id_matches_without_asking_the_stack() {
326 let local = vec![rule("a", "Alpha")];
327 assert_eq!(local_match(&local, "a").unwrap().unwrap(), "a");
328 }
329
330 #[test]
331 fn a_local_name_resolves_to_its_rule_id() {
332 let local = vec![rule("a", "Alpha")];
333 assert_eq!(local_match(&local, "Alpha").unwrap().unwrap(), "a");
334 }
335
336 #[test]
337 fn an_unmatched_selector_falls_through_to_the_stack() {
338 let local = vec![rule("a", "Alpha")];
339 assert!(local_match(&local, "Ghost").is_none());
340 }
341
342 #[test]
346 fn search_matches_a_different_case_name_substring() {
347 let r = rule("a", "Suspicious Process");
348 assert!(search_matches(&r, "process"));
349 assert!(search_matches(&r, "PROCESS"));
350 assert!(search_matches(&r, "suspicious"));
351 assert!(!search_matches(&r, "unrelated"));
352 }
353
354 #[test]
355 fn search_matches_an_exact_tag_but_not_a_tag_substring() {
356 let r = Rule::from_value(json!({
357 "rule_id": "a",
358 "name": "Alpha",
359 "tags": ["prod"]
360 }))
361 .unwrap();
362 assert!(search_matches(&r, "prod"));
363 assert!(!search_matches(&r, "pro"), "tags are exact, not substring");
364 }
365
366 #[test]
367 fn an_ambiguous_local_name_is_refused_naming_both_rule_ids() {
368 let local = vec![rule("a", "Same"), rule("b", "Same")];
369 let err = local_match(&local, "Same").unwrap().unwrap_err();
370 assert_eq!(err.kind, ErrorKind::Conflict);
371 assert!(
372 err.message.contains('a') && err.message.contains('b'),
373 "identity is rule_id, so both must be named: {}",
374 err.message
375 );
376 }
377}