1use core::fmt;
7
8#[derive(Debug, Clone)]
10pub enum ProcessSelector {
11 All,
13 Id(u32),
15 Name(String),
17 Regex(regex::Regex),
19 Fold(String),
21 Instance {
23 name: String,
25 slot: u32,
27 },
28}
29
30fn is_glob(input: &str) -> bool {
40 input.contains(['*', '?', '[', '{'])
41}
42
43fn glob_to_regex(input: &str) -> Result<String, SelectorError> {
56 let glob = globset::Glob::new(input).map_err(|e| SelectorError::BadGlob(e.to_string()))?;
57 let source = glob.regex().to_string();
58 Ok(source
59 .strip_prefix("(?-u)")
60 .map_or(source.clone(), ToString::to_string))
61}
62
63fn parse_u32_digits(s: &str) -> Option<u32> {
71 if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
72 return None;
73 }
74 s.parse().ok()
75}
76
77impl ProcessSelector {
78 pub fn parse(input: &str) -> Result<Self, SelectorError> {
89 if input.is_empty() {
90 return Err(SelectorError::Empty);
91 }
92 if input == "all" {
93 return Ok(Self::All);
94 }
95 if let Some(fold) = input.strip_prefix("fold:") {
96 if fold.is_empty() {
97 return Err(SelectorError::EmptyFold);
98 }
99 return Ok(Self::Fold(fold.to_string()));
100 }
101 if input.len() >= 2 && input.starts_with('/') && input.ends_with('/') {
102 let body = &input[1..input.len() - 1];
103 return regex::Regex::new(body)
104 .map(Self::Regex)
105 .map_err(|e| SelectorError::BadRegex(e.to_string()));
106 }
107 if let Some(id) = parse_u32_digits(input) {
108 return Ok(Self::Id(id));
109 }
110 if is_glob(input) {
111 return glob_to_regex(input)
112 .and_then(|re| {
113 regex::Regex::new(&re).map_err(|e| SelectorError::BadRegex(e.to_string()))
114 })
115 .map(Self::Regex);
116 }
117 if let Some((name, slot)) = input.rsplit_once(':')
121 && !name.is_empty()
122 && let Some(slot) = parse_u32_digits(slot)
123 {
124 return Ok(Self::Instance {
125 name: name.to_string(),
126 slot,
127 });
128 }
129
130 Ok(Self::Name(input.to_string()))
131 }
132
133 #[must_use]
142 pub const fn is_exact(&self) -> bool {
143 match self {
144 Self::Id(_) | Self::Name(_) | Self::Instance { .. } => true,
145 Self::All | Self::Regex(_) | Self::Fold(_) => false,
146 }
147 }
148
149 #[must_use]
151 pub fn matches(&self, name: &str, id: u32, fold: Option<&str>, instance: Option<u32>) -> bool {
152 match self {
153 Self::All => true,
154 Self::Id(want) => *want == id,
155 Self::Name(want) => want == name,
156 Self::Regex(re) => re.is_match(name),
157 Self::Fold(want) => fold == Some(want.as_str()),
158 Self::Instance { name: want, slot } => want == name && instance == Some(*slot),
163 }
164 }
165}
166
167#[non_exhaustive]
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum SelectorError {
177 Empty,
179 EmptyFold,
181 BadRegex(String),
183 BadGlob(String),
185}
186
187impl fmt::Display for SelectorError {
188 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189 match self {
190 Self::Empty => f.write_str("selector is empty"),
191 Self::EmptyFold => f.write_str("fold selector is missing a name"),
192 Self::BadRegex(m) => write!(f, "invalid selector regex: {m}"),
193 Self::BadGlob(m) => write!(f, "invalid selector glob: {m}"),
194 }
195 }
196}
197
198impl core::error::Error for SelectorError {}
199
200impl std::convert::TryFrom<crate::protocol::SelectorSpec> for ProcessSelector {
201 type Error = SelectorError;
202
203 fn try_from(spec: crate::protocol::SelectorSpec) -> Result<Self, Self::Error> {
210 use crate::protocol::SelectorSpec;
211 Ok(match spec {
212 SelectorSpec::All => Self::All,
213 SelectorSpec::Id(id) => Self::Id(id),
214 SelectorSpec::Name(name) => Self::Name(name),
215 SelectorSpec::Fold(fold) => Self::Fold(fold),
216 SelectorSpec::Instance { name, slot } => Self::Instance { name, slot },
217 SelectorSpec::Regex(src) => Self::Regex(
218 regex::RegexBuilder::new(&src)
220 .size_limit(1 << 20)
221 .build()
222 .map_err(|e| SelectorError::BadRegex(e.to_string()))?,
223 ),
224 })
225 }
226}
227
228impl From<&ProcessSelector> for crate::protocol::SelectorSpec {
229 fn from(sel: &ProcessSelector) -> Self {
230 use crate::protocol::SelectorSpec;
231 match sel {
232 ProcessSelector::All => SelectorSpec::All,
233 ProcessSelector::Id(id) => SelectorSpec::Id(*id),
234 ProcessSelector::Name(name) => SelectorSpec::Name(name.clone()),
235 ProcessSelector::Regex(re) => SelectorSpec::Regex(re.as_str().to_string()),
236 ProcessSelector::Fold(fold) => SelectorSpec::Fold(fold.clone()),
237 ProcessSelector::Instance { name, slot } => SelectorSpec::Instance {
238 name: name.clone(),
239 slot: *slot,
240 },
241 }
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn a_name_without_a_metacharacter_is_still_an_exact_name() {
251 for plain in ["api-auth", "web.1", "api_v2", "a-b-c"] {
252 let parsed = ProcessSelector::parse(plain).unwrap();
253 assert!(
254 matches!(&parsed, ProcessSelector::Name(name) if name == plain),
255 "{plain} carries no glob metacharacter and is a name, got {parsed:?}"
256 );
257 }
258 }
259
260 #[test]
261 fn a_glob_matches_by_prefix_and_not_by_substring() {
262 let ProcessSelector::Regex(re) = ProcessSelector::parse("api-*").unwrap() else {
263 panic!("a pattern with `*` is compiled to a regex");
264 };
265 assert!(re.is_match("api-auth"));
266 assert!(re.is_match("api-create"));
267 assert!(!re.is_match("my-api-auth"), "anchored: no substring match");
268 assert!(!re.is_match("reactmap"));
269 }
270
271 #[test]
274 fn each_glob_metacharacter_compiles_and_matches() {
275 let cases = [
276 ("*api*", "my-api-thing", "web"),
277 ("api-?", "api-1", "api-auth"),
278 ("api-[ab]*", "api-auth", "api-create"),
279 ("{web,api}", "api", "worker"),
280 ];
281 for (pattern, hit, miss) in cases {
282 let ProcessSelector::Regex(re) = ProcessSelector::parse(pattern).unwrap() else {
283 panic!("{pattern} must compile to a regex");
284 };
285 assert!(re.is_match(hit), "{pattern} must match {hit}");
286 assert!(!re.is_match(miss), "{pattern} must not match {miss}");
287 }
288 }
289
290 #[test]
291 fn the_earlier_forms_are_not_shadowed_by_the_glob_gate() {
292 assert!(matches!(
293 ProcessSelector::parse("all").unwrap(),
294 ProcessSelector::All
295 ));
296 let fold = ProcessSelector::parse("fold:back*end").unwrap();
297 assert!(
298 matches!(&fold, ProcessSelector::Fold(name) if name == "back*end"),
299 "a fold name may contain a metacharacter and is still a fold, got {fold:?}"
300 );
301 let ProcessSelector::Regex(re) = ProcessSelector::parse("/^api-/").unwrap() else {
302 panic!("an explicit regex stays a regex");
303 };
304 assert!(re.is_match("api-auth"));
305 }
306
307 #[test]
310 fn an_unparseable_glob_is_refused() {
311 let err = ProcessSelector::parse("api-[").expect_err("an unclosed class is not a glob");
312 assert!(
313 matches!(err, SelectorError::BadGlob(_)),
314 "expected BadGlob, got {err:?}"
315 );
316 assert!(err.to_string().contains("glob"), "{err}");
317 }
318
319 #[test]
320 fn parse_rules() {
321 assert!(matches!(
322 ProcessSelector::parse("all").unwrap(),
323 ProcessSelector::All
324 ));
325 assert!(matches!(
326 ProcessSelector::parse("3").unwrap(),
327 ProcessSelector::Id(3)
328 ));
329 assert!(matches!(
330 ProcessSelector::parse("web").unwrap(),
331 ProcessSelector::Name(n) if n == "web"
332 ));
333 assert!(matches!(
334 ProcessSelector::parse("/^w/").unwrap(),
335 ProcessSelector::Regex(_)
336 ));
337 assert!(matches!(
338 ProcessSelector::parse("fold:backend").unwrap(),
339 ProcessSelector::Fold(fname) if fname == "backend"
340 ));
341 }
342
343 #[test]
344 fn parse_errors() {
345 assert_eq!(
346 ProcessSelector::parse("").unwrap_err(),
347 SelectorError::Empty
348 );
349 assert_eq!(
350 ProcessSelector::parse("fold:").unwrap_err(),
351 SelectorError::EmptyFold
352 );
353 assert!(matches!(
354 ProcessSelector::parse("/((/").unwrap_err(),
355 SelectorError::BadRegex(_)
356 ));
357 }
358
359 #[test]
360 fn matching() {
361 let by_name = ProcessSelector::parse("web").unwrap();
362 assert!(by_name.matches("web", 0, None, None));
363 assert!(!by_name.matches("worker", 0, None, None));
364
365 let by_regex = ProcessSelector::parse("/^w/").unwrap();
366 assert!(by_regex.matches("worker", 9, None, None));
367 assert!(!by_regex.matches("api", 9, None, None));
368
369 let by_fold = ProcessSelector::parse("fold:backend").unwrap();
370 assert!(by_fold.matches("anything", 0, Some("backend"), None));
371 assert!(!by_fold.matches("anything", 0, None, None));
372
373 assert!(
374 ProcessSelector::parse("all")
375 .unwrap()
376 .matches("x", 42, None, None)
377 );
378 assert!(
379 ProcessSelector::parse("42")
380 .unwrap()
381 .matches("x", 42, None, None)
382 );
383 }
384
385 #[test]
388 fn only_a_name_or_an_id_names_one_entry_the_caller_knew_of() {
389 assert!(ProcessSelector::Name("bark".into()).is_exact());
390 assert!(ProcessSelector::Id(4).is_exact());
391 assert!(!ProcessSelector::All.is_exact());
392 assert!(!ProcessSelector::Fold("api".into()).is_exact());
393 assert!(!ProcessSelector::parse("/^bark$/").unwrap().is_exact());
396 }
397
398 #[test]
399 fn a_name_that_looks_numeric_is_an_id() {
400 assert!(matches!(
403 ProcessSelector::parse("42").unwrap(),
404 ProcessSelector::Id(42)
405 ));
406 }
407
408 #[test]
409 fn a_signed_number_is_a_name_at_both_sites_that_read_one() {
410 assert!(matches!(
414 ProcessSelector::parse("+5").unwrap(),
415 ProcessSelector::Name(name) if name == "+5"
416 ));
417 assert!(matches!(
418 ProcessSelector::parse("web:+2").unwrap(),
419 ProcessSelector::Name(name) if name == "web:+2"
420 ));
421 }
422
423 #[test]
424 fn selector_spec_bridges() {
425 use crate::protocol::SelectorSpec;
426 let sel: ProcessSelector = SelectorSpec::Regex("^w".to_string()).try_into().unwrap();
427 assert!(sel.matches("web", 1, None, None));
428 assert_eq!(
429 SelectorSpec::from(&sel),
430 SelectorSpec::Regex("^w".to_string())
431 );
432 for spec in [
433 SelectorSpec::All,
434 SelectorSpec::Id(3),
435 SelectorSpec::Name("web".to_string()),
436 SelectorSpec::Fold("backend".to_string()),
437 SelectorSpec::Instance {
438 name: "web".to_string(),
439 slot: 2,
440 },
441 ] {
442 let sel: ProcessSelector = spec.clone().try_into().unwrap();
443 assert_eq!(SelectorSpec::from(&sel), spec);
444 }
445 }
446
447 #[test]
448 fn selector_spec_bad_regex_is_typed_error() {
449 use crate::protocol::SelectorSpec;
450 assert!(matches!(
451 ProcessSelector::try_from(SelectorSpec::Regex("((".to_string())).unwrap_err(),
452 SelectorError::BadRegex(_)
453 ));
454 }
455
456 #[test]
457 fn an_instance_form_parses_and_matches_only_its_slot() {
458 let sel = ProcessSelector::parse("web:2").expect("parses");
459 assert!(matches!(
460 &sel,
461 ProcessSelector::Instance { name, slot } if name == "web" && *slot == 2
462 ));
463 assert!(sel.matches("web", 7, None, Some(2)));
464 assert!(!sel.matches("web", 7, None, Some(1)));
465 assert!(!sel.matches("api", 7, None, Some(2)));
466 assert!(
467 !sel.matches("web", 7, None, None),
468 "an older daemon's row carries no slot, so it cannot be the one asked for"
469 );
470 }
471
472 #[test]
473 fn an_instance_selector_names_one_entry_so_it_is_exact() {
474 assert!(
476 ProcessSelector::parse("metrics:0")
477 .expect("parses")
478 .is_exact()
479 );
480 }
481
482 #[test]
483 fn the_colon_forms_do_not_shadow_each_other() {
484 assert!(matches!(
485 ProcessSelector::parse("fold:web").expect("parses"),
486 ProcessSelector::Fold(_)
487 ));
488 assert!(matches!(
489 ProcessSelector::parse("web:2").expect("parses"),
490 ProcessSelector::Instance { .. }
491 ));
492 assert!(matches!(
495 ProcessSelector::parse("web:two").expect("parses"),
496 ProcessSelector::Name(_)
497 ));
498 assert!(matches!(
500 ProcessSelector::parse("web*:2").expect("parses"),
501 ProcessSelector::Regex(_)
502 ));
503 assert!(matches!(
505 ProcessSelector::parse("11").expect("parses"),
506 ProcessSelector::Id(11)
507 ));
508 }
509
510 #[test]
511 fn an_instance_selector_round_trips_through_the_wire_form() {
512 let sel = ProcessSelector::parse("web:2").expect("parses");
513 let spec = crate::protocol::SelectorSpec::from(&sel);
514 assert_eq!(
515 spec,
516 crate::protocol::SelectorSpec::Instance {
517 name: "web".to_string(),
518 slot: 2
519 }
520 );
521 let back = ProcessSelector::try_from(spec).expect("converts back");
522 assert!(matches!(back, ProcessSelector::Instance { .. }));
523 }
524
525 #[test]
526 fn selector_spec_oversized_regex_is_rejected() {
527 use crate::protocol::SelectorSpec;
532 let huge = format!("(a{}){{10000}}", "|b".repeat(100_000));
533 assert!(ProcessSelector::try_from(SelectorSpec::Regex(huge)).is_err());
534 }
535}