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 get_if_exists(t, selector).await? {
77 Some(rule) => Ok(rule.rule_id()?.to_string()),
78 None => by_name(t, selector).await,
79 }
80}
81
82pub async fn to_rule(t: &Transport, selector: &str) -> Result<(String, Rule)> {
88 let rule_id = match get_if_exists(t, selector).await? {
89 Some(rule) if rule.rule_id()? == selector => return Ok((selector.to_string(), rule)),
90 Some(rule) => rule.rule_id()?.to_string(),
91 None => by_name(t, selector).await?,
92 };
93 let rule = rules::get(t, &rule_id).await?;
94 Ok((rule_id, rule))
95}
96
97async fn get_if_exists(t: &Transport, rule_id: &str) -> Result<Option<Rule>> {
98 match rules::get(t, rule_id).await {
99 Ok(rule) => Ok(Some(rule)),
100 Err(e) if e.kind == ErrorKind::NotFound => Ok(None),
101 Err(e) => Err(e),
102 }
103}
104
105async fn by_name(t: &Transport, selector: &str) -> Result<String> {
106 let filter = RuleFilter {
107 name: Some(selector.to_string()),
108 ..Default::default()
109 };
110 let (candidates, total) = rules::find_page(t, &filter, 1, NAME_SEARCH_LIMIT).await?;
111 pick_by_name_capped(&candidates, selector, total)
112}
113
114fn local_match(local: &[Rule], selector: &str) -> Option<Result<String>> {
119 if local.iter().any(|r| r.rule_id().ok() == Some(selector)) {
120 return Some(Ok(selector.to_string()));
121 }
122
123 let named: Vec<&Rule> = local.iter().filter(|r| r.name() == selector).collect();
124 match named.len() {
125 0 => None,
126 1 => Some(named[0].rule_id().map(str::to_string)),
127 _ => {
128 let ids: Vec<&str> = named
129 .iter()
130 .map(|r| r.rule_id().unwrap_or(UNREADABLE_RULE_ID))
131 .collect();
132 Some(Err(Error::new(
133 ErrorKind::Conflict,
134 format!(
135 "{} local rules are named '{selector}'. Select one by rule_id: {}",
136 named.len(),
137 ids.join(", ")
138 ),
139 )))
140 }
141 }
142}
143
144fn search_matches(rule: &Rule, text: &str) -> bool {
151 let needle = text.to_lowercase();
152 rule.name().to_lowercase().contains(needle.as_str()) || rule.tags().contains(&text)
153}
154
155pub async fn resolve(
166 t: &Transport,
167 selectors: &[String],
168 tag: Option<&str>,
169 search: Option<&str>,
170 local: &[Rule],
171 noun: &str,
172) -> Result<Option<Vec<String>>> {
173 if selectors.is_empty() && tag.is_none() && search.is_none() {
174 return Ok(None);
175 }
176
177 if let Some(text) = search
181 && text.trim().is_empty()
182 {
183 return Err(Error::new(
184 ErrorKind::Error,
185 "the --search text must not be empty or whitespace-only",
186 ));
187 }
188
189 let mut ids: Vec<String> = Vec::new();
190 for s in selectors {
191 match local_match(local, s) {
192 Some(found) => ids.push(found?),
193 None => ids.push(to_rule_id(t, s).await?),
194 }
195 }
196
197 let mut tag_matched = false;
199 if let Some(tag) = tag {
200 for rule in local.iter().filter(|r| r.tags().contains(&tag)) {
201 tag_matched = true;
202 ids.push(rule.rule_id()?.to_string());
203 }
204
205 let filter = RuleFilter {
206 tag: Some(tag.to_string()),
207 ..Default::default()
208 };
209 for rule in rules::find_all(t, &filter).await? {
210 tag_matched = true;
211 ids.push(rule.rule_id()?.to_string());
212 }
213 }
214
215 let mut search_matched = false;
219 if let Some(text) = search {
220 for rule in local.iter().filter(|r| search_matches(r, text)) {
221 search_matched = true;
222 ids.push(rule.rule_id()?.to_string());
223 }
224
225 let filter = RuleFilter {
226 search: Some(text.to_string()),
227 ..Default::default()
228 };
229 for rule in rules::find_all(t, &filter).await? {
230 search_matched = true;
231 ids.push(rule.rule_id()?.to_string());
232 }
233 }
234
235 ids.sort();
236 ids.dedup();
237
238 if let Some(t) = tag
241 && !tag_matched
242 {
243 return Err(Error::new(
244 ErrorKind::NotFound,
245 format!("No rules matched tag '{t}'; nothing to {noun}"),
246 ));
247 }
248
249 if let Some(s) = search
251 && !search_matched
252 {
253 return Err(Error::new(
254 ErrorKind::NotFound,
255 format!("No rules matched search '{s}'; nothing to {noun}"),
256 ));
257 }
258
259 if ids.is_empty() {
262 return Err(Error::new(
263 ErrorKind::NotFound,
264 format!(
265 "No rules matched the selector(s) '{}'; nothing to {noun}",
266 selectors.join("', '")
267 ),
268 ));
269 }
270
271 Ok(Some(ids))
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use serde_json::json;
278
279 fn rule(id: &str, name: &str) -> Rule {
280 Rule::from_value(json!({"rule_id": id, "name": name})).unwrap()
281 }
282
283 #[test]
284 fn a_unique_name_resolves_to_its_rule_id() {
285 let found = vec![rule("a", "Alpha"), rule("b", "Beta")];
286 assert_eq!(pick_by_name(&found, "Beta").unwrap(), "b");
287 }
288
289 #[test]
290 fn an_ambiguous_name_is_a_conflict_listing_every_candidate() {
291 let found = vec![rule("a", "Same"), rule("b", "Same")];
292 let err = pick_by_name(&found, "Same").unwrap_err();
293 assert_eq!(err.kind, ErrorKind::Conflict);
294 assert!(
295 err.message.contains('a') && err.message.contains('b'),
296 "{}",
297 err.message
298 );
299 }
300
301 #[test]
302 fn a_name_with_no_match_is_not_found() {
303 let err = pick_by_name(&[rule("a", "Alpha")], "Ghost").unwrap_err();
304 assert_eq!(err.kind, ErrorKind::NotFound);
305 }
306
307 #[test]
308 fn a_capped_candidate_page_says_so_rather_than_claiming_the_name_is_absent() {
309 let found: Vec<Rule> = (0..NAME_SEARCH_LIMIT)
311 .map(|i| rule(&format!("id-{i}"), "Other"))
312 .collect();
313 let err = pick_by_name_capped(&found, "Ghost", NAME_SEARCH_LIMIT as u64 + 1).unwrap_err();
314 assert_eq!(err.kind, ErrorKind::NotFound);
315 assert!(
316 err.message.contains("first 100"),
317 "the cap must be visible: {}",
318 err.message
319 );
320 }
321
322 #[test]
323 fn name_matching_is_exact_not_substring() {
324 let err = pick_by_name(&[rule("a", "Alpha Rule")], "Alpha").unwrap_err();
325 assert_eq!(err.kind, ErrorKind::NotFound);
326 }
327
328 #[test]
329 fn a_conflict_still_names_every_candidate_when_one_rule_id_is_unreadable() {
330 let readable = rule("a", "Same");
331 let unreadable: Rule =
334 serde_json::from_value(json!({"rule_id": 123, "name": "Same"})).unwrap();
335 let found = vec![readable, unreadable];
336
337 let err = pick_by_name(&found, "Same").unwrap_err();
338
339 assert_eq!(err.kind, ErrorKind::Conflict);
340 assert!(err.message.contains("2 rules"), "{}", err.message);
341 assert!(
342 err.message.contains('a') && err.message.contains(UNREADABLE_RULE_ID),
343 "the count claims 2 candidates, so both must be listed: {}",
344 err.message
345 );
346 }
347
348 #[test]
349 fn a_local_rule_id_matches_without_asking_the_stack() {
350 let local = vec![rule("a", "Alpha")];
351 assert_eq!(local_match(&local, "a").unwrap().unwrap(), "a");
352 }
353
354 #[test]
355 fn a_local_name_resolves_to_its_rule_id() {
356 let local = vec![rule("a", "Alpha")];
357 assert_eq!(local_match(&local, "Alpha").unwrap().unwrap(), "a");
358 }
359
360 #[test]
361 fn an_unmatched_selector_falls_through_to_the_stack() {
362 let local = vec![rule("a", "Alpha")];
363 assert!(local_match(&local, "Ghost").is_none());
364 }
365
366 #[test]
370 fn search_matches_a_different_case_name_substring() {
371 let r = rule("a", "Suspicious Process");
372 assert!(search_matches(&r, "process"));
373 assert!(search_matches(&r, "PROCESS"));
374 assert!(search_matches(&r, "suspicious"));
375 assert!(!search_matches(&r, "unrelated"));
376 }
377
378 #[test]
379 fn search_matches_an_exact_tag_but_not_a_tag_substring() {
380 let r = Rule::from_value(json!({
381 "rule_id": "a",
382 "name": "Alpha",
383 "tags": ["prod"]
384 }))
385 .unwrap();
386 assert!(search_matches(&r, "prod"));
387 assert!(!search_matches(&r, "pro"), "tags are exact, not substring");
388 }
389
390 #[test]
391 fn an_ambiguous_local_name_is_refused_naming_both_rule_ids() {
392 let local = vec![rule("a", "Same"), rule("b", "Same")];
393 let err = local_match(&local, "Same").unwrap().unwrap_err();
394 assert_eq!(err.kind, ErrorKind::Conflict);
395 assert!(
396 err.message.contains('a') && err.message.contains('b'),
397 "identity is rule_id, so both must be named: {}",
398 err.message
399 );
400 }
401}