use super::{user::UserReference, workspace::WorkspaceReference};
use clap::ValueEnum;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, ValueEnum)]
#[serde(rename_all = "snake_case")]
pub enum MemberPermission {
Member,
Commenter,
Viewer,
}
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct Project {
pub gid: String,
pub name: String,
#[serde(default)]
pub resource_type: Option<String>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub archived: bool,
#[serde(default)]
pub public: Option<bool>,
#[serde(default)]
pub due_on: Option<String>,
#[serde(default)]
pub start_on: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub modified_at: Option<String>,
#[serde(default)]
pub workspace: Option<WorkspaceReference>,
#[serde(default)]
pub team: Option<WorkspaceReference>,
#[serde(default)]
pub owner: Option<UserReference>,
#[serde(default)]
pub members: Vec<ProjectMember>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub statuses: Vec<ProjectStatus>,
#[serde(default)]
pub custom_fields: BTreeMap<String, serde_json::Value>,
}
impl Project {
#[must_use]
pub fn matches(&self, filters: &[ProjectFilter]) -> bool {
filters.iter().all(|filter| filter.matches(self))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProjectMembers {
pub project_gid: String,
pub members: Vec<ProjectMember>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProjectMember {
pub gid: String,
pub user: UserReference,
#[serde(default)]
pub role: Option<MemberPermission>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct ProjectStatus {
pub gid: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub created_by: Option<UserReference>,
}
#[derive(Debug, Clone, Default)]
pub struct ProjectListParams {
pub workspace: Option<String>,
pub team: Option<String>,
pub archived: Option<bool>,
pub fields: BTreeSet<String>,
pub limit: Option<usize>,
pub filters: Vec<ProjectFilter>,
pub sort: Option<ProjectSort>,
}
impl ProjectListParams {
#[must_use]
pub fn to_query(&self) -> Vec<(String, String)> {
let mut pairs = Vec::new();
if let Some(workspace) = &self.workspace {
pairs.push(("workspace".into(), workspace.clone()));
}
if let Some(team) = &self.team {
pairs.push(("team".into(), team.clone()));
}
if let Some(archived) = self.archived {
pairs.push(("archived".into(), archived.to_string()));
}
if !self.fields.is_empty() {
let field_list = self.fields.iter().cloned().collect::<Vec<_>>().join(",");
pairs.push(("opt_fields".into(), field_list));
}
pairs
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectSort {
Name,
CreatedAt,
ModifiedAt,
}
#[derive(Debug, Clone)]
pub enum ProjectFilter {
Equals(String, String),
NotEquals(String, String),
Regex(String, Regex),
Contains(String, String),
}
impl ProjectFilter {
#[must_use]
pub fn matches(&self, project: &Project) -> bool {
match self {
Self::Equals(field, expected) => {
field_value(project, field).is_some_and(|value| value == expected.as_str())
}
Self::NotEquals(field, forbidden) => {
field_value(project, field).is_none_or(|value| value != forbidden.as_str())
}
Self::Regex(field, pattern) => {
field_value(project, field).is_some_and(|value| pattern.is_match(&value))
}
Self::Contains(field, needle) => field_value(project, field).is_some_and(|value| {
value
.to_ascii_lowercase()
.contains(&needle.to_ascii_lowercase())
}),
}
}
}
fn field_value(project: &Project, field: &str) -> Option<String> {
match field {
"name" => Some(project.name.clone()),
"gid" => Some(project.gid.clone()),
"notes" => project.notes.clone(),
"color" => project.color.clone(),
"archived" => Some(project.archived.to_string()),
"public" => project.public.map(|value| value.to_string()),
"due_on" => project.due_on.clone(),
"start_on" => project.start_on.clone(),
"created_at" => project.created_at.clone(),
"modified_at" => project.modified_at.clone(),
"workspace" => project
.workspace
.as_ref()
.map(super::workspace::WorkspaceReference::label),
"team" => project
.team
.as_ref()
.map(super::workspace::WorkspaceReference::label),
"owner" => project
.owner
.as_ref()
.map(super::user::UserReference::label),
"owner.name" | "owner_name" => project.owner.as_ref().and_then(|owner| owner.name.clone()),
"owner.email" | "owner_email" => {
project.owner.as_ref().and_then(|owner| owner.email.clone())
}
other => project
.custom_fields
.get(other)
.and_then(|value| value.as_str().map(ToString::to_string)),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub struct ProjectCreateData {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub team: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_on: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub due_on: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub members: Vec<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub custom_fields: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProjectCreateRequest {
pub data: ProjectCreateData,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct ProjectUpdateData {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_on: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub due_on: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub archived: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
}
impl ProjectUpdateData {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.name.is_none()
&& self.notes.is_none()
&& self.color.is_none()
&& self.start_on.is_none()
&& self.due_on.is_none()
&& self.archived.is_none()
&& self.public.is_none()
&& self.owner.is_none()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ProjectUpdateRequest {
pub data: ProjectUpdateData,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ProjectTemplate {
pub name: String,
pub project: ProjectCreateData,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(skip)]
pub source: Option<PathBuf>,
}
#[cfg(test)]
mod tests {
use super::*;
use regex::Regex;
fn sample_project() -> Project {
Project {
gid: "P1".into(),
name: "Demo Project".into(),
resource_type: None,
notes: Some("Demo".into()),
color: None,
archived: false,
public: Some(true),
due_on: None,
start_on: None,
created_at: None,
modified_at: None,
workspace: Some(WorkspaceReference {
gid: "W1".into(),
name: Some("Engineering".into()),
resource_type: None,
}),
team: None,
owner: Some(UserReference {
gid: "U1".into(),
name: Some("Owner".into()),
resource_type: None,
email: Some("owner@example.com".into()),
}),
members: Vec::new(),
statuses: Vec::new(),
custom_fields: BTreeMap::new(),
}
}
#[test]
fn equals_filter_matches_project_name() {
let project = sample_project();
let filter = ProjectFilter::Equals("name".into(), "Demo Project".into());
assert!(filter.matches(&project));
}
#[test]
fn regex_filter_matches_owner_email() {
let project = sample_project();
let filter = ProjectFilter::Regex(
"owner.email".into(),
Regex::new(r"(?i)owner@example\.com").unwrap(),
);
assert!(filter.matches(&project));
}
#[test]
fn update_data_is_empty_when_no_fields_set() {
let mut data = ProjectUpdateData::default();
assert!(data.is_empty());
data.archived = Some(true);
assert!(!data.is_empty());
}
}