use super::types::{FilterError, FilterOperator, PackageFilter};
fn unquote_value(value: &str) -> Result<String, FilterError> {
let trimmed = value.trim();
if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2 {
let inner = &trimmed[1..trimmed.len() - 1];
let mut result = String::new();
let mut chars = inner.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('n') => result.push('\n'),
Some('t') => result.push('\t'),
Some('r') => result.push('\r'),
Some('\\') => result.push('\\'),
Some('"') => result.push('"'),
Some(other) => {
result.push('\\');
result.push(other);
}
None => {
return Err(FilterError::InvalidSyntax(
"Trailing backslash in quoted string".to_string(),
));
}
}
} else {
result.push(ch);
}
}
Ok(result)
} else {
Ok(trimmed.to_string())
}
}
fn split_on_operator(filter: &str, op: &str) -> Option<(String, String)> {
let mut in_quotes = false;
let mut escape_next = false;
for (i, ch) in filter.char_indices() {
if escape_next {
escape_next = false;
continue;
}
match ch {
'\\' if in_quotes => {
escape_next = true;
}
'"' => {
in_quotes = !in_quotes;
}
_ if !in_quotes && filter[i..].starts_with(op) => {
let property = filter[..i].to_string();
let value = filter[i + op.len()..].to_string();
return Some((property, value));
}
_ => {}
}
}
None
}
pub fn parse_filter(filter: &str) -> Result<PackageFilter, FilterError> {
let operators = [
("!@=", FilterOperator::ArrayNotContains),
("!?", FilterOperator::NotExists),
("@~=", FilterOperator::ArrayContainsRegex),
("@^=", FilterOperator::ArrayContainsStartsWith),
("@*=", FilterOperator::ArrayContainsSubstring),
("@#>", FilterOperator::ArrayLengthGreater),
("@#<", FilterOperator::ArrayLengthLess),
("@#=", FilterOperator::ArrayLengthEquals),
("@=", FilterOperator::ArrayContains),
("@!", FilterOperator::ArrayEmpty),
("~=", FilterOperator::RegexMatch),
("^=", FilterOperator::StartsWith),
("$=", FilterOperator::EndsWith),
("*=", FilterOperator::Contains),
("!=", FilterOperator::NotEquals),
("=", FilterOperator::Equals),
("?", FilterOperator::Exists),
];
for (op_str, operator) in &operators {
if let Some((property, value)) = split_on_operator(filter, op_str) {
let property = property.trim();
let value = value.trim();
if property.is_empty() {
return Err(FilterError::InvalidSyntax(
"Property name cannot be empty".to_string(),
));
}
let property_path: Vec<String> =
property.split('.').map(|s| s.trim().to_string()).collect();
for part in &property_path {
if part.is_empty() {
return Err(FilterError::InvalidSyntax(
"Property path cannot contain empty segments".to_string(),
));
}
}
if operator.is_value_optional() && !value.is_empty() {
return Err(FilterError::InvalidSyntax(format!(
"{op_str} operator should not have a value"
)));
}
let unquoted_value = unquote_value(value)?;
return Ok(PackageFilter {
property_path,
operator: *operator,
value: unquoted_value,
});
}
}
Err(FilterError::InvalidSyntax(format!(
"No valid operator found in filter: '{filter}'"
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_equality() {
let filter = parse_filter("publish=false").unwrap();
assert_eq!(filter.property_path, vec!["publish"]);
assert_eq!(filter.operator, FilterOperator::Equals);
assert_eq!(filter.value, "false");
}
#[test]
fn test_parse_simple_equality_with_nested_path() {
let filter = parse_filter("package.publish=false").unwrap();
assert_eq!(filter.property_path, vec!["package", "publish"]);
assert_eq!(filter.operator, FilterOperator::Equals);
assert_eq!(filter.value, "false");
}
#[test]
fn test_parse_array_contains() {
let filter = parse_filter("categories@=audio").unwrap();
assert_eq!(filter.property_path, vec!["categories"]);
assert_eq!(filter.operator, FilterOperator::ArrayContains);
assert_eq!(filter.value, "audio");
}
#[test]
fn test_parse_array_empty() {
let filter = parse_filter("keywords@!").unwrap();
assert_eq!(filter.property_path, vec!["keywords"]);
assert_eq!(filter.operator, FilterOperator::ArrayEmpty);
assert_eq!(filter.value, "");
}
#[test]
fn test_parse_nested_property() {
let filter = parse_filter("metadata.workspaces.independent=true").unwrap();
assert_eq!(
filter.property_path,
vec!["metadata", "workspaces", "independent"]
);
assert_eq!(filter.operator, FilterOperator::Equals);
assert_eq!(filter.value, "true");
}
#[test]
fn test_parse_exists() {
let filter = parse_filter("readme?").unwrap();
assert_eq!(filter.property_path, vec!["readme"]);
assert_eq!(filter.operator, FilterOperator::Exists);
assert_eq!(filter.value, "");
}
#[test]
fn test_parse_not_exists() {
let filter = parse_filter("homepage!?").unwrap();
assert_eq!(filter.property_path, vec!["homepage"]);
assert_eq!(filter.operator, FilterOperator::NotExists);
assert_eq!(filter.value, "");
}
#[test]
fn test_parse_invalid_empty_property() {
let result = parse_filter("=value");
assert!(matches!(result, Err(FilterError::InvalidSyntax(_))));
}
#[test]
fn test_parse_invalid_value_with_exists() {
let result = parse_filter("readme?value");
assert!(matches!(result, Err(FilterError::InvalidSyntax(_))));
}
#[test]
fn test_parse_invalid_empty_path_segment() {
let result = parse_filter("metadata..independent=true");
assert!(matches!(result, Err(FilterError::InvalidSyntax(_))));
}
}