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
120pub 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 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 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 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 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 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}