use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::paths::{Platform, ResolveError, Resolver};
#[derive(Debug, Error)]
pub enum PredicateError {
#[error("unknown predicate kind `{kind}`")]
UnknownKind {
kind: String,
},
#[error("malformed predicate `{input}`: {reason}")]
Malformed {
input: String,
reason: String,
},
#[error("resolve error: {source}")]
Resolve {
#[source]
source: Box<ResolveError>,
},
}
impl From<ResolveError> for PredicateError {
fn from(e: ResolveError) -> Self {
Self::Resolve {
source: Box::new(e),
}
}
}
pub trait PredicateEnv {
fn platform(&self) -> Platform;
fn env(&self, var: &str) -> Option<String>;
fn command_exists(&self, name: &str) -> bool;
fn file_exists(&self, path: &Path) -> bool;
fn resolver(&self) -> &Resolver;
}
pub struct DefaultPredicateEnv {
resolver: Resolver,
}
impl DefaultPredicateEnv {
pub fn new() -> Self {
Self {
resolver: Resolver::new(),
}
}
pub fn with_resolver(resolver: Resolver) -> Self {
Self { resolver }
}
}
impl Default for DefaultPredicateEnv {
fn default() -> Self {
Self::new()
}
}
impl PredicateEnv for DefaultPredicateEnv {
fn platform(&self) -> Platform {
Platform::current()
}
fn env(&self, var: &str) -> Option<String> {
std::env::var(var).ok()
}
fn command_exists(&self, name: &str) -> bool {
which::which(name).is_ok()
}
fn file_exists(&self, path: &Path) -> bool {
path.exists()
}
fn resolver(&self) -> &Resolver {
&self.resolver
}
}
pub struct MockPredicateEnv {
pub platform: Platform,
pub env: BTreeMap<String, String>,
pub commands: BTreeSet<String>,
pub files: BTreeSet<PathBuf>,
pub resolver: Resolver,
}
impl MockPredicateEnv {
pub fn new(platform: Platform) -> Self {
Self {
platform,
env: BTreeMap::new(),
commands: BTreeSet::new(),
files: BTreeSet::new(),
resolver: Resolver::for_platform(platform),
}
}
}
impl PredicateEnv for MockPredicateEnv {
fn platform(&self) -> Platform {
self.platform
}
fn env(&self, var: &str) -> Option<String> {
self.env.get(var).cloned()
}
fn command_exists(&self, name: &str) -> bool {
self.commands.contains(name)
}
fn file_exists(&self, path: &Path) -> bool {
self.files.contains(path)
}
fn resolver(&self) -> &Resolver {
&self.resolver
}
}
pub fn eval(predicate: &str, env: &dyn PredicateEnv) -> Result<bool, PredicateError> {
if predicate.is_empty() {
return Ok(true);
}
for term in predicate.split(',') {
let term = term.trim();
if term.is_empty() {
return Err(PredicateError::Malformed {
input: predicate.to_owned(),
reason: "empty term after splitting by `,` (consecutive commas?)".to_owned(),
});
}
if !eval_atom(term, predicate, env)? {
return Ok(false);
}
}
Ok(true)
}
fn eval_atom(atom: &str, original: &str, env: &dyn PredicateEnv) -> Result<bool, PredicateError> {
if let Some(inner) = atom.strip_prefix('!') {
if inner.is_empty() {
return Err(PredicateError::Malformed {
input: original.to_owned(),
reason: "`!` must be followed by a predicate, not end-of-input".to_owned(),
});
}
return eval_atom(inner, original, env).map(|v| !v);
}
let (kind, arg) = atom
.split_once(':')
.ok_or_else(|| PredicateError::Malformed {
input: original.to_owned(),
reason: format!("`{atom}` has no `:` separator — expected `<kind>:<arg>`"),
})?;
match kind {
"command_exists" => {
if arg.is_empty() {
return Err(PredicateError::Malformed {
input: original.to_owned(),
reason: "`command_exists:` requires a non-empty command name".to_owned(),
});
}
Ok(env.command_exists(arg))
}
"env" => {
if arg.is_empty() {
return Err(PredicateError::Malformed {
input: original.to_owned(),
reason: "`env:` requires a variable name".to_owned(),
});
}
if let Some((var, expected)) = arg.split_once('=') {
Ok(env.env(var).as_deref() == Some(expected))
} else {
Ok(env.env(arg).is_some())
}
}
"platform" => {
let expected = match arg {
"linux" => Platform::Linux,
"macos" => Platform::Macos,
"windows" => Platform::Windows,
other => {
return Err(PredicateError::Malformed {
input: original.to_owned(),
reason: format!(
"`platform:{other}` is not a recognised platform; \
use `linux`, `macos`, or `windows`"
),
});
}
};
Ok(env.platform() == expected)
}
"file_exists" => {
if arg.is_empty() {
return Err(PredicateError::Malformed {
input: original.to_owned(),
reason: "`file_exists:` requires a path".to_owned(),
});
}
let resolved = env.resolver().resolve(arg)?;
Ok(env.file_exists(Path::new(&resolved)))
}
other => Err(PredicateError::UnknownKind {
kind: other.to_owned(),
}),
}
}
pub fn default_predicate_evaluator(
env: impl PredicateEnv + 'static,
) -> impl Fn(&str, &crate::runner::Context) -> bool {
move |pred, _ctx| match eval(pred, &env) {
Ok(v) => v,
Err(e) => {
tracing::warn!(predicate = pred, error = %e, "predicate eval error — skipping step");
false
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::paths::Resolver;
fn mock(platform: Platform) -> MockPredicateEnv {
MockPredicateEnv::new(platform)
}
fn linux() -> MockPredicateEnv {
mock(Platform::Linux)
}
fn macos() -> MockPredicateEnv {
mock(Platform::Macos)
}
fn windows_env() -> MockPredicateEnv {
mock(Platform::Windows)
}
#[test]
fn command_exists_false_when_not_in_set() {
let env = linux();
assert!(!eval("command_exists:nonexistent_binary_xyz", &env).unwrap());
}
#[test]
fn command_exists_true_when_in_set() {
let mut env = linux();
env.commands.insert("my_tool".to_owned());
assert!(eval("command_exists:my_tool", &env).unwrap());
}
#[test]
fn env_var_true_when_set() {
let mut env = linux();
env.env.insert("HOME".to_owned(), "/home/user".to_owned());
assert!(eval("env:HOME", &env).unwrap());
}
#[test]
fn env_var_false_when_not_set() {
let env = linux();
assert!(!eval("env:DOES_NOT_EXIST_XYZ", &env).unwrap());
}
#[test]
fn env_var_true_when_set_to_empty() {
let mut env = linux();
env.env.insert("EMPTY_VAR".to_owned(), String::new());
assert!(eval("env:EMPTY_VAR", &env).unwrap());
}
#[test]
fn env_value_match_true() {
let mut env = linux();
env.env.insert("USER".to_owned(), "root".to_owned());
assert!(eval("env:USER=root", &env).unwrap());
}
#[test]
fn env_value_match_false_when_different() {
let mut env = linux();
env.env.insert("USER".to_owned(), "alice".to_owned());
assert!(!eval("env:USER=root", &env).unwrap());
}
#[test]
fn env_value_match_false_when_unset() {
let env = linux();
assert!(!eval("env:USER=root", &env).unwrap());
}
#[test]
fn platform_linux_true_on_linux() {
let env = linux();
assert!(eval("platform:linux", &env).unwrap());
}
#[test]
fn platform_linux_false_on_macos() {
let env = macos();
assert!(!eval("platform:linux", &env).unwrap());
}
#[test]
fn platform_macos_true_on_macos() {
let env = macos();
assert!(eval("platform:macos", &env).unwrap());
}
#[test]
fn platform_windows_true_on_windows() {
let env = windows_env();
assert!(eval("platform:windows", &env).unwrap());
}
#[test]
fn file_exists_true_when_in_set() {
let mut env = linux();
env.files.insert(PathBuf::from("/etc/passwd"));
assert!(eval("file_exists:/etc/passwd", &env).unwrap());
}
#[test]
fn file_exists_false_when_not_in_set() {
let env = linux();
assert!(!eval("file_exists:/no/such/file", &env).unwrap());
}
#[test]
fn file_exists_resolves_env_var_before_checking() {
let mut env = linux();
let resolver = Resolver::for_platform(Platform::Linux).with_env(HashMap::from([(
"HOME".to_owned(),
"/home/testuser".to_owned(),
)]));
env.resolver = resolver;
env.files.insert(PathBuf::from("/home/testuser/.bashrc"));
assert!(eval("file_exists:${HOME}/.bashrc", &env).unwrap());
}
#[test]
fn negation_of_false_is_true() {
let env = linux();
assert!(eval("!command_exists:nonexistent_xyz", &env).unwrap());
}
#[test]
fn negation_of_true_is_false() {
let env = linux();
assert!(!eval("!platform:linux", &env).unwrap());
}
#[test]
fn and_both_true_is_true() {
let mut env = linux();
env.commands.insert("sh".to_owned());
assert!(eval("platform:linux,command_exists:sh", &env).unwrap());
}
#[test]
fn and_one_false_is_false() {
let env = linux();
assert!(!eval("platform:linux,command_exists:sh", &env).unwrap());
}
#[test]
fn and_three_terms_all_true() {
let mut env = linux();
env.commands.insert("sh".to_owned());
env.commands.insert("ls".to_owned());
assert!(eval("platform:linux,command_exists:sh,command_exists:ls", &env).unwrap());
}
#[test]
fn and_three_terms_one_false() {
let mut env = linux();
env.commands.insert("sh".to_owned());
assert!(!eval("platform:linux,command_exists:sh,command_exists:ls", &env).unwrap());
}
#[test]
fn whitespace_around_commas_accepted() {
let mut env = linux();
env.commands.insert("sh".to_owned());
assert!(eval("platform:linux , command_exists:sh", &env).unwrap());
}
#[test]
fn empty_predicate_is_vacuously_true() {
let env = linux();
assert!(eval("", &env).unwrap());
}
#[test]
fn empty_after_split_is_malformed() {
let env = linux();
assert!(matches!(
eval("platform:linux,,command_exists:sh", &env),
Err(PredicateError::Malformed { .. })
));
}
#[test]
fn unknown_kind_is_error() {
let env = linux();
assert!(matches!(
eval("weather:sunny", &env),
Err(PredicateError::UnknownKind { kind }) if kind == "weather"
));
}
#[test]
fn no_colon_is_malformed() {
let env = linux();
assert!(matches!(
eval("command_exists", &env),
Err(PredicateError::Malformed { .. })
));
}
#[test]
fn env_empty_var_name_is_malformed() {
let env = linux();
assert!(matches!(
eval("env:", &env),
Err(PredicateError::Malformed { .. })
));
}
#[test]
fn negation_of_empty_is_malformed() {
let env = linux();
assert!(matches!(
eval("!", &env),
Err(PredicateError::Malformed { .. })
));
}
#[test]
fn negation_and_combo() {
let mut env = linux();
env.commands.insert("rofi".to_owned());
assert!(!eval("!platform:linux,command_exists:rofi", &env).unwrap());
}
#[test]
fn negation_and_combo_true_on_macos() {
let mut env = macos();
env.commands.insert("rofi".to_owned());
assert!(eval("!platform:linux,command_exists:rofi", &env).unwrap());
}
}