diff --git a/src-tauri/src/services/filter_engine.rs b/src-tauri/src/services/filter_engine.rs new file mode 100644 index 0000000..2334dcf --- /dev/null +++ b/src-tauri/src/services/filter_engine.rs @@ -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 { + 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); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 3d4d3e4..3470561 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,2 +1,3 @@ pub mod crypto; +pub mod filter_engine; pub mod tuleap_client;