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}