1use core::fmt;
6
7#[derive(Debug, Clone)]
9pub enum ProcessSelector {
10 All,
12 Id(u32),
14 Name(String),
16 Regex(regex::Regex),
18 Fold(String),
20}
21
22fn is_glob(input: &str) -> bool {
33 input.contains(['*', '?', '[', '{'])
34}
35
36fn glob_to_regex(input: &str) -> Result<String, SelectorError> {
58 let glob = globset::Glob::new(input).map_err(|e| SelectorError::BadGlob(e.to_string()))?;
59 let source = glob.regex().to_string();
60 Ok(source
61 .strip_prefix("(?-u)")
62 .map_or(source.clone(), ToString::to_string))
63}
64
65impl ProcessSelector {
66 pub fn parse(input: &str) -> Result<Self, SelectorError> {
77 if input.is_empty() {
78 return Err(SelectorError::Empty);
79 }
80 if input == "all" {
81 return Ok(Self::All);
82 }
83 if let Some(fold) = input.strip_prefix("fold:") {
84 if fold.is_empty() {
85 return Err(SelectorError::EmptyFold);
86 }
87 return Ok(Self::Fold(fold.to_string()));
88 }
89 if input.len() >= 2 && input.starts_with('/') && input.ends_with('/') {
90 let body = &input[1..input.len() - 1];
91 return regex::Regex::new(body)
92 .map(Self::Regex)
93 .map_err(|e| SelectorError::BadRegex(e.to_string()));
94 }
95 if input.bytes().all(|b| b.is_ascii_digit())
96 && let Ok(id) = input.parse()
97 {
98 return Ok(Self::Id(id));
99 }
100 if is_glob(input) {
101 return glob_to_regex(input)
102 .and_then(|re| {
103 regex::Regex::new(&re).map_err(|e| SelectorError::BadRegex(e.to_string()))
104 })
105 .map(Self::Regex);
106 }
107 Ok(Self::Name(input.to_string()))
108 }
109
110 #[must_use]
120 pub const fn is_exact(&self) -> bool {
121 match self {
122 Self::Id(_) | Self::Name(_) => true,
123 Self::All | Self::Regex(_) | Self::Fold(_) => false,
124 }
125 }
126
127 #[must_use]
129 pub fn matches(&self, name: &str, id: u32, fold: Option<&str>) -> bool {
130 match self {
131 Self::All => true,
132 Self::Id(want) => *want == id,
133 Self::Name(want) => want == name,
134 Self::Regex(re) => re.is_match(name),
135 Self::Fold(want) => fold == Some(want.as_str()),
136 }
137 }
138}
139
140#[non_exhaustive]
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum SelectorError {
152 Empty,
154 EmptyFold,
156 BadRegex(String),
158 BadGlob(String),
160}
161
162impl fmt::Display for SelectorError {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 match self {
165 Self::Empty => f.write_str("selector is empty"),
166 Self::EmptyFold => f.write_str("fold selector is missing a name"),
167 Self::BadRegex(m) => write!(f, "invalid selector regex: {m}"),
168 Self::BadGlob(m) => write!(f, "invalid selector glob: {m}"),
169 }
170 }
171}
172
173impl core::error::Error for SelectorError {}
174
175impl std::convert::TryFrom<crate::protocol::SelectorSpec> for ProcessSelector {
176 type Error = SelectorError;
177
178 fn try_from(spec: crate::protocol::SelectorSpec) -> Result<Self, Self::Error> {
185 use crate::protocol::SelectorSpec;
186 Ok(match spec {
187 SelectorSpec::All => Self::All,
188 SelectorSpec::Id(id) => Self::Id(id),
189 SelectorSpec::Name(name) => Self::Name(name),
190 SelectorSpec::Fold(fold) => Self::Fold(fold),
191 SelectorSpec::Regex(src) => Self::Regex(
192 regex::RegexBuilder::new(&src)
194 .size_limit(1 << 20)
195 .build()
196 .map_err(|e| SelectorError::BadRegex(e.to_string()))?,
197 ),
198 })
199 }
200}
201
202impl From<&ProcessSelector> for crate::protocol::SelectorSpec {
203 fn from(sel: &ProcessSelector) -> Self {
204 use crate::protocol::SelectorSpec;
205 match sel {
206 ProcessSelector::All => SelectorSpec::All,
207 ProcessSelector::Id(id) => SelectorSpec::Id(*id),
208 ProcessSelector::Name(name) => SelectorSpec::Name(name.clone()),
209 ProcessSelector::Regex(re) => SelectorSpec::Regex(re.as_str().to_string()),
210 ProcessSelector::Fold(fold) => SelectorSpec::Fold(fold.clone()),
211 }
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
222 fn a_name_without_a_metacharacter_is_still_an_exact_name() {
223 for plain in ["zeus-auth", "web.1", "api_v2", "a-b-c"] {
224 let parsed = ProcessSelector::parse(plain).unwrap();
225 assert!(
226 matches!(&parsed, ProcessSelector::Name(name) if name == plain),
227 "{plain} carries no glob metacharacter and is a name, got {parsed:?}"
228 );
229 }
230 }
231
232 #[test]
235 fn a_glob_matches_by_prefix_and_not_by_substring() {
236 let ProcessSelector::Regex(re) = ProcessSelector::parse("zeus-*").unwrap() else {
237 panic!("a pattern with `*` is compiled to a regex");
238 };
239 assert!(re.is_match("zeus-auth"));
240 assert!(re.is_match("zeus-create"));
241 assert!(!re.is_match("my-zeus-auth"), "anchored: no substring match");
242 assert!(!re.is_match("reactmap"));
243 }
244
245 #[test]
248 fn each_glob_metacharacter_compiles_and_matches() {
249 let cases = [
250 ("*api*", "my-api-thing", "web"),
251 ("zeus-?", "zeus-1", "zeus-auth"),
252 ("zeus-[ab]*", "zeus-auth", "zeus-create"),
253 ("{web,api}", "api", "worker"),
254 ];
255 for (pattern, hit, miss) in cases {
256 let ProcessSelector::Regex(re) = ProcessSelector::parse(pattern).unwrap() else {
257 panic!("{pattern} must compile to a regex");
258 };
259 assert!(re.is_match(hit), "{pattern} must match {hit}");
260 assert!(!re.is_match(miss), "{pattern} must not match {miss}");
261 }
262 }
263
264 #[test]
267 fn the_earlier_forms_are_not_shadowed_by_the_glob_gate() {
268 assert!(matches!(
269 ProcessSelector::parse("all").unwrap(),
270 ProcessSelector::All
271 ));
272 let fold = ProcessSelector::parse("fold:back*end").unwrap();
273 assert!(
274 matches!(&fold, ProcessSelector::Fold(name) if name == "back*end"),
275 "a fold name may contain a metacharacter and is still a fold, got {fold:?}"
276 );
277 let ProcessSelector::Regex(re) = ProcessSelector::parse("/^zeus-/").unwrap() else {
278 panic!("an explicit regex stays a regex");
279 };
280 assert!(re.is_match("zeus-auth"));
281 }
282
283 #[test]
286 fn an_unparseable_glob_is_refused() {
287 let err = ProcessSelector::parse("zeus-[").expect_err("an unclosed class is not a glob");
288 assert!(
289 matches!(err, SelectorError::BadGlob(_)),
290 "expected BadGlob, got {err:?}"
291 );
292 assert!(err.to_string().contains("glob"), "{err}");
293 }
294
295 #[test]
296 fn parse_rules() {
297 assert!(matches!(
298 ProcessSelector::parse("all").unwrap(),
299 ProcessSelector::All
300 ));
301 assert!(matches!(
302 ProcessSelector::parse("3").unwrap(),
303 ProcessSelector::Id(3)
304 ));
305 assert!(matches!(
306 ProcessSelector::parse("web").unwrap(),
307 ProcessSelector::Name(n) if n == "web"
308 ));
309 assert!(matches!(
310 ProcessSelector::parse("/^w/").unwrap(),
311 ProcessSelector::Regex(_)
312 ));
313 assert!(matches!(
314 ProcessSelector::parse("fold:backend").unwrap(),
315 ProcessSelector::Fold(fname) if fname == "backend"
316 ));
317 }
318
319 #[test]
320 fn parse_errors() {
321 assert_eq!(
322 ProcessSelector::parse("").unwrap_err(),
323 SelectorError::Empty
324 );
325 assert_eq!(
326 ProcessSelector::parse("fold:").unwrap_err(),
327 SelectorError::EmptyFold
328 );
329 assert!(matches!(
330 ProcessSelector::parse("/((/").unwrap_err(),
331 SelectorError::BadRegex(_)
332 ));
333 }
334
335 #[test]
336 fn matching() {
337 let by_name = ProcessSelector::parse("web").unwrap();
338 assert!(by_name.matches("web", 0, None));
339 assert!(!by_name.matches("worker", 0, None));
340
341 let by_regex = ProcessSelector::parse("/^w/").unwrap();
342 assert!(by_regex.matches("worker", 9, None));
343 assert!(!by_regex.matches("api", 9, None));
344
345 let by_fold = ProcessSelector::parse("fold:backend").unwrap();
346 assert!(by_fold.matches("anything", 0, Some("backend")));
347 assert!(!by_fold.matches("anything", 0, None));
348
349 assert!(
350 ProcessSelector::parse("all")
351 .unwrap()
352 .matches("x", 42, None)
353 );
354 assert!(ProcessSelector::parse("42").unwrap().matches("x", 42, None));
355 }
356
357 #[test]
362 fn only_a_name_or_an_id_names_one_entry_the_caller_knew_of() {
363 assert!(ProcessSelector::Name("bark".into()).is_exact());
364 assert!(ProcessSelector::Id(4).is_exact());
365 assert!(!ProcessSelector::All.is_exact());
366 assert!(!ProcessSelector::Fold("api".into()).is_exact());
367 assert!(!ProcessSelector::parse("/^bark$/").unwrap().is_exact());
370 }
371
372 #[test]
373 fn a_name_that_looks_numeric_is_an_id() {
374 assert!(matches!(
377 ProcessSelector::parse("42").unwrap(),
378 ProcessSelector::Id(42)
379 ));
380 }
381
382 #[test]
383 fn selector_spec_bridges() {
384 use crate::protocol::SelectorSpec;
385 let sel: ProcessSelector = SelectorSpec::Regex("^w".to_string()).try_into().unwrap();
386 assert!(sel.matches("web", 1, None));
387 assert_eq!(
388 SelectorSpec::from(&sel),
389 SelectorSpec::Regex("^w".to_string())
390 );
391 for spec in [
392 SelectorSpec::All,
393 SelectorSpec::Id(3),
394 SelectorSpec::Name("web".to_string()),
395 SelectorSpec::Fold("backend".to_string()),
396 ] {
397 let sel: ProcessSelector = spec.clone().try_into().unwrap();
398 assert_eq!(SelectorSpec::from(&sel), spec);
399 }
400 }
401
402 #[test]
403 fn selector_spec_bad_regex_is_typed_error() {
404 use crate::protocol::SelectorSpec;
405 assert!(matches!(
406 ProcessSelector::try_from(SelectorSpec::Regex("((".to_string())).unwrap_err(),
407 SelectorError::BadRegex(_)
408 ));
409 }
410
411 #[test]
412 fn selector_spec_oversized_regex_is_rejected() {
413 use crate::protocol::SelectorSpec;
418 let huge = format!("(a{}){{10000}}", "|b".repeat(100_000));
419 assert!(ProcessSelector::try_from(SelectorSpec::Regex(huge)).is_err());
420 }
421}