feat: AND/OR filter engine for Tuleap artifact filtering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
thibaud-leclere 2026-04-13 14:32:47 +02:00
parent 1e69cf16a9
commit 6e1356ba1b
2 changed files with 213 additions and 0 deletions

View file

@ -0,0 +1,212 @@
use crate::models::tracker::{Filter, FilterGroup};
use crate::services::tuleap_client::extract_artifact_field_values;
pub fn apply_filters(
artifacts: &[serde_json::Value],
filter_groups: &[FilterGroup],
) -> Vec<serde_json::Value> {
if filter_groups.is_empty() {
return artifacts.to_vec();
}
artifacts
.iter()
.filter(|a| matches_all_groups(a, filter_groups))
.cloned()
.collect()
}
fn matches_all_groups(artifact: &serde_json::Value, groups: &[FilterGroup]) -> bool {
groups.iter().all(|g| matches_any_condition(artifact, &g.conditions))
}
fn matches_any_condition(artifact: &serde_json::Value, conditions: &[Filter]) -> bool {
if conditions.is_empty() {
return true;
}
conditions.iter().any(|c| matches_condition(artifact, c))
}
fn matches_condition(artifact: &serde_json::Value, condition: &Filter) -> bool {
let field_values = extract_artifact_field_values(artifact, &condition.field);
match condition.operator.as_str() {
"Equals" => {
condition.value.len() == 1
&& field_values.iter().any(|v| v == &condition.value[0])
}
"NotEquals" => {
condition.value.len() == 1
&& !field_values.iter().any(|v| v == &condition.value[0])
}
"In" => field_values.iter().any(|v| condition.value.contains(v)),
"NotIn" => !field_values.iter().any(|v| condition.value.contains(v)),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_artifact(status: &str, assigned: &str, priority: &str) -> serde_json::Value {
json!({
"id": 123,
"title": "Test ticket",
"values": [
{
"field_id": 1,
"label": "Status",
"type": "sb",
"values": [{ "id": 1, "label": status }]
},
{
"field_id": 2,
"label": "Assigned to",
"type": "msb",
"bind_value_objects": [{ "id": 2, "display_name": assigned }]
},
{
"field_id": 3,
"label": "Priority",
"type": "sb",
"values": [{ "id": 3, "label": priority }]
}
]
})
}
#[test]
fn test_empty_filters_returns_all() {
let artifacts = vec![
make_artifact("Nouveau", "Alice", "Haute"),
make_artifact("Ferme", "Bob", "Basse"),
];
let result = apply_filters(&artifacts, &[]);
assert_eq!(result.len(), 2);
}
#[test]
fn test_single_in_filter() {
let artifacts = vec![
make_artifact("Nouveau", "Alice", "Haute"),
make_artifact("Ferme", "Bob", "Basse"),
];
let groups = vec![FilterGroup {
conditions: vec![Filter {
field: "Status".to_string(),
operator: "In".to_string(),
value: vec!["Nouveau".to_string()],
}],
}];
let result = apply_filters(&artifacts, &groups);
assert_eq!(result.len(), 1);
assert_eq!(result[0]["values"][0]["values"][0]["label"], "Nouveau");
}
#[test]
fn test_or_within_group() {
let artifacts = vec![
make_artifact("Nouveau", "Alice", "Haute"),
make_artifact("A traiter", "Bob", "Moyenne"),
make_artifact("Ferme", "Carol", "Basse"),
];
let groups = vec![FilterGroup {
conditions: vec![
Filter {
field: "Status".to_string(),
operator: "Equals".to_string(),
value: vec!["Nouveau".to_string()],
},
Filter {
field: "Status".to_string(),
operator: "Equals".to_string(),
value: vec!["A traiter".to_string()],
},
],
}];
let result = apply_filters(&artifacts, &groups);
assert_eq!(result.len(), 2);
}
#[test]
fn test_and_across_groups() {
let artifacts = vec![
make_artifact("Nouveau", "Team Maintenance", "Haute"),
make_artifact("A traiter", "Other Team", "Moyenne"),
make_artifact("Ferme", "Team Maintenance", "Basse"),
];
let groups = vec![
FilterGroup {
conditions: vec![Filter {
field: "Status".to_string(),
operator: "In".to_string(),
value: vec!["Nouveau".to_string(), "A traiter".to_string()],
}],
},
FilterGroup {
conditions: vec![Filter {
field: "Assigned to".to_string(),
operator: "In".to_string(),
value: vec!["Team Maintenance".to_string()],
}],
},
];
let result = apply_filters(&artifacts, &groups);
assert_eq!(result.len(), 1);
assert_eq!(result[0]["values"][0]["values"][0]["label"], "Nouveau");
}
#[test]
fn test_not_in_filter() {
let artifacts = vec![
make_artifact("Nouveau", "Alice", "Haute"),
make_artifact("Ferme", "Bob", "Basse"),
make_artifact("Ferme", "Carol", "Moyenne"),
];
let groups = vec![FilterGroup {
conditions: vec![Filter {
field: "Status".to_string(),
operator: "NotIn".to_string(),
value: vec!["Ferme".to_string()],
}],
}];
let result = apply_filters(&artifacts, &groups);
assert_eq!(result.len(), 1);
assert_eq!(result[0]["values"][0]["values"][0]["label"], "Nouveau");
}
#[test]
fn test_equals_filter() {
let artifacts = vec![
make_artifact("Nouveau", "Alice", "Haute"),
make_artifact("Nouveau", "Bob", "Basse"),
make_artifact("Ferme", "Carol", "Haute"),
];
let groups = vec![FilterGroup {
conditions: vec![Filter {
field: "Priority".to_string(),
operator: "Equals".to_string(),
value: vec!["Haute".to_string()],
}],
}];
let result = apply_filters(&artifacts, &groups);
assert_eq!(result.len(), 2);
}
#[test]
fn test_no_match_returns_empty() {
let artifacts = vec![
make_artifact("Nouveau", "Alice", "Haute"),
make_artifact("A traiter", "Bob", "Moyenne"),
];
let groups = vec![FilterGroup {
conditions: vec![Filter {
field: "Status".to_string(),
operator: "Equals".to_string(),
value: vec!["Ferme".to_string()],
}],
}];
let result = apply_filters(&artifacts, &groups);
assert_eq!(result.len(), 0);
}
}

View file

@ -1,2 +1,3 @@
pub mod crypto;
pub mod filter_engine;
pub mod tuleap_client;