1use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21
22use thiserror::Error;
23
24use crate::paths::{Platform, ResolveError, Resolver};
25
26#[derive(Debug, Error)]
30pub enum PredicateError {
31 #[error("unknown predicate kind `{kind}`")]
33 UnknownKind {
34 kind: String,
36 },
37
38 #[error("malformed predicate `{input}`: {reason}")]
40 Malformed {
41 input: String,
43 reason: String,
45 },
46
47 #[error("resolve error: {source}")]
49 Resolve {
50 #[source]
52 source: Box<ResolveError>,
53 },
54}
55
56impl From<ResolveError> for PredicateError {
57 fn from(e: ResolveError) -> Self {
58 Self::Resolve {
59 source: Box::new(e),
60 }
61 }
62}
63
64pub trait PredicateEnv {
72 fn platform(&self) -> Platform;
75
76 fn env(&self, var: &str) -> Option<String>;
79
80 fn command_exists(&self, name: &str) -> bool;
82
83 fn file_exists(&self, path: &Path) -> bool;
85
86 fn resolver(&self) -> &Resolver;
88}
89
90pub struct DefaultPredicateEnv {
94 resolver: Resolver,
95}
96
97impl DefaultPredicateEnv {
98 pub fn new() -> Self {
100 Self {
101 resolver: Resolver::new(),
102 }
103 }
104
105 pub fn with_resolver(resolver: Resolver) -> Self {
108 Self { resolver }
109 }
110}
111
112impl Default for DefaultPredicateEnv {
113 fn default() -> Self {
114 Self::new()
115 }
116}
117
118impl PredicateEnv for DefaultPredicateEnv {
119 fn platform(&self) -> Platform {
120 Platform::current()
121 }
122
123 fn env(&self, var: &str) -> Option<String> {
124 std::env::var(var).ok()
125 }
126
127 fn command_exists(&self, name: &str) -> bool {
128 which::which(name).is_ok()
129 }
130
131 fn file_exists(&self, path: &Path) -> bool {
132 path.exists()
133 }
134
135 fn resolver(&self) -> &Resolver {
136 &self.resolver
137 }
138}
139
140pub struct MockPredicateEnv {
148 pub platform: Platform,
150 pub env: BTreeMap<String, String>,
152 pub commands: BTreeSet<String>,
154 pub files: BTreeSet<PathBuf>,
156 pub resolver: Resolver,
158}
159
160impl MockPredicateEnv {
161 pub fn new(platform: Platform) -> Self {
163 Self {
164 platform,
165 env: BTreeMap::new(),
166 commands: BTreeSet::new(),
167 files: BTreeSet::new(),
168 resolver: Resolver::for_platform(platform),
169 }
170 }
171}
172
173impl PredicateEnv for MockPredicateEnv {
174 fn platform(&self) -> Platform {
175 self.platform
176 }
177
178 fn env(&self, var: &str) -> Option<String> {
179 self.env.get(var).cloned()
180 }
181
182 fn command_exists(&self, name: &str) -> bool {
183 self.commands.contains(name)
184 }
185
186 fn file_exists(&self, path: &Path) -> bool {
187 self.files.contains(path)
188 }
189
190 fn resolver(&self) -> &Resolver {
191 &self.resolver
192 }
193}
194
195pub fn eval(predicate: &str, env: &dyn PredicateEnv) -> Result<bool, PredicateError> {
203 if predicate.is_empty() {
204 return Ok(true);
205 }
206
207 for term in predicate.split(',') {
208 let term = term.trim();
209
210 if term.is_empty() {
211 return Err(PredicateError::Malformed {
212 input: predicate.to_owned(),
213 reason: "empty term after splitting by `,` (consecutive commas?)".to_owned(),
214 });
215 }
216
217 if !eval_atom(term, predicate, env)? {
218 return Ok(false);
219 }
220 }
221
222 Ok(true)
223}
224
225fn eval_atom(atom: &str, original: &str, env: &dyn PredicateEnv) -> Result<bool, PredicateError> {
227 if let Some(inner) = atom.strip_prefix('!') {
228 if inner.is_empty() {
229 return Err(PredicateError::Malformed {
230 input: original.to_owned(),
231 reason: "`!` must be followed by a predicate, not end-of-input".to_owned(),
232 });
233 }
234 return eval_atom(inner, original, env).map(|v| !v);
235 }
236
237 let (kind, arg) = atom
238 .split_once(':')
239 .ok_or_else(|| PredicateError::Malformed {
240 input: original.to_owned(),
241 reason: format!("`{atom}` has no `:` separator — expected `<kind>:<arg>`"),
242 })?;
243
244 match kind {
245 "command_exists" => {
246 if arg.is_empty() {
247 return Err(PredicateError::Malformed {
248 input: original.to_owned(),
249 reason: "`command_exists:` requires a non-empty command name".to_owned(),
250 });
251 }
252 Ok(env.command_exists(arg))
253 }
254
255 "env" => {
256 if arg.is_empty() {
257 return Err(PredicateError::Malformed {
258 input: original.to_owned(),
259 reason: "`env:` requires a variable name".to_owned(),
260 });
261 }
262 if let Some((var, expected)) = arg.split_once('=') {
263 Ok(env.env(var).as_deref() == Some(expected))
265 } else {
266 Ok(env.env(arg).is_some())
268 }
269 }
270
271 "platform" => {
272 let expected = match arg {
273 "linux" => Platform::Linux,
274 "macos" => Platform::Macos,
275 "windows" => Platform::Windows,
276 other => {
277 return Err(PredicateError::Malformed {
278 input: original.to_owned(),
279 reason: format!(
280 "`platform:{other}` is not a recognised platform; \
281 use `linux`, `macos`, or `windows`"
282 ),
283 });
284 }
285 };
286 Ok(env.platform() == expected)
287 }
288
289 "file_exists" => {
290 if arg.is_empty() {
291 return Err(PredicateError::Malformed {
292 input: original.to_owned(),
293 reason: "`file_exists:` requires a path".to_owned(),
294 });
295 }
296 let resolved = env.resolver().resolve(arg)?;
297 Ok(env.file_exists(Path::new(&resolved)))
298 }
299
300 other => Err(PredicateError::UnknownKind {
301 kind: other.to_owned(),
302 }),
303 }
304}
305
306pub fn default_predicate_evaluator(
315 env: impl PredicateEnv + 'static,
316) -> impl Fn(&str, &crate::runner::Context) -> bool {
317 move |pred, _ctx| match eval(pred, &env) {
318 Ok(v) => v,
319 Err(e) => {
320 tracing::warn!(predicate = pred, error = %e, "predicate eval error — skipping step");
321 false
322 }
323 }
324}
325
326#[cfg(test)]
329mod tests {
330 use std::collections::HashMap;
331
332 use super::*;
333 use crate::paths::Resolver;
334
335 fn mock(platform: Platform) -> MockPredicateEnv {
336 MockPredicateEnv::new(platform)
337 }
338
339 fn linux() -> MockPredicateEnv {
340 mock(Platform::Linux)
341 }
342
343 fn macos() -> MockPredicateEnv {
344 mock(Platform::Macos)
345 }
346
347 fn windows_env() -> MockPredicateEnv {
348 mock(Platform::Windows)
349 }
350
351 #[test]
354 fn command_exists_false_when_not_in_set() {
355 let env = linux();
356 assert!(!eval("command_exists:nonexistent_binary_xyz", &env).unwrap());
357 }
358
359 #[test]
360 fn command_exists_true_when_in_set() {
361 let mut env = linux();
362 env.commands.insert("my_tool".to_owned());
363 assert!(eval("command_exists:my_tool", &env).unwrap());
364 }
365
366 #[test]
369 fn env_var_true_when_set() {
370 let mut env = linux();
371 env.env.insert("HOME".to_owned(), "/home/user".to_owned());
372 assert!(eval("env:HOME", &env).unwrap());
373 }
374
375 #[test]
376 fn env_var_false_when_not_set() {
377 let env = linux();
378 assert!(!eval("env:DOES_NOT_EXIST_XYZ", &env).unwrap());
379 }
380
381 #[test]
382 fn env_var_true_when_set_to_empty() {
383 let mut env = linux();
384 env.env.insert("EMPTY_VAR".to_owned(), String::new());
385 assert!(eval("env:EMPTY_VAR", &env).unwrap());
387 }
388
389 #[test]
392 fn env_value_match_true() {
393 let mut env = linux();
394 env.env.insert("USER".to_owned(), "root".to_owned());
395 assert!(eval("env:USER=root", &env).unwrap());
396 }
397
398 #[test]
399 fn env_value_match_false_when_different() {
400 let mut env = linux();
401 env.env.insert("USER".to_owned(), "alice".to_owned());
402 assert!(!eval("env:USER=root", &env).unwrap());
403 }
404
405 #[test]
406 fn env_value_match_false_when_unset() {
407 let env = linux();
408 assert!(!eval("env:USER=root", &env).unwrap());
409 }
410
411 #[test]
414 fn platform_linux_true_on_linux() {
415 let env = linux();
416 assert!(eval("platform:linux", &env).unwrap());
417 }
418
419 #[test]
420 fn platform_linux_false_on_macos() {
421 let env = macos();
422 assert!(!eval("platform:linux", &env).unwrap());
423 }
424
425 #[test]
426 fn platform_macos_true_on_macos() {
427 let env = macos();
428 assert!(eval("platform:macos", &env).unwrap());
429 }
430
431 #[test]
432 fn platform_windows_true_on_windows() {
433 let env = windows_env();
434 assert!(eval("platform:windows", &env).unwrap());
435 }
436
437 #[test]
440 fn file_exists_true_when_in_set() {
441 let mut env = linux();
442 env.files.insert(PathBuf::from("/etc/passwd"));
443 assert!(eval("file_exists:/etc/passwd", &env).unwrap());
444 }
445
446 #[test]
447 fn file_exists_false_when_not_in_set() {
448 let env = linux();
449 assert!(!eval("file_exists:/no/such/file", &env).unwrap());
450 }
451
452 #[test]
453 fn file_exists_resolves_env_var_before_checking() {
454 let mut env = linux();
455 let resolver = Resolver::for_platform(Platform::Linux).with_env(HashMap::from([(
457 "HOME".to_owned(),
458 "/home/testuser".to_owned(),
459 )]));
460 env.resolver = resolver;
461 env.files.insert(PathBuf::from("/home/testuser/.bashrc"));
462 assert!(eval("file_exists:${HOME}/.bashrc", &env).unwrap());
463 }
464
465 #[test]
468 fn negation_of_false_is_true() {
469 let env = linux();
470 assert!(eval("!command_exists:nonexistent_xyz", &env).unwrap());
471 }
472
473 #[test]
474 fn negation_of_true_is_false() {
475 let env = linux();
476 assert!(!eval("!platform:linux", &env).unwrap());
477 }
478
479 #[test]
482 fn and_both_true_is_true() {
483 let mut env = linux();
484 env.commands.insert("sh".to_owned());
485 assert!(eval("platform:linux,command_exists:sh", &env).unwrap());
486 }
487
488 #[test]
489 fn and_one_false_is_false() {
490 let env = linux();
491 assert!(!eval("platform:linux,command_exists:sh", &env).unwrap());
493 }
494
495 #[test]
496 fn and_three_terms_all_true() {
497 let mut env = linux();
498 env.commands.insert("sh".to_owned());
499 env.commands.insert("ls".to_owned());
500 assert!(eval("platform:linux,command_exists:sh,command_exists:ls", &env).unwrap());
501 }
502
503 #[test]
504 fn and_three_terms_one_false() {
505 let mut env = linux();
506 env.commands.insert("sh".to_owned());
507 assert!(!eval("platform:linux,command_exists:sh,command_exists:ls", &env).unwrap());
509 }
510
511 #[test]
514 fn whitespace_around_commas_accepted() {
515 let mut env = linux();
516 env.commands.insert("sh".to_owned());
517 assert!(eval("platform:linux , command_exists:sh", &env).unwrap());
518 }
519
520 #[test]
523 fn empty_predicate_is_vacuously_true() {
524 let env = linux();
525 assert!(eval("", &env).unwrap());
526 }
527
528 #[test]
531 fn empty_after_split_is_malformed() {
532 let env = linux();
533 assert!(matches!(
534 eval("platform:linux,,command_exists:sh", &env),
535 Err(PredicateError::Malformed { .. })
536 ));
537 }
538
539 #[test]
542 fn unknown_kind_is_error() {
543 let env = linux();
544 assert!(matches!(
545 eval("weather:sunny", &env),
546 Err(PredicateError::UnknownKind { kind }) if kind == "weather"
547 ));
548 }
549
550 #[test]
553 fn no_colon_is_malformed() {
554 let env = linux();
555 assert!(matches!(
556 eval("command_exists", &env),
557 Err(PredicateError::Malformed { .. })
558 ));
559 }
560
561 #[test]
562 fn env_empty_var_name_is_malformed() {
563 let env = linux();
564 assert!(matches!(
565 eval("env:", &env),
566 Err(PredicateError::Malformed { .. })
567 ));
568 }
569
570 #[test]
573 fn negation_of_empty_is_malformed() {
574 let env = linux();
575 assert!(matches!(
576 eval("!", &env),
577 Err(PredicateError::Malformed { .. })
578 ));
579 }
580
581 #[test]
584 fn negation_and_combo() {
585 let mut env = linux();
587 env.commands.insert("rofi".to_owned());
588 assert!(!eval("!platform:linux,command_exists:rofi", &env).unwrap());
589 }
590
591 #[test]
592 fn negation_and_combo_true_on_macos() {
593 let mut env = macos();
595 env.commands.insert("rofi".to_owned());
596 assert!(eval("!platform:linux,command_exists:rofi", &env).unwrap());
597 }
598}