1use super::{user::UserReference, workspace::WorkspaceReference};
4use serde::{Deserialize, Serialize};
5use std::ops::Deref;
6use thiserror::Error;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd)]
10#[serde(rename_all = "snake_case")]
11pub struct TagCompact {
12 pub gid: String,
14 pub name: String,
16 #[serde(default)]
18 pub resource_type: Option<String>,
19}
20
21impl TagCompact {
22 #[must_use]
24 pub fn label(&self) -> &str {
25 &self.name
26 }
27}
28
29#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
31#[serde(rename_all = "kebab-case")]
32pub enum TagColor {
33 DarkBlue,
35 DarkBrown,
37 DarkGreen,
39 DarkOrange,
41 DarkPink,
43 DarkPurple,
45 DarkRed,
47 DarkTeal,
49 DarkWarmGray,
51 LightBlue,
53 LightBrown,
55 LightGreen,
57 LightOrange,
59 LightPink,
61 LightPurple,
63 LightRed,
65 LightTeal,
67 LightWarmGray,
69 #[serde(other)]
71 Unknown,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "snake_case")]
77pub struct Tag {
78 pub gid: String,
80 pub name: String,
82 #[serde(default)]
84 pub resource_type: Option<String>,
85 #[serde(default)]
87 pub color: Option<TagColor>,
88 #[serde(default)]
90 pub notes: Option<String>,
91 #[serde(default)]
93 pub created_at: Option<String>,
94 #[serde(default)]
96 pub followers: Vec<UserReference>,
97 #[serde(default)]
99 pub workspace: Option<WorkspaceReference>,
100 #[serde(default)]
102 pub permalink_url: Option<String>,
103}
104
105#[derive(Debug, Clone, Default)]
107pub struct TagListParams {
108 pub workspace: String,
110 pub limit: Option<usize>,
112}
113
114impl TagListParams {
115 #[must_use]
117 pub fn to_query(&self) -> Vec<(String, String)> {
118 vec![("workspace".into(), self.workspace.clone())]
119 }
120}
121
122#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
124#[serde(rename_all = "snake_case")]
125pub struct TagCreateData {
126 pub name: String,
128 pub workspace: String,
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub color: Option<TagColor>,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub notes: Option<String>,
136 #[serde(skip_serializing_if = "Vec::is_empty")]
138 pub followers: Vec<String>,
139}
140
141#[derive(Debug, Clone, Serialize)]
143pub struct TagCreateRequest {
144 pub data: TagCreateData,
146}
147
148#[derive(Debug, Clone)]
150pub struct TagCreateBuilder {
151 data: TagCreateData,
152}
153
154impl TagCreateBuilder {
155 #[must_use]
157 pub fn new(name: impl Into<String>, workspace: impl Into<String>) -> Self {
158 Self {
159 data: TagCreateData {
160 name: name.into(),
161 workspace: workspace.into(),
162 color: None,
163 notes: None,
164 followers: Vec::new(),
165 },
166 }
167 }
168
169 #[must_use]
171 pub fn name(mut self, name: impl Into<String>) -> Self {
172 self.data.name = name.into();
173 self
174 }
175
176 #[must_use]
178 pub fn color(mut self, color: TagColor) -> Self {
179 self.data.color = Some(color);
180 self
181 }
182
183 #[must_use]
185 pub fn notes(mut self, notes: impl Into<String>) -> Self {
186 self.data.notes = Some(notes.into());
187 self
188 }
189
190 #[must_use]
192 pub fn follower(mut self, follower: impl Into<String>) -> Self {
193 let gid = follower.into();
194 if !self.data.followers.contains(&gid) {
195 self.data.followers.push(gid);
196 }
197 self
198 }
199
200 pub fn build(self) -> Result<TagCreateRequest, TagValidationError> {
206 if self.data.name.trim().is_empty() {
207 return Err(TagValidationError::MissingName);
208 }
209 if self.data.workspace.trim().is_empty() {
210 return Err(TagValidationError::MissingWorkspace);
211 }
212 Ok(TagCreateRequest { data: self.data })
213 }
214}
215
216#[allow(
227 clippy::option_option,
228 reason = "Option<Option<T>> models the API PATCH tri-state: absent leaves the field unchanged, null clears it, Some(v) sets it"
229)]
230#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
231#[serde(rename_all = "snake_case")]
232pub struct TagUpdateData {
233 #[serde(skip_serializing_if = "Option::is_none")]
235 pub name: Option<String>,
236 #[serde(skip_serializing_if = "Option::is_none")]
238 pub color: Option<TagColor>,
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub notes: Option<Option<String>>,
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub followers: Option<Vec<String>>,
245}
246
247impl TagUpdateData {
248 #[must_use]
250 pub const fn is_empty(&self) -> bool {
251 self.name.is_none()
252 && self.color.is_none()
253 && self.notes.is_none()
254 && self.followers.is_none()
255 }
256}
257
258#[derive(Debug, Clone, Serialize)]
260pub struct TagUpdateRequest {
261 pub data: TagUpdateData,
263}
264
265#[derive(Debug, Default, Clone)]
267pub struct TagUpdateBuilder {
268 data: TagUpdateData,
269}
270
271impl TagUpdateBuilder {
272 #[must_use]
274 pub fn new() -> Self {
275 Self::default()
276 }
277
278 #[must_use]
280 pub fn name(mut self, name: impl Into<String>) -> Self {
281 self.data.name = Some(name.into());
282 self
283 }
284
285 #[must_use]
287 pub fn color(mut self, color: TagColor) -> Self {
288 self.data.color = Some(color);
289 self
290 }
291
292 #[must_use]
294 pub fn notes(mut self, notes: impl Into<String>) -> Self {
295 self.data.notes = Some(Some(notes.into()));
296 self
297 }
298
299 #[must_use]
301 pub fn clear_notes(mut self) -> Self {
302 self.data.notes = Some(None);
303 self
304 }
305
306 #[must_use]
308 pub fn followers<I, S>(mut self, followers: I) -> Self
309 where
310 I: IntoIterator<Item = S>,
311 S: Into<String>,
312 {
313 let mut values: Vec<String> = followers.into_iter().map(Into::into).collect();
314 values.sort();
315 values.dedup();
316 self.data.followers = Some(values);
317 self
318 }
319
320 pub fn build(self) -> Result<TagUpdateRequest, TagValidationError> {
326 if self.data.is_empty() {
327 return Err(TagValidationError::EmptyUpdate);
328 }
329 Ok(TagUpdateRequest { data: self.data })
330 }
331}
332
333#[derive(Debug, Error, PartialEq, Eq)]
335pub enum TagValidationError {
336 #[error("tag name cannot be empty")]
338 MissingName,
339 #[error("tags require a workspace identifier")]
341 MissingWorkspace,
342 #[error("tag update payload does not include any changes")]
344 EmptyUpdate,
345}
346
347impl Deref for TagUpdateBuilder {
348 type Target = TagUpdateData;
349
350 fn deref(&self) -> &Self::Target {
351 &self.data
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn create_builder_requires_name() {
361 let builder = TagCreateBuilder::new(" ", "ws-123");
362 let result = builder.build();
363 assert_eq!(result.unwrap_err(), TagValidationError::MissingName);
364 }
365
366 #[test]
367 fn create_builder_requires_workspace() {
368 let builder = TagCreateBuilder::new("Important", " ");
369 let result = builder.build();
370 assert_eq!(result.unwrap_err(), TagValidationError::MissingWorkspace);
371 }
372
373 #[test]
374 fn create_builder_success() {
375 let builder = TagCreateBuilder::new("Important", "ws-123")
376 .color(TagColor::DarkRed)
377 .notes("High priority items");
378 let request = builder.build().expect("builder should succeed");
379 assert_eq!(request.data.name, "Important");
380 assert_eq!(request.data.workspace, "ws-123");
381 assert_eq!(request.data.color, Some(TagColor::DarkRed));
382 }
383
384 #[test]
385 fn update_builder_requires_changes() {
386 let builder = TagUpdateBuilder::new();
387 let result = builder.build();
388 assert_eq!(result.unwrap_err(), TagValidationError::EmptyUpdate);
389 }
390
391 #[test]
392 fn update_builder_accepts_changes() {
393 let request = TagUpdateBuilder::new()
394 .name("Updated")
395 .color(TagColor::LightGreen)
396 .build()
397 .expect("builder should succeed");
398 assert_eq!(request.data.name.as_deref(), Some("Updated"));
399 assert_eq!(request.data.color, Some(TagColor::LightGreen));
400 }
401
402 #[test]
403 fn update_builder_clears_notes() {
404 let request = TagUpdateBuilder::new()
405 .clear_notes()
406 .build()
407 .expect("builder should succeed");
408 assert_eq!(request.data.notes, Some(None));
409 }
410}