Skip to main content

elasticctl_api/
selection.rs

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