diff --git a/crates/hah-dsl/Cargo.toml b/crates/hah-dsl/Cargo.toml index 0294e66..e5dca25 100644 --- a/crates/hah-dsl/Cargo.toml +++ b/crates/hah-dsl/Cargo.toml @@ -12,6 +12,7 @@ hah-utils = { path = "../hah-utils" } anyhow = "1" regex = "1" serde = { version = "1", features = ["derive"] } +winnow = "1.0.3" [dev-dependencies] hah-core = { path = "../hah-core", features = ["mock"] } diff --git a/crates/hah-dsl/src/expr.rs b/crates/hah-dsl/src/expr.rs new file mode 100644 index 0000000..412722e --- /dev/null +++ b/crates/hah-dsl/src/expr.rs @@ -0,0 +1,405 @@ +//! Strongly typed expression AST for the HaH DSL. + +use crate::pipeline::{Filter, RuleValue, ValueMap}; +use anyhow::{Result, anyhow}; + +#[derive(Debug, Clone, PartialEq)] +pub enum Expression { + /// A variable reference (e.g., `$stdout`). + Variable(String), + /// A literal value (e.g., `'foo'`, `42`, `true`). + Literal(RuleValue), + /// A function call or filter (e.g., `trim`, `nth(1)`). + Filter { name: String, args: Vec }, + /// A pipeline of expressions (e.g., `expr | expr | expr`). + Pipeline(Vec), +} + +impl Expression { + pub fn eval(&self, values: &ValueMap) -> Result { + match self { + Self::Variable(name) => Ok(values.get(name).cloned().unwrap_or(RuleValue::Null)), + Self::Literal(val) => Ok(val.clone()), + Self::Filter { name, args } => { + // Standalone filter call: evaluate arguments and apply + let mut evaled_args = Vec::new(); + for arg in args { + evaled_args.push(arg.eval(values)?); + } + apply_filter_new(RuleValue::Null, name, evaled_args) + } + Self::Pipeline(steps) => { + if steps.is_empty() { + return Ok(RuleValue::Null); + } + let mut current = steps[0].eval(values)?; + for step in &steps[1..] { + current = match step { + Self::Filter { name, args } => { + // Evaluate arguments + let mut evaled_args = Vec::new(); + for arg in args { + evaled_args.push(arg.eval(values)?); + } + apply_filter_new(current, name, evaled_args)? + } + _ => return Err(anyhow!("Expected filter in pipeline, got {:?}", step)), + }; + } + Ok(current) + } + } + } +} + +fn apply_filter_new(value: RuleValue, name: &str, args: Vec) -> Result { + let filter = build_filter(name, args)?; + crate::filters::apply(value, &filter) +} + +fn build_filter(name: &str, args: Vec) -> Result { + // Zero-argument filters + if let Some(f) = zero_arg_filter(name) { + return Ok(f); + } + // Filters that take an integer argument + if let Some(f) = int_arg_filter(name, &args)? { + return Ok(f); + } + // Filters that take a string argument + if let Some(f) = str_arg_filter(name, &args)? { + return Ok(f); + } + Err(anyhow!("Unknown filter: {}", name)) +} + +fn zero_arg_filter(name: &str) -> Option { + match name { + "trim" => Some(Filter::Trim), + "lines" => Some(Filter::Lines), + "non_empty" => Some(Filter::NonEmpty), + "first" => Some(Filter::First), + "number" => Some(Filter::Number), + "count" => Some(Filter::Count), + "sort" => Some(Filter::Sort), + "unique" => Some(Filter::Unique), + "bytes_to_mb" => Some(Filter::BytesToMb), + _ => None, + } +} + +fn int_arg_filter(name: &str, args: &[RuleValue]) -> Result> { + let n = || -> Result { + args.first() + .and_then(RuleValue::as_int) + .map(|n| n as usize) + .ok_or_else(|| anyhow!("{} requires an integer argument", name)) + }; + match name { + "skip" => Ok(Some(Filter::Skip(n()?))), + "nth" => Ok(Some(Filter::Nth(n()?))), + "field" => Ok(Some(Filter::Field(n()?))), + _ => Ok(None), + } +} + +fn str_arg_filter(name: &str, args: &[RuleValue]) -> Result> { + let s = || -> Result { + args.first() + .and_then(RuleValue::as_str) + .map(str::to_string) + .ok_or_else(|| anyhow!("{} requires a string argument", name)) + }; + match name { + "prefix_strip" => Ok(Some(Filter::PrefixStrip(s()?))), + "starts_with" => Ok(Some(Filter::StartsWith(s()?))), + "contains" => Ok(Some(Filter::Contains(s()?))), + "reject_contains" => Ok(Some(Filter::RejectContains(s()?))), + "join" => Ok(Some(Filter::Join(s()?))), + "default" => Ok(Some(Filter::Default(s()?))), + _ => Ok(None), + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::testutil::map_of; + use std::collections::HashMap; + + // ── Variable ────────────────────────────────────────────────────────────── + + #[test] + fn eval_variable_found() { + let values = map_of(&[("x", RuleValue::Int(7))]); + assert_eq!( + Expression::Variable("x".into()).eval(&values).unwrap(), + RuleValue::Int(7) + ); + } + + #[test] + fn eval_variable_missing_returns_null() { + assert_eq!( + Expression::Variable("missing".into()) + .eval(&HashMap::new()) + .unwrap(), + RuleValue::Null + ); + } + + // ── Literal ─────────────────────────────────────────────────────────────── + + #[test] + fn eval_literal_passthrough() { + assert_eq!( + Expression::Literal(RuleValue::Bool(true)) + .eval(&HashMap::new()) + .unwrap(), + RuleValue::Bool(true) + ); + } + + // ── Standalone filter ───────────────────────────────────────────────────── + + #[test] + fn eval_standalone_filter_trim() { + // A standalone Filter applied to Null (degenerate case) + let expr = Expression::Filter { + name: "trim".into(), + args: vec![], + }; + // trim on Null gives an error — that's the expected behaviour + assert!(expr.eval(&HashMap::new()).is_err()); + } + + #[test] + fn eval_unknown_filter_errors() { + let expr = Expression::Filter { + name: "no_such_filter".into(), + args: vec![], + }; + assert!(expr.eval(&HashMap::new()).is_err()); + } + + // ── Pipeline ────────────────────────────────────────────────────────────── + + #[test] + fn eval_empty_pipeline_returns_null() { + assert_eq!( + Expression::Pipeline(vec![]).eval(&HashMap::new()).unwrap(), + RuleValue::Null + ); + } + + #[test] + fn eval_pipeline_non_filter_step_errors() { + let expr = Expression::Pipeline(vec![ + Expression::Literal(RuleValue::Str("hello".into())), + Expression::Literal(RuleValue::Str("not_a_filter".into())), + ]); + assert!(expr.eval(&HashMap::new()).is_err()); + } + + #[test] + fn eval_pipeline_trim() { + let values = map_of(&[("v", RuleValue::Str(" hi ".into()))]); + let expr = Expression::Pipeline(vec![ + Expression::Variable("v".into()), + Expression::Filter { + name: "trim".into(), + args: vec![], + }, + ]); + assert_eq!(expr.eval(&values).unwrap(), RuleValue::Str("hi".into())); + } + + // ── apply_filter_new branches ───────────────────────────────────────────── + + #[test] + fn all_zero_arg_filters_dispatch_without_panic() { + for name in &[ + "trim", + "lines", + "non_empty", + "first", + "number", + "count", + "sort", + "unique", + "bytes_to_mb", + ] { + let expr = Expression::Filter { + name: name.to_string(), + args: vec![], + }; + // We don't care about the result (Null input may error), just that + // the dispatch arm exists. + let _ = expr.eval(&HashMap::new()); + } + } + + #[test] + fn filter_skip_applies_with_int_arg() { + let values = map_of(&[( + "v", + RuleValue::List(vec![ + RuleValue::Str("a".into()), + RuleValue::Str("b".into()), + RuleValue::Str("c".into()), + ]), + )]); + let expr = Expression::Pipeline(vec![ + Expression::Variable("v".into()), + Expression::Filter { + name: "skip".into(), + args: vec![Expression::Literal(RuleValue::Int(1))], + }, + ]); + assert_eq!( + expr.eval(&values).unwrap(), + RuleValue::List(vec![RuleValue::Str("b".into()), RuleValue::Str("c".into()),]) + ); + } + + #[test] + fn filter_nth_applies_with_int_arg() { + let values = map_of(&[( + "v", + RuleValue::List(vec![RuleValue::Str("a".into()), RuleValue::Str("b".into())]), + )]); + let expr = Expression::Pipeline(vec![ + Expression::Variable("v".into()), + Expression::Filter { + name: "nth".into(), + args: vec![Expression::Literal(RuleValue::Int(1))], + }, + ]); + assert_eq!(expr.eval(&values).unwrap(), RuleValue::Str("b".into())); + } + + #[test] + fn filter_field_applies_with_int_arg() { + let values = map_of(&[("v", RuleValue::Str("hello world".into()))]); + let expr = Expression::Pipeline(vec![ + Expression::Variable("v".into()), + Expression::Filter { + name: "field".into(), + args: vec![Expression::Literal(RuleValue::Int(1))], + }, + ]); + assert_eq!(expr.eval(&values).unwrap(), RuleValue::Str("world".into())); + } + + #[test] + fn filter_prefix_strip_applies_with_str_arg() { + let values = map_of(&[("v", RuleValue::Str("linux-5.15".into()))]); + let expr = Expression::Pipeline(vec![ + Expression::Variable("v".into()), + Expression::Filter { + name: "prefix_strip".into(), + args: vec![Expression::Literal(RuleValue::Str("linux-".into()))], + }, + ]); + assert_eq!(expr.eval(&values).unwrap(), RuleValue::Str("5.15".into())); + } + + #[test] + fn filter_starts_with_applies_with_str_arg() { + let values = map_of(&[( + "v", + RuleValue::List(vec![ + RuleValue::Str("linux-5".into()), + RuleValue::Str("other".into()), + ]), + )]); + let expr = Expression::Pipeline(vec![ + Expression::Variable("v".into()), + Expression::Filter { + name: "starts_with".into(), + args: vec![Expression::Literal(RuleValue::Str("linux-".into()))], + }, + ]); + assert_eq!( + expr.eval(&values).unwrap(), + RuleValue::List(vec![RuleValue::Str("linux-5".into())]) + ); + } + + #[test] + fn filter_contains_applies_with_str_arg() { + let values = map_of(&[("v", RuleValue::Str("hello world".into()))]); + let expr = Expression::Pipeline(vec![ + Expression::Variable("v".into()), + Expression::Filter { + name: "contains".into(), + args: vec![Expression::Literal(RuleValue::Str("world".into()))], + }, + ]); + assert_eq!(expr.eval(&values).unwrap(), RuleValue::Bool(true)); + } + + #[test] + fn filter_reject_contains_applies_with_str_arg() { + let values = map_of(&[( + "v", + RuleValue::List(vec![ + RuleValue::Str("keep".into()), + RuleValue::Str("drop-this".into()), + ]), + )]); + let expr = Expression::Pipeline(vec![ + Expression::Variable("v".into()), + Expression::Filter { + name: "reject_contains".into(), + args: vec![Expression::Literal(RuleValue::Str("drop".into()))], + }, + ]); + assert_eq!( + expr.eval(&values).unwrap(), + RuleValue::List(vec![RuleValue::Str("keep".into())]) + ); + } + + #[test] + fn filter_join_applies_with_str_arg() { + let values = map_of(&[( + "v", + RuleValue::List(vec![RuleValue::Str("a".into()), RuleValue::Str("b".into())]), + )]); + let expr = Expression::Pipeline(vec![ + Expression::Variable("v".into()), + Expression::Filter { + name: "join".into(), + args: vec![Expression::Literal(RuleValue::Str(", ".into()))], + }, + ]); + assert_eq!(expr.eval(&values).unwrap(), RuleValue::Str("a, b".into())); + } + + #[test] + fn filter_default_applies_with_str_arg() { + let values = map_of(&[("v", RuleValue::Null)]); + let expr = Expression::Pipeline(vec![ + Expression::Variable("v".into()), + Expression::Filter { + name: "default".into(), + args: vec![Expression::Literal(RuleValue::Str("fallback".into()))], + }, + ]); + assert_eq!( + expr.eval(&values).unwrap(), + RuleValue::Str("fallback".into()) + ); + } + + #[test] + fn filter_skip_missing_arg_errors() { + let expr = Expression::Filter { + name: "skip".into(), + args: vec![], + }; + assert!(expr.eval(&HashMap::new()).is_err()); + } +} diff --git a/crates/hah-dsl/src/filters/list.rs b/crates/hah-dsl/src/filters/list.rs new file mode 100644 index 0000000..a8959ff --- /dev/null +++ b/crates/hah-dsl/src/filters/list.rs @@ -0,0 +1,202 @@ +use crate::pipeline::RuleValue; +use anyhow::{Result, anyhow}; + +pub fn non_empty(value: RuleValue) -> Result { + match value { + RuleValue::List(v) => Ok(RuleValue::List( + v.into_iter() + .filter(|item| !matches!(item, RuleValue::Null)) + .filter(|item| !matches!(item, RuleValue::Str(s) if s.is_empty())) + .collect(), + )), + other => Err(anyhow!("non_empty: expected a list, got {:?}", other)), + } +} + +pub fn first(value: RuleValue) -> Result { + match value { + RuleValue::List(mut v) => Ok(if v.is_empty() { + RuleValue::Null + } else { + v.remove(0) + }), + other => Err(anyhow!("first: expected a list, got {:?}", other)), + } +} + +pub fn skip(value: RuleValue, n: usize) -> Result { + match value { + RuleValue::List(mut v) => { + if n < v.len() { + Ok(RuleValue::List(v.split_off(n))) + } else { + Ok(RuleValue::List(vec![])) + } + } + other => Err(anyhow!("skip: expected a list, got {:?}", other)), + } +} + +pub fn nth(value: RuleValue, n: usize) -> Result { + match value { + RuleValue::List(v) => Ok(v.get(n).cloned().unwrap_or(RuleValue::Null)), + other => Err(anyhow!("nth: expected a list, got {:?}", other)), + } +} + +pub fn count(value: &RuleValue) -> RuleValue { + match value { + RuleValue::List(v) => RuleValue::Int(v.len() as i64), + RuleValue::Null => RuleValue::Int(0), + _ => RuleValue::Int(1), + } +} + +pub fn sort(value: RuleValue) -> Result { + match value { + RuleValue::List(mut v) => { + v.sort_by_key(RuleValue::display); + Ok(RuleValue::List(v)) + } + other => Err(anyhow!("sort: expected a list, got {:?}", other)), + } +} + +pub fn unique(value: RuleValue) -> Result { + match value { + RuleValue::List(mut v) => { + v.sort_by_key(RuleValue::display); + v.dedup(); + Ok(RuleValue::List(v)) + } + other => Err(anyhow!("unique: expected a list, got {:?}", other)), + } +} + +pub fn join(value: RuleValue, sep: &str) -> Result { + match value { + RuleValue::List(v) => Ok(RuleValue::Str( + v.iter() + .map(RuleValue::display) + .collect::>() + .join(sep), + )), + RuleValue::Str(s) => Ok(RuleValue::Str(s)), + other => Err(anyhow!("join: expected a list, got {:?}", other)), + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::testutil::{list, sv}; + + #[test] + fn non_empty_removes_null_and_empty_strings() { + let input = RuleValue::List(vec![sv("a"), RuleValue::Null, sv(""), sv("b")]); + assert_eq!(non_empty(input).unwrap(), list(&["a", "b"])); + } + + #[test] + fn non_empty_err_on_non_list() { + assert!(non_empty(sv("x")).is_err()); + } + + #[test] + fn first_returns_head() { + assert_eq!(first(list(&["a", "b"])).unwrap(), sv("a")); + } + + #[test] + fn first_returns_null_on_empty_list() { + assert_eq!(first(RuleValue::List(vec![])).unwrap(), RuleValue::Null); + } + + #[test] + fn first_err_on_non_list() { + assert!(first(sv("x")).is_err()); + } + + #[test] + fn skip_removes_n_elements() { + assert_eq!(skip(list(&["a", "b", "c"]), 2).unwrap(), list(&["c"])); + } + + #[test] + fn skip_past_end_returns_empty() { + assert_eq!(skip(list(&["a"]), 5).unwrap(), RuleValue::List(vec![])); + } + + #[test] + fn skip_err_on_non_list() { + assert!(skip(sv("x"), 1).is_err()); + } + + #[test] + fn nth_returns_element() { + assert_eq!(nth(list(&["a", "b", "c"]), 1).unwrap(), sv("b")); + } + + #[test] + fn nth_out_of_bounds_returns_null() { + assert_eq!(nth(list(&["a"]), 5).unwrap(), RuleValue::Null); + } + + #[test] + fn nth_err_on_non_list() { + assert!(nth(sv("x"), 0).is_err()); + } + + #[test] + fn count_list() { + assert_eq!(count(&list(&["a", "b", "c"])), RuleValue::Int(3)); + } + + #[test] + fn count_null() { + assert_eq!(count(&RuleValue::Null), RuleValue::Int(0)); + } + + #[test] + fn count_scalar() { + assert_eq!(count(&sv("x")), RuleValue::Int(1)); + } + + #[test] + fn sort_orders_alphabetically() { + let sorted = sort(list(&["c", "a", "b"])).unwrap(); + assert_eq!(sorted, list(&["a", "b", "c"])); + } + + #[test] + fn sort_err_on_non_list() { + assert!(sort(sv("x")).is_err()); + } + + #[test] + fn unique_deduplicates() { + let u = unique(list(&["b", "a", "b", "a"])).unwrap(); + assert_eq!(u, list(&["a", "b"])); + } + + #[test] + fn unique_err_on_non_list() { + assert!(unique(sv("x")).is_err()); + } + + #[test] + fn join_list_with_separator() { + assert_eq!(join(list(&["a", "b", "c"]), ", ").unwrap(), sv("a, b, c")); + } + + #[test] + fn join_str_passthrough() { + assert_eq!(join(sv("hello"), ",").unwrap(), sv("hello")); + } + + #[test] + fn join_err_on_non_list_non_str() { + assert!(join(RuleValue::Int(1), ",").is_err()); + } +} diff --git a/crates/hah-dsl/src/filters/mod.rs b/crates/hah-dsl/src/filters/mod.rs new file mode 100644 index 0000000..674e5b3 --- /dev/null +++ b/crates/hah-dsl/src/filters/mod.rs @@ -0,0 +1,58 @@ +use crate::pipeline::{Filter, RuleValue}; +use anyhow::Result; + +pub mod list; +pub mod scalar; +pub mod string; + +pub fn apply(value: RuleValue, filter: &Filter) -> Result { + apply_list(value, filter) + .or_else(|(v, f)| apply_string(v, f)) + .or_else(|(v, f)| apply_scalar(v, f)) + .unwrap_or_else(|_| unreachable!("all Filter variants are handled")) +} + +fn apply_list( + value: RuleValue, + filter: &Filter, +) -> std::result::Result, (RuleValue, &Filter)> { + match filter { + Filter::NonEmpty => Ok(list::non_empty(value)), + Filter::First => Ok(list::first(value)), + Filter::Sort => Ok(list::sort(value)), + Filter::Unique => Ok(list::unique(value)), + Filter::Count => Ok(Ok(list::count(&value))), + Filter::Skip(n) => Ok(list::skip(value, *n)), + Filter::Nth(n) => Ok(list::nth(value, *n)), + Filter::Join(s) => Ok(list::join(value, s)), + _ => Err((value, filter)), + } +} + +fn apply_string( + value: RuleValue, + filter: &Filter, +) -> std::result::Result, (RuleValue, &Filter)> { + match filter { + Filter::Trim => Ok(string::trim(value)), + Filter::Lines => Ok(string::lines(value)), + Filter::Field(n) => Ok(string::field(value, *n)), + Filter::PrefixStrip(s) => Ok(string::prefix_strip(value, s)), + Filter::StartsWith(s) => Ok(string::starts_with(value, s)), + Filter::Contains(s) => Ok(string::contains(&value, s)), + Filter::RejectContains(s) => Ok(string::reject_contains(value, s)), + _ => Err((value, filter)), + } +} + +fn apply_scalar( + value: RuleValue, + filter: &Filter, +) -> std::result::Result, (RuleValue, &Filter)> { + match filter { + Filter::Number => Ok(scalar::number(value)), + Filter::BytesToMb => Ok(scalar::bytes_to_mb(value)), + Filter::Default(s) => Ok(scalar::default_val(value, s.clone())), + _ => Err((value, filter)), + } +} diff --git a/crates/hah-dsl/src/filters/scalar.rs b/crates/hah-dsl/src/filters/scalar.rs new file mode 100644 index 0000000..f623f2f --- /dev/null +++ b/crates/hah-dsl/src/filters/scalar.rs @@ -0,0 +1,116 @@ +use crate::pipeline::RuleValue; +use anyhow::{Result, anyhow}; + +pub fn number(value: RuleValue) -> Result { + match value { + RuleValue::Int(_) => Ok(value), + RuleValue::Str(s) => { + let n: i64 = s + .trim() + .parse() + .map_err(|_| anyhow!("Not a number: '{}'", s))?; + Ok(RuleValue::Int(n)) + } + _ => Err(anyhow!("Cannot convert {:?} to number", value)), + } +} + +pub fn bytes_to_mb(value: RuleValue) -> Result { + let bytes = match value { + RuleValue::Int(n) => n, + RuleValue::Str(s) => s + .trim() + .parse() + .map_err(|_| anyhow!("Not a number: '{}'", s))?, + _ => return Err(anyhow!("bytes_to_mb requires a number, got {:?}", value)), + }; + Ok(RuleValue::Int(bytes / (1024 * 1024))) +} + +pub fn default_val(value: RuleValue, default: String) -> Result { + let is_empty_str = matches!(&value, RuleValue::Str(s) if s.is_empty()); + if value == RuleValue::Null || is_empty_str { + Ok(RuleValue::Str(default)) + } else { + Ok(value) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn number_from_int_passthrough() { + assert_eq!(number(RuleValue::Int(5)).unwrap(), RuleValue::Int(5)); + } + + #[test] + fn number_from_str() { + assert_eq!( + number(RuleValue::Str(" 42 ".into())).unwrap(), + RuleValue::Int(42) + ); + } + + #[test] + fn number_from_invalid_str_errors() { + assert!(number(RuleValue::Str("abc".into())).is_err()); + } + + #[test] + fn number_from_bool_errors() { + assert!(number(RuleValue::Bool(true)).is_err()); + } + + #[test] + fn bytes_to_mb_from_int() { + assert_eq!( + bytes_to_mb(RuleValue::Int(10 * 1024 * 1024)).unwrap(), + RuleValue::Int(10) + ); + } + + #[test] + fn bytes_to_mb_from_str() { + assert_eq!( + bytes_to_mb(RuleValue::Str("2097152".into())).unwrap(), + RuleValue::Int(2) + ); + } + + #[test] + fn bytes_to_mb_from_invalid_str_errors() { + assert!(bytes_to_mb(RuleValue::Str("nope".into())).is_err()); + } + + #[test] + fn bytes_to_mb_from_bool_errors() { + assert!(bytes_to_mb(RuleValue::Bool(true)).is_err()); + } + + #[test] + fn default_val_null_returns_default() { + assert_eq!( + default_val(RuleValue::Null, "fallback".into()).unwrap(), + RuleValue::Str("fallback".into()) + ); + } + + #[test] + fn default_val_empty_str_returns_default() { + assert_eq!( + default_val(RuleValue::Str(String::new()), "fallback".into()).unwrap(), + RuleValue::Str("fallback".into()) + ); + } + + #[test] + fn default_val_non_empty_passthrough() { + assert_eq!( + default_val(RuleValue::Str("value".into()), "fallback".into()).unwrap(), + RuleValue::Str("value".into()) + ); + } +} diff --git a/crates/hah-dsl/src/filters/string.rs b/crates/hah-dsl/src/filters/string.rs new file mode 100644 index 0000000..826a37e --- /dev/null +++ b/crates/hah-dsl/src/filters/string.rs @@ -0,0 +1,268 @@ +use crate::pipeline::RuleValue; +use anyhow::{Result, anyhow}; + +pub fn trim(value: RuleValue) -> Result { + match value { + RuleValue::Str(s) => Ok(RuleValue::Str(s.trim().to_string())), + RuleValue::List(v) => Ok(RuleValue::List( + v.into_iter() + .map(|item| match item { + RuleValue::Str(s) => RuleValue::Str(s.trim().to_string()), + other => other, + }) + .collect(), + )), + other => Err(anyhow!("trim: expected a string or list, got {:?}", other)), + } +} + +pub fn lines(value: RuleValue) -> Result { + match value { + RuleValue::Str(s) => Ok(RuleValue::List( + s.lines() + .map(|line| RuleValue::Str(line.to_string())) + .collect(), + )), + other => Err(anyhow!("lines: expected a string, got {:?}", other)), + } +} + +pub fn field(value: RuleValue, n: usize) -> Result { + match value { + RuleValue::Str(s) => { + let fields: Vec<&str> = s.split_whitespace().collect(); + Ok(fields + .get(n) + .map_or(RuleValue::Null, |f| RuleValue::Str(f.to_string()))) + } + RuleValue::List(v) => Ok(RuleValue::List( + v.into_iter() + .map(|item| match item { + RuleValue::Str(s) => { + let fields: Vec<&str> = s.split_whitespace().collect(); + fields + .get(n) + .map_or(RuleValue::Null, |f| RuleValue::Str(f.to_string())) + } + other => other, + }) + .collect(), + )), + other => Err(anyhow!("field: expected a string or list, got {:?}", other)), + } +} + +pub fn prefix_strip(value: RuleValue, prefix: &str) -> Result { + match value { + RuleValue::Str(s) => Ok(RuleValue::Str( + s.strip_prefix(prefix).unwrap_or(&s).to_string(), + )), + RuleValue::List(v) => Ok(RuleValue::List( + v.into_iter() + .map(|item| match item { + RuleValue::Str(s) => { + RuleValue::Str(s.strip_prefix(prefix).unwrap_or(&s).to_string()) + } + other => other, + }) + .collect(), + )), + other => Err(anyhow!( + "prefix_strip: expected a string or list, got {:?}", + other + )), + } +} + +pub fn starts_with(value: RuleValue, prefix: &str) -> Result { + match value { + RuleValue::List(v) => Ok(RuleValue::List( + v.into_iter() + .filter(|item| match item { + RuleValue::Str(s) => s.starts_with(prefix), + _ => false, + }) + .collect(), + )), + RuleValue::Str(s) => Ok(if s.starts_with(prefix) { + RuleValue::Str(s) + } else { + RuleValue::Null + }), + other => Err(anyhow!( + "starts_with: expected a list or string, got {:?}", + other + )), + } +} + +pub fn contains(value: &RuleValue, substring: &str) -> Result { + match value { + RuleValue::List(v) => Ok(RuleValue::Bool(v.iter().any(|item| match item { + RuleValue::Str(s) => s.contains(substring), + _ => false, + }))), + RuleValue::Str(s) => Ok(RuleValue::Bool(s.contains(substring))), + _ => Ok(RuleValue::Bool(false)), + } +} + +pub fn reject_contains(value: RuleValue, substring: &str) -> Result { + match value { + RuleValue::List(v) => Ok(RuleValue::List( + v.into_iter() + .filter(|item| match item { + RuleValue::Str(s) => !s.contains(substring), + _ => true, + }) + .collect(), + )), + other => Err(anyhow!("reject_contains: expected a list, got {:?}", other)), + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::testutil::{list, sv}; + + #[test] + fn trim_string() { + assert_eq!(trim(sv(" hello ")).unwrap(), sv("hello")); + } + + #[test] + fn trim_list() { + assert_eq!(trim(list(&[" a ", " b"])).unwrap(), list(&["a", "b"])); + } + + #[test] + fn trim_err_on_non_str_non_list() { + assert!(trim(RuleValue::Int(1)).is_err()); + } + + #[test] + fn lines_splits_on_newlines() { + assert_eq!(lines(sv("a\nb\nc")).unwrap(), list(&["a", "b", "c"])); + } + + #[test] + fn lines_err_on_non_str() { + assert!(lines(RuleValue::Int(1)).is_err()); + } + + #[test] + fn field_returns_nth_word() { + assert_eq!(field(sv("hello world foo"), 1).unwrap(), sv("world")); + } + + #[test] + fn field_out_of_range_returns_null() { + assert_eq!(field(sv("a"), 5).unwrap(), RuleValue::Null); + } + + #[test] + fn field_on_list_applies_per_element() { + assert_eq!(field(list(&["a b", "c d"]), 1).unwrap(), list(&["b", "d"])); + } + + #[test] + fn field_err_on_non_str_non_list() { + assert!(field(RuleValue::Int(1), 0).is_err()); + } + + #[test] + fn prefix_strip_str() { + assert_eq!( + prefix_strip(sv("linux-5.15"), "linux-").unwrap(), + sv("5.15") + ); + } + + #[test] + fn prefix_strip_no_match_unchanged() { + assert_eq!(prefix_strip(sv("other"), "linux-").unwrap(), sv("other")); + } + + #[test] + fn prefix_strip_list() { + assert_eq!( + prefix_strip(list(&["linux-1", "other"]), "linux-").unwrap(), + list(&["1", "other"]) + ); + } + + #[test] + fn prefix_strip_err_on_non_str_non_list() { + assert!(prefix_strip(RuleValue::Int(1), "x").is_err()); + } + + #[test] + fn starts_with_filters_list() { + assert_eq!( + starts_with(list(&["linux-5", "headers-5", "linux-6"]), "linux-").unwrap(), + list(&["linux-5", "linux-6"]) + ); + } + + #[test] + fn starts_with_str_matches() { + assert_eq!(starts_with(sv("linux-5"), "linux-").unwrap(), sv("linux-5")); + } + + #[test] + fn starts_with_str_no_match_returns_null() { + assert_eq!(starts_with(sv("other"), "linux-").unwrap(), RuleValue::Null); + } + + #[test] + fn starts_with_err_on_non_str_non_list() { + assert!(starts_with(RuleValue::Int(1), "x").is_err()); + } + + #[test] + fn contains_list_found() { + assert_eq!( + contains(&list(&["hello", "world"]), "world").unwrap(), + RuleValue::Bool(true) + ); + } + + #[test] + fn contains_list_not_found() { + assert_eq!( + contains(&list(&["hello"]), "missing").unwrap(), + RuleValue::Bool(false) + ); + } + + #[test] + fn contains_str_found() { + assert_eq!( + contains(&sv("hello world"), "world").unwrap(), + RuleValue::Bool(true) + ); + } + + #[test] + fn contains_non_str_returns_false() { + assert_eq!( + contains(&RuleValue::Int(1), "x").unwrap(), + RuleValue::Bool(false) + ); + } + + #[test] + fn reject_contains_filters_list() { + assert_eq!( + reject_contains(list(&["keep", "drop-this", "keep2"]), "drop").unwrap(), + list(&["keep", "keep2"]) + ); + } + + #[test] + fn reject_contains_err_on_non_list() { + assert!(reject_contains(sv("x"), "x").is_err()); + } +} diff --git a/crates/hah-dsl/src/lib.rs b/crates/hah-dsl/src/lib.rs index 463a25d..f486d44 100644 --- a/crates/hah-dsl/src/lib.rs +++ b/crates/hah-dsl/src/lib.rs @@ -1,3 +1,29 @@ pub mod capabilities; +pub mod expr; +pub mod filters; +pub mod parsers; pub mod pipeline; pub mod rule; + +#[cfg(test)] +pub mod testutil { + use crate::pipeline::{RuleValue, ValueMap}; + + /// Construct a `RuleValue::Str` from a string literal. + pub fn sv(s: &str) -> RuleValue { + RuleValue::Str(s.to_string()) + } + + /// Construct a `RuleValue::List` of strings from a slice of string literals. + pub fn list(items: &[&str]) -> RuleValue { + RuleValue::List(items.iter().copied().map(sv).collect()) + } + + /// Construct a `ValueMap` from a slice of `(key, value)` pairs. + pub fn map_of(pairs: &[(&str, RuleValue)]) -> ValueMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), v.clone())) + .collect() + } +} diff --git a/crates/hah-dsl/src/parsers/dsl.rs b/crates/hah-dsl/src/parsers/dsl.rs new file mode 100644 index 0000000..6a9cc21 --- /dev/null +++ b/crates/hah-dsl/src/parsers/dsl.rs @@ -0,0 +1,110 @@ +use winnow::Parser; +use winnow::Result; +use winnow::ascii::{dec_int, space0}; +use winnow::combinator::{alt, delimited, opt, preceded, separated}; +use winnow::token::{take_till, take_while}; + +use crate::expr::Expression; +use crate::pipeline::RuleValue; + +/// Parse a full pipeline expression. +pub fn parse_expression(input: &mut &str) -> Result { + let mut exprs: Vec = + separated(1.., parse_single_expression, (space0, '|', space0)).parse_next(input)?; + + if exprs.len() == 1 { + Ok(exprs.remove(0)) + } else { + Ok(Expression::Pipeline(exprs)) + } +} + +fn parse_single_expression(input: &mut &str) -> Result { + alt(( + parse_variable, + parse_bool_literal, + parse_string_literal, + parse_int_literal, + parse_filter_call, + )) + .parse_next(input) +} + +/// Parse a bare string for eval_expr that may contain spaces. +pub fn parse_eval_expr(input: &mut &str) -> Result { + if !input.contains('|') && !input.contains('$') && !input.contains('(') { + if let Ok(n) = input.trim().parse::() { + *input = ""; + return Ok(Expression::Literal(RuleValue::Int(n))); + } + if input.trim() == "true" { + *input = ""; + return Ok(Expression::Literal(RuleValue::Bool(true))); + } + if input.trim() == "false" { + *input = ""; + return Ok(Expression::Literal(RuleValue::Bool(false))); + } + let s = input.trim(); + *input = ""; + return Ok(Expression::Literal(RuleValue::Str(s.to_string()))); + } + + alt(( + parse_expression, + take_while(1.., |_| true) + .map(|s: &str| Expression::Literal(RuleValue::Str(s.trim().to_string()))), + )) + .parse_next(input) +} + +fn parse_variable(input: &mut &str) -> Result { + preceded( + '$', + take_while(1.., |c: char| c.is_alphanumeric() || c == '_' || c == '.'), + ) + .map(|name: &str| Expression::Variable(name.to_string())) + .parse_next(input) +} + +fn parse_string_literal(input: &mut &str) -> Result { + alt(( + delimited('\'', take_till(0.., '\''), '\''), + delimited('"', take_till(0.., '"'), '"'), + )) + .map(|s: &str| Expression::Literal(RuleValue::Str(s.to_string()))) + .parse_next(input) +} + +fn parse_int_literal(input: &mut &str) -> Result { + dec_int + .map(|n: i64| Expression::Literal(RuleValue::Int(n))) + .parse_next(input) +} + +fn parse_bool_literal(input: &mut &str) -> Result { + alt(("true".value(true), "false".value(false))) + .map(|b| Expression::Literal(RuleValue::Bool(b))) + .parse_next(input) +} + +fn parse_filter_call(input: &mut &str) -> Result { + let name = ( + take_while(1, |c: char| c.is_ascii_alphabetic() || c == '_'), + take_while(0.., |c: char| c.is_alphanumeric() || c == '_'), + ) + .map(|(first, rest): (&str, &str)| format!("{}{}", first, rest)) + .parse_next(input)?; + + let args = opt(delimited( + '(', + separated(0.., parse_single_expression, (space0, ',', space0)), + ')', + )) + .parse_next(input)?; + + Ok(Expression::Filter { + name, + args: args.unwrap_or_default(), + }) +} diff --git a/crates/hah-dsl/src/parsers/mod.rs b/crates/hah-dsl/src/parsers/mod.rs new file mode 100644 index 0000000..fae137e --- /dev/null +++ b/crates/hah-dsl/src/parsers/mod.rs @@ -0,0 +1,3 @@ +pub mod dsl; + +pub use dsl::{parse_eval_expr, parse_expression}; diff --git a/crates/hah-dsl/src/pipeline.rs b/crates/hah-dsl/src/pipeline.rs index 8f9adac..15973c7 100644 --- a/crates/hah-dsl/src/pipeline.rs +++ b/crates/hah-dsl/src/pipeline.rs @@ -12,7 +12,7 @@ //! Use [`eval_expr`] for the public entry point. Use [`render_template`] to //! substitute `{varname}` placeholders in outcome strings. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use anyhow::{Result, anyhow}; @@ -94,7 +94,7 @@ pub type ValueMap = HashMap; // ── Filter steps ───────────────────────────────────────────────────────────── /// A single transformation step in a pipeline. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub enum Filter { Trim, Lines, @@ -116,426 +116,13 @@ pub enum Filter { Default(String), } -fn parse_filter(token: &str) -> Result { - let token = token.trim(); - if let Some(paren_pos) = token.find('(') { - if !token.ends_with(')') { - return Err(anyhow!( - "malformed filter (missing closing parenthesis): {token}" - )); - } - let name = token[..paren_pos].trim(); - let raw_arg = token[paren_pos + 1..token.len() - 1].trim(); - let arg = raw_arg.trim_matches('\'').trim_matches('"'); - return parse_filter_with_arg(name, arg); - } - match token { - "trim" => Ok(Filter::Trim), - "lines" => Ok(Filter::Lines), - "non_empty" => Ok(Filter::NonEmpty), - "first" => Ok(Filter::First), - "number" => Ok(Filter::Number), - "count" => Ok(Filter::Count), - "sort" => Ok(Filter::Sort), - "unique" => Ok(Filter::Unique), - "bytes_to_mb" => Ok(Filter::BytesToMb), - other => Err(anyhow!("unknown filter: {other}")), - } -} - -fn parse_filter_with_arg(name: &str, arg: &str) -> Result { - match name { - "skip" => arg - .parse::() - .map(Filter::Skip) - .map_err(|_| anyhow!("skip: expected an integer argument, got {arg:?}")), - "nth" => arg - .parse::() - .map(Filter::Nth) - .map_err(|_| anyhow!("nth: expected an integer argument, got {arg:?}")), - "field" => arg - .parse::() - .map(Filter::Field) - .map_err(|_| anyhow!("field: expected an integer argument, got {arg:?}")), - "prefix_strip" => Ok(Filter::PrefixStrip(arg.to_string())), - "starts_with" => Ok(Filter::StartsWith(arg.to_string())), - "contains" => Ok(Filter::Contains(arg.to_string())), - "reject_contains" => Ok(Filter::RejectContains(arg.to_string())), - "join" => Ok(Filter::Join(arg.to_string())), - "default" => Ok(Filter::Default(arg.to_string())), - other => Err(anyhow!("unknown filter with arguments: {other}")), - } -} - -// ── Pipeline ───────────────────────────────────────────────────────────────── - -/// A parsed transformation pipeline. -#[derive(Debug)] -pub struct Pipeline { - /// Variable name (without the leading `$`) used as the initial value. - pub source: String, - /// Ordered sequence of filter steps applied left to right. - pub filters: Vec, -} - -/// Split a pipeline string on `|` while respecting single-quoted string -/// arguments such as `join(', ')`. -fn split_pipeline(expr: &str) -> Vec { - let mut parts: Vec = Vec::new(); - let mut current = String::new(); - let mut in_single = false; - for ch in expr.chars() { - match ch { - '\'' => { - in_single = !in_single; - current.push(ch); - } - '|' if !in_single => { - let part = current.trim().to_string(); - if !part.is_empty() { - parts.push(part); - } - current = String::new(); - } - _ => current.push(ch), - } - } - let last = current.trim().to_string(); - if !last.is_empty() { - parts.push(last); - } - parts -} - -/// Parse a pipeline expression string into a [`Pipeline`]. -pub fn parse_pipeline(expr: &str) -> Result { - let parts = split_pipeline(expr); - if parts.is_empty() { - return Err(anyhow!("empty pipeline expression")); - } - let source_token = parts[0].trim(); - if !source_token.starts_with('$') { - return Err(anyhow!( - "pipeline source must start with '$', got: {source_token:?}" - )); - } - let source = source_token.trim_start_matches('$').to_string(); - let filters = parts[1..] - .iter() - .map(|t| parse_filter(t)) - .collect::>>()?; - Ok(Pipeline { source, filters }) -} - -// ── Individual filter implementations ──────────────────────────────────────── - -fn filter_trim(value: RuleValue) -> Result { - match value { - RuleValue::Str(s) => Ok(RuleValue::Str(s.trim().to_string())), - other => Ok(other), - } -} - -fn filter_lines(value: RuleValue) -> Result { - match value { - RuleValue::Str(s) => Ok(RuleValue::List( - s.lines().map(|l| RuleValue::Str(l.to_string())).collect(), - )), - other => Ok(RuleValue::List(vec![other])), - } -} - -fn filter_non_empty(value: RuleValue) -> Result { - match value { - RuleValue::List(v) => Ok(RuleValue::List( - v.into_iter() - .filter(|x| match x { - RuleValue::Str(s) => !s.is_empty(), - RuleValue::Null => false, - _ => true, - }) - .collect(), - )), - RuleValue::Str(s) if s.is_empty() => Ok(RuleValue::Null), - other => Ok(other), - } -} - -fn filter_skip(value: RuleValue, n: usize) -> Result { - match value { - RuleValue::List(v) => Ok(RuleValue::List(v.into_iter().skip(n).collect())), - other => Err(anyhow!("skip: expected a list, got {:?}", other.display())), - } -} - -fn filter_first(value: RuleValue) -> Result { - match value { - RuleValue::List(mut v) => Ok(if v.is_empty() { - RuleValue::Null - } else { - v.remove(0) - }), - other => Ok(other), - } -} - -fn filter_nth(value: RuleValue, n: usize) -> Result { - match value { - RuleValue::List(v) => Ok(v.into_iter().nth(n).unwrap_or(RuleValue::Null)), - other => Err(anyhow!("nth: expected a list, got {:?}", other.display())), - } -} - -fn filter_field(value: RuleValue, n: usize) -> Result { - match value { - RuleValue::Str(s) => Ok(s - .split_whitespace() - .nth(n) - .map_or(RuleValue::Null, |f| RuleValue::Str(f.to_string()))), - other => Err(anyhow!( - "field: expected a string, got {:?}", - other.display() - )), - } -} - -fn filter_number(value: RuleValue) -> Result { - match value { - RuleValue::Int(n) => Ok(RuleValue::Int(n)), - RuleValue::Str(s) => s - .trim() - .parse::() - .map(RuleValue::Int) - .map_err(|_| anyhow!("number: cannot parse {:?} as an integer", s)), - other => Err(anyhow!( - "number: expected a string or int, got {:?}", - other.display() - )), - } -} - -fn filter_count(value: &RuleValue) -> RuleValue { - let n: i64 = match value { - RuleValue::List(v) => v.len() as i64, - RuleValue::Str(s) => i64::from(!s.is_empty()), - RuleValue::Null => 0, - _ => 1, - }; - RuleValue::Int(n) -} - -fn filter_prefix_strip(value: RuleValue, prefix: &str) -> Result { - match value { - RuleValue::Str(s) => Ok(RuleValue::Str( - s.strip_prefix(prefix).unwrap_or(&s).to_string(), - )), - RuleValue::List(v) => Ok(RuleValue::List( - v.into_iter() - .map(|item| match item { - RuleValue::Str(s) => { - RuleValue::Str(s.strip_prefix(prefix).unwrap_or(&s).to_string()) - } - other => other, - }) - .collect(), - )), - other => Err(anyhow!( - "prefix_strip: expected a string or list, got {:?}", - other.display() - )), - } -} - -fn filter_starts_with(value: RuleValue, prefix: &str) -> Result { - match value { - RuleValue::List(v) => Ok(RuleValue::List( - v.into_iter() - .filter(|item| match item { - RuleValue::Str(s) => s.starts_with(prefix), - _ => false, - }) - .collect(), - )), - RuleValue::Str(s) => Ok(if s.starts_with(prefix) { - RuleValue::Str(s) - } else { - RuleValue::Null - }), - other => Err(anyhow!( - "starts_with: expected a list or string, got {:?}", - other.display() - )), - } -} - -fn filter_contains(value: &RuleValue, substring: &str) -> Result { - match value { - RuleValue::List(v) => Ok(RuleValue::Bool(v.iter().any(|item| match item { - RuleValue::Str(s) => s.contains(substring), - _ => false, - }))), - RuleValue::Str(s) => Ok(RuleValue::Bool(s.contains(substring))), - _ => Ok(RuleValue::Bool(false)), - } -} - -fn filter_reject_contains(value: RuleValue, substring: &str) -> Result { - match value { - RuleValue::List(v) => Ok(RuleValue::List( - v.into_iter() - .filter(|item| match item { - RuleValue::Str(s) => !s.contains(substring), - _ => true, - }) - .collect(), - )), - other => Err(anyhow!( - "reject_contains: expected a list, got {:?}", - other.display() - )), - } -} - -fn filter_sort(value: RuleValue) -> Result { - match value { - RuleValue::List(mut v) => { - v.sort_by_key(RuleValue::display); - Ok(RuleValue::List(v)) - } - other => Ok(other), - } -} - -fn filter_unique(value: RuleValue) -> Result { - match value { - RuleValue::List(v) => { - let mut seen = HashSet::new(); - Ok(RuleValue::List( - v.into_iter().filter(|x| seen.insert(x.display())).collect(), - )) - } - other => Ok(other), - } -} - -fn filter_join(value: RuleValue, sep: &str) -> Result { - match value { - RuleValue::List(v) => Ok(RuleValue::Str( - v.iter() - .map(RuleValue::display) - .collect::>() - .join(sep), - )), - RuleValue::Str(s) => Ok(RuleValue::Str(s)), - other => Err(anyhow!("join: expected a list, got {:?}", other.display())), - } -} - -fn filter_bytes_to_mb(value: RuleValue) -> Result { - match value { - RuleValue::Int(n) => Ok(RuleValue::Int(n / 1_048_576)), - RuleValue::Str(s) => s - .trim() - .parse::() - .map(|n| RuleValue::Int(n / 1_048_576)) - .map_err(|_| anyhow!("bytes_to_mb: cannot parse {:?} as an integer", s)), - other => Err(anyhow!( - "bytes_to_mb: expected int or string, got {:?}", - other.display() - )), - } -} - -fn filter_default(value: RuleValue, default_val: &str) -> RuleValue { - let use_default = - matches!(value, RuleValue::Null) || matches!(&value, RuleValue::Str(s) if s.is_empty()); - if use_default { - RuleValue::Str(default_val.to_string()) - } else { - value - } -} - -// ── Filter dispatch ─────────────────────────────────────────────────────────── - -fn apply_scalar_filter(value: RuleValue, filter: &Filter) -> Result { - match filter { - Filter::Trim => filter_trim(value), - Filter::Lines => filter_lines(value), - Filter::NonEmpty => filter_non_empty(value), - Filter::First => filter_first(value), - Filter::Sort => filter_sort(value), - Filter::Unique => filter_unique(value), - Filter::Count => Ok(filter_count(&value)), - Filter::Skip(n) => filter_skip(value, *n), - Filter::Nth(n) => filter_nth(value, *n), - Filter::Field(n) => filter_field(value, *n), - Filter::Number => filter_number(value), - _ => unreachable!(), - } -} - -fn apply_string_filter(value: RuleValue, filter: &Filter) -> Result { - match filter { - Filter::PrefixStrip(p) => filter_prefix_strip(value, p), - Filter::StartsWith(p) => filter_starts_with(value, p), - Filter::Contains(s) => filter_contains(&value, s), - Filter::RejectContains(s) => filter_reject_contains(value, s), - Filter::Join(sep) => filter_join(value, sep), - Filter::BytesToMb => filter_bytes_to_mb(value), - Filter::Default(d) => Ok(filter_default(value, d)), - _ => unreachable!(), - } -} - -fn apply_filter(value: RuleValue, filter: &Filter) -> Result { - match filter { - Filter::Trim - | Filter::Lines - | Filter::NonEmpty - | Filter::First - | Filter::Sort - | Filter::Unique - | Filter::Count - | Filter::Skip(_) - | Filter::Nth(_) - | Filter::Field(_) - | Filter::Number => apply_scalar_filter(value, filter), - _ => apply_string_filter(value, filter), - } -} - // ── Public API ──────────────────────────────────────────────────────────────── - -/// Evaluate a parsed pipeline against the given value map. -pub fn eval_pipeline(pipeline: &Pipeline, values: &ValueMap) -> Result { - let mut current = values - .get(&pipeline.source) - .cloned() - .unwrap_or(RuleValue::Null); - for filter in &pipeline.filters { - current = apply_filter(current, filter)?; - } - Ok(current) -} - -/// Evaluate an expression that is either a pipeline, a bare `$varname`, or a -/// literal value (integer, boolean keyword, or string). +/// Evaluate an expression using the new strongly typed engine. pub fn eval_expr(expr: &str, values: &ValueMap) -> Result { - let expr = expr.trim(); - if expr.contains('|') { - eval_pipeline(&parse_pipeline(expr)?, values) - } else if let Some(key) = expr.strip_prefix('$') { - Ok(values.get(key).cloned().unwrap_or(RuleValue::Null)) - } else if let Ok(n) = expr.parse::() { - Ok(RuleValue::Int(n)) - } else if expr == "true" { - Ok(RuleValue::Bool(true)) - } else if expr == "false" { - Ok(RuleValue::Bool(false)) - } else { - Ok(RuleValue::Str(expr.to_string())) - } + let mut input = expr.trim(); + let ast = crate::parsers::dsl::parse_eval_expr(&mut input) + .map_err(|e| anyhow!("Failed to parse expression {:?}: {}", expr, e))?; + ast.eval(values) } /// Substitute `{varname}` placeholders in a template string using the value map. @@ -553,203 +140,7 @@ pub fn render_template(template: &str, values: &ValueMap) -> String { #[allow(clippy::unwrap_used)] mod tests { use super::*; - - fn str_val(s: &str) -> RuleValue { - RuleValue::Str(s.to_string()) - } - fn list_val(items: &[&str]) -> RuleValue { - RuleValue::List(items.iter().copied().map(str_val).collect()) - } - fn map_of(pairs: &[(&str, RuleValue)]) -> ValueMap { - pairs - .iter() - .map(|(k, v)| ((*k).to_string(), v.clone())) - .collect() - } - - // ── parse_filter ───────────────────────────────────────────────────────── - - #[test] - fn parse_filter_no_args() { - assert!(matches!(parse_filter("trim").unwrap(), Filter::Trim)); - assert!(matches!(parse_filter("lines").unwrap(), Filter::Lines)); - assert!(matches!(parse_filter("count").unwrap(), Filter::Count)); - assert!(matches!(parse_filter("sort").unwrap(), Filter::Sort)); - assert!(matches!(parse_filter("unique").unwrap(), Filter::Unique)); - assert!(matches!(parse_filter("number").unwrap(), Filter::Number)); - assert!(matches!( - parse_filter("bytes_to_mb").unwrap(), - Filter::BytesToMb - )); - } - - #[test] - fn parse_filter_with_int_arg() { - assert!(matches!(parse_filter("nth(2)").unwrap(), Filter::Nth(2))); - assert!(matches!(parse_filter("skip(3)").unwrap(), Filter::Skip(3))); - assert!(matches!( - parse_filter("field(0)").unwrap(), - Filter::Field(0) - )); - } - - #[test] - fn parse_filter_with_string_arg() { - let f = parse_filter("join(', ')").unwrap(); - assert!(matches!(f, Filter::Join(s) if s == ", ")); - let f = parse_filter("prefix_strip('foo ')").unwrap(); - assert!(matches!(f, Filter::PrefixStrip(s) if s == "foo ")); - } - - #[test] - fn parse_filter_unknown_returns_err() { - assert!(parse_filter("nonexistent").is_err()); - } - - // ── parse_pipeline ──────────────────────────────────────────────────────── - - #[test] - fn parse_pipeline_simple() { - let p = parse_pipeline("$stdout | lines | trim").unwrap(); - assert_eq!(p.source, "stdout"); - assert_eq!(p.filters.len(), 2); - } - - #[test] - fn parse_pipeline_no_filters() { - let p = parse_pipeline("$result").unwrap(); - assert_eq!(p.source, "result"); - assert!(p.filters.is_empty()); - } - - #[test] - fn parse_pipeline_quoted_separator_in_arg() { - // The ',' inside join('|') must not be treated as a separator - let p = parse_pipeline("$list | join(' | ')").unwrap(); - assert_eq!(p.source, "list"); - assert_eq!(p.filters.len(), 1); - assert!(matches!(&p.filters[0], Filter::Join(s) if s == " | ")); - } - - #[test] - fn parse_pipeline_missing_dollar_returns_err() { - assert!(parse_pipeline("stdout | lines").is_err()); - } - - // ── apply_filter ───────────────────────────────────────────────────────── - - #[test] - fn filter_trim() { - let v = apply_filter(str_val(" hello "), &Filter::Trim).unwrap(); - assert_eq!(v, str_val("hello")); - } - - #[test] - fn filter_lines_splits_by_newline() { - let v = apply_filter(str_val("a\nb\nc"), &Filter::Lines).unwrap(); - assert_eq!(v, list_val(&["a", "b", "c"])); - } - - #[test] - fn filter_non_empty_removes_blanks() { - let v = apply_filter(list_val(&["a", "", "b", ""]), &Filter::NonEmpty).unwrap(); - assert_eq!(v, list_val(&["a", "b"])); - } - - #[test] - fn filter_nth_returns_correct_item() { - let v = apply_filter(list_val(&["a", "b", "c"]), &Filter::Nth(1)).unwrap(); - assert_eq!(v, str_val("b")); - } - - #[test] - fn filter_nth_out_of_range_returns_null() { - let v = apply_filter(list_val(&["a"]), &Filter::Nth(5)).unwrap(); - assert_eq!(v, RuleValue::Null); - } - - #[test] - fn filter_number_parses_string() { - let v = apply_filter(str_val(" 42 "), &Filter::Number).unwrap(); - assert_eq!(v, RuleValue::Int(42)); - } - - #[test] - fn filter_number_invalid_returns_err() { - assert!(apply_filter(str_val("not-a-number"), &Filter::Number).is_err()); - } - - #[test] - fn filter_starts_with_filters_list() { - let v = apply_filter( - list_val(&["foo bar", "baz", "foo qux"]), - &Filter::StartsWith("foo".to_string()), - ) - .unwrap(); - assert_eq!(v, list_val(&["foo bar", "foo qux"])); - } - - #[test] - fn filter_prefix_strip_on_list() { - let v = apply_filter( - list_val(&["rc pkg-a", "rc pkg-b"]), - &Filter::PrefixStrip("rc ".to_string()), - ) - .unwrap(); - assert_eq!(v, list_val(&["pkg-a", "pkg-b"])); - } - - #[test] - fn filter_reject_contains() { - let v = apply_filter( - list_val(&["linux-image-5.15", "linux-image-6.1", "linux-image-meta"]), - &Filter::RejectContains("meta".to_string()), - ) - .unwrap(); - assert_eq!(v, list_val(&["linux-image-5.15", "linux-image-6.1"])); - } - - #[test] - fn filter_count_on_list() { - let v = apply_filter(list_val(&["a", "b", "c"]), &Filter::Count).unwrap(); - assert_eq!(v, RuleValue::Int(3)); - } - - #[test] - fn filter_sort() { - let v = apply_filter(list_val(&["banana", "apple", "cherry"]), &Filter::Sort).unwrap(); - assert_eq!(v, list_val(&["apple", "banana", "cherry"])); - } - - #[test] - fn filter_unique_removes_duplicates() { - let v = apply_filter(list_val(&["a", "b", "a", "c", "b"]), &Filter::Unique).unwrap(); - assert_eq!(v, list_val(&["a", "b", "c"])); - } - - #[test] - fn filter_join_produces_string() { - let v = apply_filter(list_val(&["x", "y", "z"]), &Filter::Join(", ".to_string())).unwrap(); - assert_eq!(v, str_val("x, y, z")); - } - - #[test] - fn filter_bytes_to_mb() { - let v = apply_filter(RuleValue::Int(2 * 1_048_576), &Filter::BytesToMb).unwrap(); - assert_eq!(v, RuleValue::Int(2)); - } - - #[test] - fn filter_default_on_null() { - let v = apply_filter(RuleValue::Null, &Filter::Default("fallback".to_string())).unwrap(); - assert_eq!(v, str_val("fallback")); - } - - #[test] - fn filter_default_on_non_null_passes_through() { - let v = apply_filter(str_val("actual"), &Filter::Default("fallback".to_string())).unwrap(); - assert_eq!(v, str_val("actual")); - } + use crate::testutil::{map_of, sv}; // ── eval_expr ───────────────────────────────────────────────────────────── @@ -786,14 +177,14 @@ mod tests { #[test] fn eval_expr_pipeline() { - let values = map_of(&[("out", str_val(" 42 "))]); + let values = map_of(&[("out", sv(" 42 "))]); let v = eval_expr("$out | trim | number", &values).unwrap(); assert_eq!(v, RuleValue::Int(42)); } #[test] fn eval_expr_pipeline_nth_and_trim() { - let values = map_of(&[("out", str_val("header\n 99 \n"))]); + let values = map_of(&[("out", sv("header\n 99 \n"))]); let v = eval_expr("$out | lines | nth(1) | trim | number", &values).unwrap(); assert_eq!(v, RuleValue::Int(99)); } @@ -803,7 +194,7 @@ mod tests { #[test] fn render_template_substitutes_placeholders() { let values = map_of(&[ - ("name", str_val("linux-image-5.15")), + ("name", sv("linux-image-5.15")), ("count", RuleValue::Int(3)), ]); let result = render_template("{count} package(s): {name}", &values); @@ -825,7 +216,7 @@ mod tests { assert_eq!(RuleValue::Int(42).display(), "42"); assert_eq!(RuleValue::Null.display(), ""); assert_eq!( - RuleValue::List(vec![str_val("a"), RuleValue::Int(1)]).display(), + RuleValue::List(vec![sv("a"), RuleValue::Int(1)]).display(), "a, 1" ); } @@ -864,254 +255,4 @@ mod tests { assert_eq!(RuleValue::Null.as_int(), None); assert_eq!(RuleValue::Bool(true).as_int(), None); } - - // ── Filter error paths ──────────────────────────────────────────────────── - - #[test] - fn filter_skip_non_list_returns_err() { - assert!(apply_filter(RuleValue::Int(1), &Filter::Skip(1)).is_err()); - } - - #[test] - fn filter_nth_non_list_returns_err() { - assert!(apply_filter(RuleValue::Int(1), &Filter::Nth(0)).is_err()); - } - - #[test] - fn filter_field_non_string_returns_err() { - assert!(apply_filter(RuleValue::Int(1), &Filter::Field(0)).is_err()); - } - - #[test] - fn filter_number_null_returns_err() { - assert!(apply_filter(RuleValue::Null, &Filter::Number).is_err()); - } - - #[test] - fn filter_prefix_strip_on_other_returns_err() { - assert!(apply_filter(RuleValue::Null, &Filter::PrefixStrip("x".into())).is_err()); - } - - #[test] - fn filter_starts_with_on_other_returns_err() { - assert!(apply_filter(RuleValue::Int(1), &Filter::StartsWith("x".into())).is_err()); - } - - #[test] - fn filter_reject_contains_non_list_returns_err() { - assert!(apply_filter(RuleValue::Int(1), &Filter::RejectContains("x".into())).is_err()); - } - - #[test] - fn filter_join_non_list_non_str_returns_err() { - assert!(apply_filter(RuleValue::Int(1), &Filter::Join(",".into())).is_err()); - } - - #[test] - fn filter_bytes_to_mb_on_null_returns_err() { - assert!(apply_filter(RuleValue::Null, &Filter::BytesToMb).is_err()); - } - - #[test] - fn filter_bytes_to_mb_invalid_str_returns_err() { - assert!(apply_filter(str_val("not-a-number"), &Filter::BytesToMb).is_err()); - } - - // ── Filter positive paths not yet exercised ─────────────────────────────── - - #[test] - fn filter_trim_on_non_str_passes_through() { - let v = apply_filter(RuleValue::Int(5), &Filter::Trim).unwrap(); - assert_eq!(v, RuleValue::Int(5)); - } - - #[test] - fn filter_lines_on_non_str_wraps_in_list() { - let v = apply_filter(RuleValue::Int(1), &Filter::Lines).unwrap(); - assert_eq!(v, RuleValue::List(vec![RuleValue::Int(1)])); - } - - #[test] - fn filter_non_empty_non_empty_str_passes_through() { - let v = apply_filter(str_val("hello"), &Filter::NonEmpty).unwrap(); - assert_eq!(v, str_val("hello")); - } - - #[test] - fn filter_non_empty_other_variant_passes_through() { - let v = apply_filter(RuleValue::Int(1), &Filter::NonEmpty).unwrap(); - assert_eq!(v, RuleValue::Int(1)); - } - - #[test] - fn filter_skip_on_list() { - let v = apply_filter(list_val(&["a", "b", "c"]), &Filter::Skip(1)).unwrap(); - assert_eq!(v, list_val(&["b", "c"])); - } - - #[test] - fn filter_first_on_non_empty_list() { - let v = apply_filter(list_val(&["x", "y"]), &Filter::First).unwrap(); - assert_eq!(v, str_val("x")); - } - - #[test] - fn filter_first_on_empty_list_returns_null() { - let v = apply_filter(RuleValue::List(vec![]), &Filter::First).unwrap(); - assert_eq!(v, RuleValue::Null); - } - - #[test] - fn filter_first_on_non_list_passes_through() { - let v = apply_filter(str_val("abc"), &Filter::First).unwrap(); - assert_eq!(v, str_val("abc")); - } - - #[test] - fn filter_field_on_string() { - let v = apply_filter(str_val("hello world foo"), &Filter::Field(1)).unwrap(); - assert_eq!(v, str_val("world")); - } - - #[test] - fn filter_field_out_of_bounds_returns_null() { - let v = apply_filter(str_val("one two"), &Filter::Field(5)).unwrap(); - assert_eq!(v, RuleValue::Null); - } - - #[test] - fn filter_number_on_int_passes_through() { - let v = apply_filter(RuleValue::Int(7), &Filter::Number).unwrap(); - assert_eq!(v, RuleValue::Int(7)); - } - - #[test] - fn filter_prefix_strip_on_single_str() { - let v = apply_filter(str_val("rc pkg"), &Filter::PrefixStrip("rc ".into())).unwrap(); - assert_eq!(v, str_val("pkg")); - } - - #[test] - fn filter_prefix_strip_no_prefix_match_unchanged() { - let v = apply_filter(str_val("other"), &Filter::PrefixStrip("rc ".into())).unwrap(); - assert_eq!(v, str_val("other")); - } - - #[test] - fn filter_prefix_strip_in_list_no_match_unchanged() { - let v = apply_filter( - list_val(&["foo bar", "baz"]), - &Filter::PrefixStrip("qux ".into()), - ) - .unwrap(); - assert_eq!(v, list_val(&["foo bar", "baz"])); - } - - #[test] - fn filter_starts_with_on_matching_str() { - let v = apply_filter(str_val("foo bar"), &Filter::StartsWith("foo".into())).unwrap(); - assert_eq!(v, str_val("foo bar")); - } - - #[test] - fn filter_starts_with_on_non_matching_str_returns_null() { - let v = apply_filter(str_val("bar"), &Filter::StartsWith("foo".into())).unwrap(); - assert_eq!(v, RuleValue::Null); - } - - #[test] - fn filter_contains_in_list_true() { - let v = apply_filter(list_val(&["foo", "bar"]), &Filter::Contains("foo".into())).unwrap(); - assert_eq!(v, RuleValue::Bool(true)); - } - - #[test] - fn filter_contains_in_list_false() { - let v = apply_filter(list_val(&["foo", "bar"]), &Filter::Contains("baz".into())).unwrap(); - assert_eq!(v, RuleValue::Bool(false)); - } - - #[test] - fn filter_contains_in_str() { - let v = apply_filter(str_val("hello world"), &Filter::Contains("world".into())).unwrap(); - assert_eq!(v, RuleValue::Bool(true)); - } - - #[test] - fn filter_contains_on_other_returns_false() { - let v = apply_filter(RuleValue::Null, &Filter::Contains("x".into())).unwrap(); - assert_eq!(v, RuleValue::Bool(false)); - } - - #[test] - fn filter_count_on_empty_str() { - let v = apply_filter(str_val(""), &Filter::Count).unwrap(); - assert_eq!(v, RuleValue::Int(0)); - } - - #[test] - fn filter_count_on_non_empty_str() { - let v = apply_filter(str_val("x"), &Filter::Count).unwrap(); - assert_eq!(v, RuleValue::Int(1)); - } - - #[test] - fn filter_count_on_null() { - let v = apply_filter(RuleValue::Null, &Filter::Count).unwrap(); - assert_eq!(v, RuleValue::Int(0)); - } - - #[test] - fn filter_count_on_int() { - let v = apply_filter(RuleValue::Int(99), &Filter::Count).unwrap(); - assert_eq!(v, RuleValue::Int(1)); - } - - #[test] - fn filter_sort_on_non_list_passes_through() { - let v = apply_filter(str_val("z"), &Filter::Sort).unwrap(); - assert_eq!(v, str_val("z")); - } - - #[test] - fn filter_unique_on_non_list_passes_through() { - let v = apply_filter(RuleValue::Int(5), &Filter::Unique).unwrap(); - assert_eq!(v, RuleValue::Int(5)); - } - - #[test] - fn filter_join_str_passes_through() { - let v = apply_filter(str_val("abc"), &Filter::Join(",".into())).unwrap(); - assert_eq!(v, str_val("abc")); - } - - #[test] - fn filter_bytes_to_mb_from_str() { - let v = apply_filter(str_val("2097152"), &Filter::BytesToMb).unwrap(); - assert_eq!(v, RuleValue::Int(2)); - } - - #[test] - fn filter_default_on_empty_str() { - let v = apply_filter(str_val(""), &Filter::Default("fallback".into())).unwrap(); - assert_eq!(v, str_val("fallback")); - } - - // ── Additional parse tests ──────────────────────────────────────────────── - - #[test] - fn parse_filter_unclosed_paren_returns_err() { - assert!(parse_filter("nth(5").is_err()); - } - - #[test] - fn parse_pipeline_empty_string_returns_err() { - assert!(parse_pipeline("").is_err()); - } - - #[test] - fn eval_expr_string_literal() { - let v = eval_expr("hello world", &ValueMap::new()).unwrap(); - assert_eq!(v, RuleValue::Str("hello world".into())); - } }