1use super::{user::UserReference, workspace::WorkspaceReference};
4use clap::ValueEnum;
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7use serde_with::serde_as;
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::PathBuf;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, ValueEnum)]
13#[serde(rename_all = "snake_case")]
14pub enum MemberPermission {
15 Member,
17 Commenter,
19 Viewer,
21}
22
23#[serde_as]
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27pub struct Project {
28 pub gid: String,
30 pub name: String,
32 #[serde(default)]
34 pub resource_type: Option<String>,
35 #[serde(default)]
37 pub notes: Option<String>,
38 #[serde(default)]
40 pub color: Option<String>,
41 #[serde(default)]
43 pub archived: bool,
44 #[serde(default)]
46 pub public: Option<bool>,
47 #[serde(default)]
49 pub due_on: Option<String>,
50 #[serde(default)]
52 pub start_on: Option<String>,
53 #[serde(default)]
55 pub created_at: Option<String>,
56 #[serde(default)]
58 pub modified_at: Option<String>,
59 #[serde(default)]
61 pub workspace: Option<WorkspaceReference>,
62 #[serde(default)]
64 pub team: Option<WorkspaceReference>,
65 #[serde(default)]
67 pub owner: Option<UserReference>,
68 #[serde(default)]
70 pub members: Vec<ProjectMember>,
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub statuses: Vec<ProjectStatus>,
74 #[serde(default)]
76 pub custom_fields: BTreeMap<String, serde_json::Value>,
77}
78
79impl Project {
80 #[must_use]
82 pub fn matches(&self, filters: &[ProjectFilter]) -> bool {
83 filters.iter().all(|filter| filter.matches(self))
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89pub struct ProjectMembers {
90 pub project_gid: String,
92 pub members: Vec<ProjectMember>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98pub struct ProjectMember {
99 pub gid: String,
101 pub user: UserReference,
103 #[serde(default)]
105 pub role: Option<MemberPermission>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110#[serde(rename_all = "snake_case")]
111pub struct ProjectStatus {
112 pub gid: String,
114 #[serde(default)]
116 pub title: Option<String>,
117 #[serde(default)]
119 pub color: Option<String>,
120 #[serde(default)]
122 pub text: Option<String>,
123 #[serde(default)]
125 pub created_at: Option<String>,
126 #[serde(default)]
128 pub created_by: Option<UserReference>,
129}
130
131#[derive(Debug, Clone, Default)]
133pub struct ProjectListParams {
134 pub workspace: Option<String>,
136 pub team: Option<String>,
138 pub archived: Option<bool>,
140 pub fields: BTreeSet<String>,
142 pub limit: Option<usize>,
144 pub filters: Vec<ProjectFilter>,
146 pub sort: Option<ProjectSort>,
148}
149
150impl ProjectListParams {
151 #[must_use]
153 pub fn to_query(&self) -> Vec<(String, String)> {
154 let mut pairs = Vec::new();
155 if let Some(workspace) = &self.workspace {
156 pairs.push(("workspace".into(), workspace.clone()));
157 }
158 if let Some(team) = &self.team {
159 pairs.push(("team".into(), team.clone()));
160 }
161 if let Some(archived) = self.archived {
162 pairs.push(("archived".into(), archived.to_string()));
163 }
164 if !self.fields.is_empty() {
165 let field_list = self.fields.iter().cloned().collect::<Vec<_>>().join(",");
166 pairs.push(("opt_fields".into(), field_list));
167 }
168 pairs
169 }
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum ProjectSort {
175 Name,
177 CreatedAt,
179 ModifiedAt,
181}
182
183#[derive(Debug, Clone)]
185pub enum ProjectFilter {
186 Equals(String, String),
188 NotEquals(String, String),
190 Regex(String, Regex),
192 Contains(String, String),
194}
195
196impl ProjectFilter {
197 #[must_use]
199 pub fn matches(&self, project: &Project) -> bool {
200 match self {
201 Self::Equals(field, expected) => {
202 field_value(project, field).is_some_and(|value| value == expected.as_str())
203 }
204 Self::NotEquals(field, forbidden) => {
205 field_value(project, field).is_none_or(|value| value != forbidden.as_str())
206 }
207 Self::Regex(field, pattern) => {
208 field_value(project, field).is_some_and(|value| pattern.is_match(&value))
209 }
210 Self::Contains(field, needle) => field_value(project, field).is_some_and(|value| {
211 value
212 .to_ascii_lowercase()
213 .contains(&needle.to_ascii_lowercase())
214 }),
215 }
216 }
217}
218
219fn field_value(project: &Project, field: &str) -> Option<String> {
220 match field {
221 "name" => Some(project.name.clone()),
222 "gid" => Some(project.gid.clone()),
223 "notes" => project.notes.clone(),
224 "color" => project.color.clone(),
225 "archived" => Some(project.archived.to_string()),
226 "public" => project.public.map(|value| value.to_string()),
227 "due_on" => project.due_on.clone(),
228 "start_on" => project.start_on.clone(),
229 "created_at" => project.created_at.clone(),
230 "modified_at" => project.modified_at.clone(),
231 "workspace" => project
232 .workspace
233 .as_ref()
234 .map(super::workspace::WorkspaceReference::label),
235 "team" => project
236 .team
237 .as_ref()
238 .map(super::workspace::WorkspaceReference::label),
239 "owner" => project
240 .owner
241 .as_ref()
242 .map(super::user::UserReference::label),
243 "owner.name" | "owner_name" => project.owner.as_ref().and_then(|owner| owner.name.clone()),
244 "owner.email" | "owner_email" => {
245 project.owner.as_ref().and_then(|owner| owner.email.clone())
246 }
247 other => project
248 .custom_fields
249 .get(other)
250 .and_then(|value| value.as_str().map(ToString::to_string)),
251 }
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize, Default)]
256#[serde(rename_all = "snake_case")]
257pub struct ProjectCreateData {
258 pub name: String,
260 #[serde(skip_serializing_if = "Option::is_none")]
262 pub workspace: Option<String>,
263 #[serde(skip_serializing_if = "Option::is_none")]
265 pub team: Option<String>,
266 #[serde(skip_serializing_if = "Option::is_none")]
268 pub notes: Option<String>,
269 #[serde(skip_serializing_if = "Option::is_none")]
271 pub color: Option<String>,
272 #[serde(skip_serializing_if = "Option::is_none")]
274 pub start_on: Option<String>,
275 #[serde(skip_serializing_if = "Option::is_none")]
277 pub due_on: Option<String>,
278 #[serde(skip_serializing_if = "Option::is_none")]
280 pub public: Option<bool>,
281 #[serde(skip_serializing_if = "Option::is_none")]
283 pub owner: Option<String>,
284 #[serde(skip_serializing_if = "Vec::is_empty")]
286 pub members: Vec<String>,
287 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
289 pub custom_fields: BTreeMap<String, serde_json::Value>,
290}
291
292#[derive(Debug, Clone, Serialize)]
294pub struct ProjectCreateRequest {
295 pub data: ProjectCreateData,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
301#[serde(rename_all = "snake_case")]
302pub struct ProjectUpdateData {
303 #[serde(skip_serializing_if = "Option::is_none")]
305 pub name: Option<String>,
306 #[serde(skip_serializing_if = "Option::is_none")]
308 pub notes: Option<String>,
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub color: Option<String>,
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub start_on: Option<String>,
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub due_on: Option<String>,
318 #[serde(skip_serializing_if = "Option::is_none")]
320 pub archived: Option<bool>,
321 #[serde(skip_serializing_if = "Option::is_none")]
323 pub public: Option<bool>,
324 #[serde(skip_serializing_if = "Option::is_none")]
326 pub owner: Option<String>,
327}
328
329impl ProjectUpdateData {
330 #[must_use]
332 pub const fn is_empty(&self) -> bool {
333 self.name.is_none()
334 && self.notes.is_none()
335 && self.color.is_none()
336 && self.start_on.is_none()
337 && self.due_on.is_none()
338 && self.archived.is_none()
339 && self.public.is_none()
340 && self.owner.is_none()
341 }
342}
343
344#[derive(Debug, Clone, Serialize)]
346pub struct ProjectUpdateRequest {
347 pub data: ProjectUpdateData,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub struct ProjectTemplate {
355 pub name: String,
357 pub project: ProjectCreateData,
359 #[serde(default)]
361 pub description: Option<String>,
362 #[serde(default)]
364 pub tags: Vec<String>,
365 #[serde(skip)]
367 pub source: Option<PathBuf>,
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use regex::Regex;
374
375 fn sample_project() -> Project {
376 Project {
377 gid: "P1".into(),
378 name: "Demo Project".into(),
379 resource_type: None,
380 notes: Some("Demo".into()),
381 color: None,
382 archived: false,
383 public: Some(true),
384 due_on: None,
385 start_on: None,
386 created_at: None,
387 modified_at: None,
388 workspace: Some(WorkspaceReference {
389 gid: "W1".into(),
390 name: Some("Engineering".into()),
391 resource_type: None,
392 }),
393 team: None,
394 owner: Some(UserReference {
395 gid: "U1".into(),
396 name: Some("Owner".into()),
397 resource_type: None,
398 email: Some("owner@example.com".into()),
399 }),
400 members: Vec::new(),
401 statuses: Vec::new(),
402 custom_fields: BTreeMap::new(),
403 }
404 }
405
406 #[test]
407 fn equals_filter_matches_project_name() {
408 let project = sample_project();
409 let filter = ProjectFilter::Equals("name".into(), "Demo Project".into());
410 assert!(filter.matches(&project));
411 }
412
413 #[test]
414 fn regex_filter_matches_owner_email() {
415 let project = sample_project();
416 let filter = ProjectFilter::Regex(
417 "owner.email".into(),
418 Regex::new(r"(?i)owner@example\.com").unwrap(),
419 );
420 assert!(filter.matches(&project));
421 }
422
423 #[test]
424 fn update_data_is_empty_when_no_fields_set() {
425 let mut data = ProjectUpdateData::default();
426 assert!(data.is_empty());
427 data.archived = Some(true);
428 assert!(!data.is_empty());
429 }
430}