1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::error::AdminError;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum Platform {
15 Acc,
17 Bim360,
19}
20
21#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum Region {
34 Us,
35 Emea,
36}
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40pub struct ProjectFilter {
41 pub name_pattern: Option<String>,
43 pub status: Option<ProjectStatus>,
45 pub platform: Option<Platform>,
47 pub created_after: Option<DateTime<Utc>>,
49 pub created_before: Option<DateTime<Utc>>,
51 pub region: Option<Region>,
53 pub include_ids: Option<Vec<String>>,
55 pub exclude_ids: Option<Vec<String>>,
57}
58
59impl ProjectFilter {
60 pub fn new() -> Self {
62 Self::default()
63 }
64
65 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 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 pub fn matches(&self, project: &raps_acc::types::AccountProject) -> bool {
180 if !self.matches_name(&project.name) {
182 return false;
183 }
184
185 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 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 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 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 if let Some(ref include_ids) = self.include_ids
234 && !include_ids.contains(&project.id)
235 {
236 return false;
237 }
238
239 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 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
258fn 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}