use super::{user::UserReference, workspace::WorkspaceReference};
use serde::{Deserialize, Serialize};
use std::ops::Deref;
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd)]
#[serde(rename_all = "snake_case")]
pub struct TagCompact {
pub gid: String,
pub name: String,
#[serde(default)]
pub resource_type: Option<String>,
}
impl TagCompact {
#[must_use]
pub fn label(&self) -> &str {
&self.name
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum TagColor {
DarkBlue,
DarkBrown,
DarkGreen,
DarkOrange,
DarkPink,
DarkPurple,
DarkRed,
DarkTeal,
DarkWarmGray,
LightBlue,
LightBrown,
LightGreen,
LightOrange,
LightPink,
LightPurple,
LightRed,
LightTeal,
LightWarmGray,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct Tag {
pub gid: String,
pub name: String,
#[serde(default)]
pub resource_type: Option<String>,
#[serde(default)]
pub color: Option<TagColor>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub followers: Vec<UserReference>,
#[serde(default)]
pub workspace: Option<WorkspaceReference>,
#[serde(default)]
pub permalink_url: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct TagListParams {
pub workspace: String,
pub limit: Option<usize>,
}
impl TagListParams {
#[must_use]
pub fn to_query(&self) -> Vec<(String, String)> {
vec![("workspace".into(), self.workspace.clone())]
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct TagCreateData {
pub name: String,
pub workspace: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<TagColor>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub followers: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TagCreateRequest {
pub data: TagCreateData,
}
#[derive(Debug, Clone)]
pub struct TagCreateBuilder {
data: TagCreateData,
}
impl TagCreateBuilder {
#[must_use]
pub fn new(name: impl Into<String>, workspace: impl Into<String>) -> Self {
Self {
data: TagCreateData {
name: name.into(),
workspace: workspace.into(),
color: None,
notes: None,
followers: Vec::new(),
},
}
}
#[must_use]
pub fn name(mut self, name: impl Into<String>) -> Self {
self.data.name = name.into();
self
}
#[must_use]
pub fn color(mut self, color: TagColor) -> Self {
self.data.color = Some(color);
self
}
#[must_use]
pub fn notes(mut self, notes: impl Into<String>) -> Self {
self.data.notes = Some(notes.into());
self
}
#[must_use]
pub fn follower(mut self, follower: impl Into<String>) -> Self {
let gid = follower.into();
if !self.data.followers.contains(&gid) {
self.data.followers.push(gid);
}
self
}
pub fn build(self) -> Result<TagCreateRequest, TagValidationError> {
if self.data.name.trim().is_empty() {
return Err(TagValidationError::MissingName);
}
if self.data.workspace.trim().is_empty() {
return Err(TagValidationError::MissingWorkspace);
}
Ok(TagCreateRequest { data: self.data })
}
}
#[allow(
clippy::option_option,
reason = "Option<Option<T>> models the API PATCH tri-state: absent leaves the field unchanged, null clears it, Some(v) sets it"
)]
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct TagUpdateData {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<TagColor>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub followers: Option<Vec<String>>,
}
impl TagUpdateData {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.name.is_none()
&& self.color.is_none()
&& self.notes.is_none()
&& self.followers.is_none()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct TagUpdateRequest {
pub data: TagUpdateData,
}
#[derive(Debug, Default, Clone)]
pub struct TagUpdateBuilder {
data: TagUpdateData,
}
impl TagUpdateBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn name(mut self, name: impl Into<String>) -> Self {
self.data.name = Some(name.into());
self
}
#[must_use]
pub fn color(mut self, color: TagColor) -> Self {
self.data.color = Some(color);
self
}
#[must_use]
pub fn notes(mut self, notes: impl Into<String>) -> Self {
self.data.notes = Some(Some(notes.into()));
self
}
#[must_use]
pub fn clear_notes(mut self) -> Self {
self.data.notes = Some(None);
self
}
#[must_use]
pub fn followers<I, S>(mut self, followers: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut values: Vec<String> = followers.into_iter().map(Into::into).collect();
values.sort();
values.dedup();
self.data.followers = Some(values);
self
}
pub fn build(self) -> Result<TagUpdateRequest, TagValidationError> {
if self.data.is_empty() {
return Err(TagValidationError::EmptyUpdate);
}
Ok(TagUpdateRequest { data: self.data })
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum TagValidationError {
#[error("tag name cannot be empty")]
MissingName,
#[error("tags require a workspace identifier")]
MissingWorkspace,
#[error("tag update payload does not include any changes")]
EmptyUpdate,
}
impl Deref for TagUpdateBuilder {
type Target = TagUpdateData;
fn deref(&self) -> &Self::Target {
&self.data
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_builder_requires_name() {
let builder = TagCreateBuilder::new(" ", "ws-123");
let result = builder.build();
assert_eq!(result.unwrap_err(), TagValidationError::MissingName);
}
#[test]
fn create_builder_requires_workspace() {
let builder = TagCreateBuilder::new("Important", " ");
let result = builder.build();
assert_eq!(result.unwrap_err(), TagValidationError::MissingWorkspace);
}
#[test]
fn create_builder_success() {
let builder = TagCreateBuilder::new("Important", "ws-123")
.color(TagColor::DarkRed)
.notes("High priority items");
let request = builder.build().expect("builder should succeed");
assert_eq!(request.data.name, "Important");
assert_eq!(request.data.workspace, "ws-123");
assert_eq!(request.data.color, Some(TagColor::DarkRed));
}
#[test]
fn update_builder_requires_changes() {
let builder = TagUpdateBuilder::new();
let result = builder.build();
assert_eq!(result.unwrap_err(), TagValidationError::EmptyUpdate);
}
#[test]
fn update_builder_accepts_changes() {
let request = TagUpdateBuilder::new()
.name("Updated")
.color(TagColor::LightGreen)
.build()
.expect("builder should succeed");
assert_eq!(request.data.name.as_deref(), Some("Updated"));
assert_eq!(request.data.color, Some(TagColor::LightGreen));
}
#[test]
fn update_builder_clears_notes() {
let request = TagUpdateBuilder::new()
.clear_notes()
.build()
.expect("builder should succeed");
assert_eq!(request.data.notes, Some(None));
}
}