Skip to main content

raps_admin/
filter.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Project filter for selecting target projects
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::error::AdminError;
10
11/// Platform type for projects
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum Platform {
15    /// Autodesk Construction Cloud
16    Acc,
17    /// BIM 360 (legacy)
18    Bim360,
19}
20
21/// Project status
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum ProjectStatus {
25    Active,
26    Inactive,
27    Archived,
28}
29
30/// Region for projects
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum Region {
34    Us,
35    Emea,
36}
37
38/// Filter criteria for selecting target projects
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40pub struct ProjectFilter {
41    /// Glob pattern for project name matching
42    pub name_pattern: Option<String>,
43    /// Filter by project status (default: Active)
44    pub status: Option<ProjectStatus>,
45    /// Filter by platform (ACC, BIM360, or both if None)
46    pub platform: Option<Platform>,
47    /// Include projects created after this date
48    pub created_after: Option<DateTime<Utc>>,
49    /// Include projects created before this date
50    pub created_before: Option<DateTime<Utc>>,
51    /// Filter by region
52    pub region: Option<Region>,
53    /// Explicit list of project IDs to include
54    pub include_ids: Option<Vec<String>>,
55    /// Explicit list of project IDs to exclude
56    pub exclude_ids: Option<Vec<String>>,
57}
58
59impl ProjectFilter {
60    /// Create a new empty filter (matches all projects)
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Parse filter from string expression
66    ///
67    /// Syntax: `key:value[,key:value...]`
68    ///
69    /// Keys:
70    /// - `name` - Project name (supports * wildcard)
71    /// - `status` - Project status (active, inactive, archived)
72    /// - `platform` - Platform type (acc, bim360)
73    /// - `created` - Date filter (>YYYY-MM-DD, <YYYY-MM-DD)
74    /// - `region` - Region (us, emea)
75    ///
76    /// Example: `name:*Hospital*,status:active,platform:acc`
77    pub fn from_expression(expr: &str) -> Result<Self, AdminError> {
78        let mut filter = Self::new();
79
80        for part in expr.split(',') {
81            let part = part.trim();
82            if part.is_empty() {
83                continue;
84            }
85
86            let (key, value) = part
87                .split_once(':')
88                .ok_or_else(|| AdminError::InvalidFilter {
89                    message: format!("Invalid filter syntax: '{}'. Expected 'key:value'", part),
90                })?;
91
92            match key.trim().to_lowercase().as_str() {
93                "name" => filter.name_pattern = Some(value.trim().to_string()),
94                "status" => {
95                    filter.status = Some(match value.trim().to_lowercase().as_str() {
96                        "active" => ProjectStatus::Active,
97                        "inactive" => ProjectStatus::Inactive,
98                        "archived" => ProjectStatus::Archived,
99                        _ => {
100                            return Err(AdminError::InvalidFilter {
101                                message: format!(
102                                    "Invalid status: '{}'. Expected: active, inactive, archived",
103                                    value
104                                ),
105                            });
106                        }
107                    });
108                }
109                "platform" => {
110                    filter.platform = Some(match value.trim().to_lowercase().as_str() {
111                        "acc" => Platform::Acc,
112                        "bim360" => Platform::Bim360,
113                        _ => {
114                            return Err(AdminError::InvalidFilter {
115                                message: format!(
116                                    "Invalid platform: '{}'. Expected: acc, bim360",
117                                    value
118                                ),
119                            });
120                        }
121                    });
122                }
123                "region" => {
124                    filter.region = Some(match value.trim().to_lowercase().as_str() {
125                        "us" => Region::Us,
126                        "emea" => Region::Emea,
127                        _ => {
128                            return Err(AdminError::InvalidFilter {
129                                message: format!("Invalid region: '{}'. Expected: us, emea", value),
130                            });
131                        }
132                    });
133                }
134                "created" => {
135                    let value = value.trim();
136                    if let Some(date_str) = value.strip_prefix('>') {
137                        let date = parse_date(date_str.trim())?;
138                        filter.created_after = Some(date);
139                    } else if let Some(date_str) = value.strip_prefix('<') {
140                        let date = parse_date(date_str.trim())?;
141                        filter.created_before = Some(date);
142                    } else {
143                        return Err(AdminError::InvalidFilter {
144                            message: format!(
145                                "Invalid created filter: '{}'. Use >YYYY-MM-DD or <YYYY-MM-DD",
146                                value
147                            ),
148                        });
149                    }
150                }
151                _ => {
152                    return Err(AdminError::InvalidFilter {
153                        message: format!(
154                            "Unknown filter key: '{}'. Valid keys: name, status, platform, created, region",
155                            key
156                        ),
157                    });
158                }
159            }
160        }
161
162        Ok(filter)
163    }
164
165    /// Check if a project name matches the filter's name pattern
166    pub fn matches_name(&self, project_name: &str) -> bool {
167        match &self.name_pattern {
168            None => true,
169            Some(pattern) => {
170                let glob_pattern = glob::Pattern::new(pattern).ok();
171                glob_pattern
172                    .map(|p| p.matches(project_name))
173                    .unwrap_or(false)
174            }
175        }
176    }
177
178    /// Check if a project matches all filter criteria
179    pub fn matches(&self, project: &raps_acc::types::AccountProject) -> bool {
180        // Check name pattern
181        if !self.matches_name(&project.name) {
182            return false;
183        }
184
185        // Check status
186        if let Some(ref filter_status) = self.status {
187            let project_status = project
188                .status
189                .as_ref()
190                .map(|s| s.to_lowercase())
191                .unwrap_or_else(|| "active".to_string());
192
193            let status_matches = match filter_status {
194                ProjectStatus::Active => project_status == "active",
195                ProjectStatus::Inactive => project_status == "inactive",
196                ProjectStatus::Archived => project_status == "archived",
197            };
198
199            if !status_matches {
200                return false;
201            }
202        }
203
204        // Check platform
205        if let Some(ref filter_platform) = self.platform {
206            let platform_matches = match filter_platform {
207                Platform::Acc => project.is_acc(),
208                Platform::Bim360 => project.is_bim360(),
209            };
210
211            if !platform_matches {
212                return false;
213            }
214        }
215
216        // Check created_after
217        if let Some(ref after_date) = self.created_after
218            && let Some(ref created) = project.created_at
219            && created < after_date
220        {
221            return false;
222        }
223
224        // Check created_before
225        if let Some(ref before_date) = self.created_before
226            && let Some(ref created) = project.created_at
227            && created > before_date
228        {
229            return false;
230        }
231
232        // Check include_ids (if specified, project must be in the list)
233        if let Some(ref include_ids) = self.include_ids
234            && !include_ids.contains(&project.id)
235        {
236            return false;
237        }
238
239        // Check exclude_ids
240        if let Some(ref exclude_ids) = self.exclude_ids
241            && exclude_ids.contains(&project.id)
242        {
243            return false;
244        }
245
246        true
247    }
248
249    /// Apply filter to a list of projects
250    pub fn apply(
251        &self,
252        projects: Vec<raps_acc::types::AccountProject>,
253    ) -> Vec<raps_acc::types::AccountProject> {
254        projects.into_iter().filter(|p| self.matches(p)).collect()
255    }
256}
257
258/// Parse a date string in YYYY-MM-DD format
259fn parse_date(s: &str) -> Result<DateTime<Utc>, AdminError> {
260    let naive = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| {
261        AdminError::InvalidFilter {
262            message: format!("Invalid date format: '{}'. Expected YYYY-MM-DD", s),
263        }
264    })?;
265
266    Ok(naive.and_hms_opt(0, 0, 0).expect("Valid time").and_utc())
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn test_parse_empty_filter() {
275        let filter = ProjectFilter::from_expression("").unwrap();
276        assert!(filter.name_pattern.is_none());
277        assert!(filter.status.is_none());
278    }
279
280    #[test]
281    fn test_parse_name_filter() {
282        let filter = ProjectFilter::from_expression("name:*Hospital*").unwrap();
283        assert_eq!(filter.name_pattern, Some("*Hospital*".to_string()));
284    }
285
286    #[test]
287    fn test_parse_multiple_filters() {
288        let filter =
289            ProjectFilter::from_expression("name:*Building*,status:active,platform:acc").unwrap();
290        assert_eq!(filter.name_pattern, Some("*Building*".to_string()));
291        assert_eq!(filter.status, Some(ProjectStatus::Active));
292        assert_eq!(filter.platform, Some(Platform::Acc));
293    }
294
295    #[test]
296    fn test_parse_date_filter() {
297        let filter = ProjectFilter::from_expression("created:>2024-01-01").unwrap();
298        assert!(filter.created_after.is_some());
299    }
300
301    #[test]
302    fn test_invalid_filter_syntax() {
303        let result = ProjectFilter::from_expression("invalid");
304        assert!(result.is_err());
305    }
306
307    #[test]
308    fn test_matches_name() {
309        let filter = ProjectFilter {
310            name_pattern: Some("*Hospital*".to_string()),
311            ..Default::default()
312        };
313        assert!(filter.matches_name("City Hospital Phase 2"));
314        assert!(filter.matches_name("Hospital"));
315        assert!(!filter.matches_name("Office Building"));
316    }
317}