Skip to main content

elasticctl_api/
selection.rs

1//! Resolve user-facing selectors to stable `rule_id` values.
2//!
3//! One resolver serves `rules export`, `delete`, `enable`, and state commands.
4//! This keeps ambiguous-name and empty-match behavior consistent.
5//!
6//! Identity is always `rule_id`. Names only find IDs.
7
8use crate::model::Rule;
9use crate::rules::{self, RuleFilter};
10use elasticctl_core::{Error, ErrorKind, Result, Transport};
11
12/// Displays an unreadable `rule_id`, such as a non-string value from the
13/// server.
14pub const UNREADABLE_RULE_ID: &str = "<unreadable rule_id>";
15
16/// Same-named candidates to consider in one page. The cap prevents a
17/// pathological name lookup from becoming a corpus read.
18pub const NAME_SEARCH_LIMIT: u32 = 100;
19
20/// Match names exactly. A prefix match could target the wrong rule.
21pub 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            // The selector might be an absent `rule_id`; reporting a missed
29            // name would misidentify the problem.
30            format!("No rule with rule_id or name '{name}'"),
31        )),
32        _ => {
33            // List every candidate, including unreadable IDs, so the count and
34            // list agree.
35            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
51/// Run `pick_by_name` on a candidate page with the server's candidate total.
52///
53/// The KQL filter can return near matches on an analyzed field, so compare
54/// exactly here. If the page is truncated with no exact match, report the cap
55/// instead of claiming the name is absent.
56pub 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
70/// Resolve a `rule_id` or display name against the stack. Try `rule_id` first
71/// because it is unambiguous.
72///
73/// Name lookup uses one server-filtered `_find`. Walking 2,066 rules took 8.8
74/// seconds only to report no match.
75pub 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
90/// Match a selector against local rules before querying the stack.
91///
92/// `None` means no local match, so the caller queries the stack.
93/// `Some(Err(..))` indicates an ambiguous local name.
94fn 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
120/// Resolve selectors and an optional tag to `rule_id` values. Returns `None`
121/// when neither is given and the caller should act on every rule.
122///
123/// Check `local` before the stack. Pass an empty slice for `rules export` and
124/// `state pull`. Pass disk rules for local commands so scoped `push` can select
125/// locally added rules that the stack does not have.
126///
127/// `noun` completes command-specific refusal messages, such as "nothing to
128/// export".
129pub async fn resolve(
130    t: &Transport,
131    selectors: &[String],
132    tag: Option<&str>,
133    local: &[Rule],
134    noun: &str,
135) -> Result<Option<Vec<String>>> {
136    if selectors.is_empty() && tag.is_none() {
137        return Ok(None);
138    }
139
140    let mut ids: Vec<String> = Vec::new();
141    for s in selectors {
142        match local_match(local, s) {
143            Some(found) => ids.push(found?),
144            None => ids.push(to_rule_id(t, s).await?),
145        }
146    }
147
148    // Track tag matches separately so a selector cannot hide an unmatched tag.
149    let mut tag_matched = false;
150    if let Some(tag) = tag {
151        for rule in local.iter().filter(|r| r.tags().contains(&tag)) {
152            tag_matched = true;
153            ids.push(rule.rule_id()?.to_string());
154        }
155
156        let filter = RuleFilter {
157            tag: Some(tag.to_string()),
158            ..Default::default()
159        };
160        for rule in rules::find_all(t, &filter).await? {
161            tag_matched = true;
162            ids.push(rule.rule_id()?.to_string());
163        }
164    }
165
166    ids.sort();
167    ids.dedup();
168
169    // Report an unmatched `--tag` even when a selector matched. A mistyped tag
170    // must not silently shrink the result.
171    if let Some(t) = tag
172        && !tag_matched
173    {
174        return Err(Error::new(
175            ErrorKind::NotFound,
176            format!("No rules matched tag '{t}'; nothing to {noun}"),
177        ));
178    }
179
180    // Defensive fallback: name the selector in the refusal rather than leaving
181    // it blank.
182    if ids.is_empty() {
183        return Err(Error::new(
184            ErrorKind::NotFound,
185            format!(
186                "No rules matched the selector(s) '{}'; nothing to {noun}",
187                selectors.join("', '")
188            ),
189        ));
190    }
191
192    Ok(Some(ids))
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use serde_json::json;
199
200    fn rule(id: &str, name: &str) -> Rule {
201        Rule::from_value(json!({"rule_id": id, "name": name})).unwrap()
202    }
203
204    #[test]
205    fn a_unique_name_resolves_to_its_rule_id() {
206        let found = vec![rule("a", "Alpha"), rule("b", "Beta")];
207        assert_eq!(pick_by_name(&found, "Beta").unwrap(), "b");
208    }
209
210    #[test]
211    fn an_ambiguous_name_is_a_conflict_listing_every_candidate() {
212        let found = vec![rule("a", "Same"), rule("b", "Same")];
213        let err = pick_by_name(&found, "Same").unwrap_err();
214        assert_eq!(err.kind, ErrorKind::Conflict);
215        assert!(
216            err.message.contains('a') && err.message.contains('b'),
217            "{}",
218            err.message
219        );
220    }
221
222    #[test]
223    fn a_name_with_no_match_is_not_found() {
224        let err = pick_by_name(&[rule("a", "Alpha")], "Ghost").unwrap_err();
225        assert_eq!(err.kind, ErrorKind::NotFound);
226    }
227
228    #[test]
229    fn a_capped_candidate_page_says_so_rather_than_claiming_the_name_is_absent() {
230        // A truncated result must not report the name as absent.
231        let found: Vec<Rule> = (0..NAME_SEARCH_LIMIT)
232            .map(|i| rule(&format!("id-{i}"), "Other"))
233            .collect();
234        let err = pick_by_name_capped(&found, "Ghost", NAME_SEARCH_LIMIT as u64 + 1).unwrap_err();
235        assert_eq!(err.kind, ErrorKind::NotFound);
236        assert!(
237            err.message.contains("first 100"),
238            "the cap must be visible: {}",
239            err.message
240        );
241    }
242
243    #[test]
244    fn name_matching_is_exact_not_substring() {
245        let err = pick_by_name(&[rule("a", "Alpha Rule")], "Alpha").unwrap_err();
246        assert_eq!(err.kind, ErrorKind::NotFound);
247    }
248
249    #[test]
250    fn a_conflict_still_names_every_candidate_when_one_rule_id_is_unreadable() {
251        let readable = rule("a", "Same");
252        // `Rule::from_value` rejects non-string IDs, but transparent
253        // `Deserialize` does not, so `pick_by_name` must handle one.
254        let unreadable: Rule =
255            serde_json::from_value(json!({"rule_id": 123, "name": "Same"})).unwrap();
256        let found = vec![readable, unreadable];
257
258        let err = pick_by_name(&found, "Same").unwrap_err();
259
260        assert_eq!(err.kind, ErrorKind::Conflict);
261        assert!(err.message.contains("2 rules"), "{}", err.message);
262        assert!(
263            err.message.contains('a') && err.message.contains(UNREADABLE_RULE_ID),
264            "the count claims 2 candidates, so both must be listed: {}",
265            err.message
266        );
267    }
268
269    #[test]
270    fn a_local_rule_id_matches_without_asking_the_stack() {
271        let local = vec![rule("a", "Alpha")];
272        assert_eq!(local_match(&local, "a").unwrap().unwrap(), "a");
273    }
274
275    #[test]
276    fn a_local_name_resolves_to_its_rule_id() {
277        let local = vec![rule("a", "Alpha")];
278        assert_eq!(local_match(&local, "Alpha").unwrap().unwrap(), "a");
279    }
280
281    #[test]
282    fn an_unmatched_selector_falls_through_to_the_stack() {
283        let local = vec![rule("a", "Alpha")];
284        assert!(local_match(&local, "Ghost").is_none());
285    }
286
287    #[test]
288    fn an_ambiguous_local_name_is_refused_naming_both_rule_ids() {
289        let local = vec![rule("a", "Same"), rule("b", "Same")];
290        let err = local_match(&local, "Same").unwrap().unwrap_err();
291        assert_eq!(err.kind, ErrorKind::Conflict);
292        assert!(
293            err.message.contains('a') && err.message.contains('b'),
294            "identity is rule_id, so both must be named: {}",
295            err.message
296        );
297    }
298}