1use serde::{Deserialize, Deserializer, Serialize, Serializer};
5
6use crate::ids::{
7 IssueId, LabelId, ProjectId, ProjectMilestoneId, TeamId, UserId, WorkflowStateId,
8};
9
10const TIMELESS_DATE_FORMAT: &[time::format_description::BorrowedFormatItem<'static>] =
11 time::macros::format_description!("[year]-[month]-[day]");
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct TimelessDate(pub time::Date);
20
21impl std::fmt::Display for TimelessDate {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 let formatted = self
24 .0
25 .format(TIMELESS_DATE_FORMAT)
26 .map_err(|_| std::fmt::Error)?;
27 f.write_str(&formatted)
28 }
29}
30
31impl std::str::FromStr for TimelessDate {
32 type Err = time::error::Parse;
33
34 fn from_str(s: &str) -> Result<Self, Self::Err> {
35 time::Date::parse(s, TIMELESS_DATE_FORMAT).map(Self)
36 }
37}
38
39impl From<time::Date> for TimelessDate {
40 fn from(date: time::Date) -> Self {
41 Self(date)
42 }
43}
44
45impl Serialize for TimelessDate {
46 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
47 serializer.collect_str(self)
48 }
49}
50
51impl<'de> Deserialize<'de> for TimelessDate {
52 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
53 let s = String::deserialize(deserializer)?;
54 s.parse().map_err(serde::de::Error::custom)
55 }
56}
57
58#[derive(Debug, Clone, Default, PartialEq)]
67pub enum Undefinable<T> {
68 #[default]
70 Undefined,
71 Null,
73 Value(T),
75}
76
77impl<T> Undefinable<T> {
78 pub fn is_undefined(&self) -> bool {
80 matches!(self, Undefinable::Undefined)
81 }
82}
83
84impl<T> From<T> for Undefinable<T> {
85 fn from(value: T) -> Self {
86 Undefinable::Value(value)
87 }
88}
89
90impl<T: Serialize> Serialize for Undefinable<T> {
91 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
92 match self {
93 Undefinable::Undefined | Undefinable::Null => serializer.serialize_none(),
97 Undefinable::Value(value) => value.serialize(serializer),
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
105#[serde(from = "u8", into = "u8")]
106#[non_exhaustive]
107pub enum Priority {
108 None,
110 Urgent,
112 High,
114 Medium,
116 Low,
118 Other(u8),
120}
121
122impl From<u8> for Priority {
123 fn from(value: u8) -> Self {
124 match value {
125 0 => Priority::None,
126 1 => Priority::Urgent,
127 2 => Priority::High,
128 3 => Priority::Medium,
129 4 => Priority::Low,
130 other => Priority::Other(other),
131 }
132 }
133}
134
135impl From<Priority> for u8 {
136 fn from(value: Priority) -> Self {
137 match value {
138 Priority::None => 0,
139 Priority::Urgent => 1,
140 Priority::High => 2,
141 Priority::Medium => 3,
142 Priority::Low => 4,
143 Priority::Other(other) => other,
144 }
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151#[non_exhaustive]
152pub enum PaginationOrderBy {
153 CreatedAt,
155 UpdatedAt,
157}
158
159macro_rules! string_enum {
160 (
161 $(#[$meta:meta])*
162 $name:ident {
163 $($(#[$vmeta:meta])* $variant:ident => $wire:literal,)+
164 }
165 ) => {
166 $(#[$meta])*
167 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
168 #[serde(from = "String", into = "String")]
169 #[non_exhaustive]
170 pub enum $name {
171 $($(#[$vmeta])* $variant,)+
172 Unrecognized(String),
175 }
176
177 impl From<String> for $name {
178 fn from(s: String) -> Self {
179 match s.as_str() {
180 $($wire => Self::$variant,)+
181 _ => Self::Unrecognized(s),
182 }
183 }
184 }
185
186 impl From<$name> for String {
187 fn from(v: $name) -> String {
188 match v {
189 $($name::$variant => $wire.to_string(),)+
190 $name::Unrecognized(s) => s,
191 }
192 }
193 }
194 };
195}
196
197string_enum! {
198 WorkflowStateType {
200 Triage => "triage",
202 Backlog => "backlog",
204 Unstarted => "unstarted",
206 Started => "started",
208 Completed => "completed",
210 Canceled => "canceled",
212 }
213}
214
215string_enum! {
216 IssueRelationType {
221 Blocks => "blocks",
223 Duplicate => "duplicate",
225 Related => "related",
227 Similar => "similar",
229 }
230}
231
232string_enum! {
233 ProjectStatusType {
235 Backlog => "backlog",
237 Planned => "planned",
239 Started => "started",
241 Paused => "paused",
243 Completed => "completed",
245 Canceled => "canceled",
247 }
248}
249
250string_enum! {
251 ProjectHealth {
253 OnTrack => "onTrack",
255 AtRisk => "atRisk",
257 OffTrack => "offTrack",
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268#[non_exhaustive]
269pub struct UserRef {
270 pub id: UserId,
272 pub name: String,
274 pub display_name: String,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284#[non_exhaustive]
285pub struct TeamRef {
286 pub id: TeamId,
288 pub key: String,
290 pub name: String,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase")]
300#[non_exhaustive]
301pub struct ProjectRef {
302 pub id: ProjectId,
304 pub name: String,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314#[non_exhaustive]
315pub struct IssueStub {
316 pub id: IssueId,
318 pub identifier: String,
320 pub title: String,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(rename_all = "camelCase")]
330#[non_exhaustive]
331pub struct LabelRef {
332 pub id: LabelId,
334 pub name: String,
336 pub color: String,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
345#[serde(rename_all = "camelCase")]
346#[non_exhaustive]
347pub struct StateRef {
348 pub id: WorkflowStateId,
350 pub name: String,
352 #[serde(rename = "type")]
354 pub state_type: WorkflowStateType,
355 pub color: String,
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize)]
364#[serde(rename_all = "camelCase")]
365#[non_exhaustive]
366pub struct MilestoneRef {
367 pub id: ProjectMilestoneId,
369 pub name: String,
371}
372
373pub fn nodes<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
379where
380 D: Deserializer<'de>,
381 T: Deserialize<'de>,
382{
383 #[derive(Deserialize)]
384 struct Nodes<T> {
385 nodes: Vec<T>,
386 }
387 Ok(Nodes::deserialize(deserializer)?.nodes)
388}
389
390pub(crate) fn ensure_success(operation: &'static str, success: bool) -> crate::Result<()> {
393 if success {
394 Ok(())
395 } else {
396 Err(crate::Error::MutationFailed { operation })
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn timeless_date_roundtrip() {
406 let date: TimelessDate = "2026-07-05".parse().unwrap();
407 assert_eq!(date.to_string(), "2026-07-05");
408 assert_eq!(serde_json::to_string(&date).unwrap(), "\"2026-07-05\"");
409 let back: TimelessDate = serde_json::from_str("\"2026-07-05\"").unwrap();
410 assert_eq!(back, date);
411 assert!("not-a-date".parse::<TimelessDate>().is_err());
412 }
413
414 #[test]
415 fn undefinable_serializes_null_and_value() {
416 #[derive(Serialize)]
417 struct Input {
418 #[serde(skip_serializing_if = "Undefinable::is_undefined")]
419 a: Undefinable<u32>,
420 #[serde(skip_serializing_if = "Undefinable::is_undefined")]
421 b: Undefinable<u32>,
422 #[serde(skip_serializing_if = "Undefinable::is_undefined")]
423 c: Undefinable<u32>,
424 }
425 let value = serde_json::to_value(Input {
426 a: Undefinable::Undefined,
427 b: Undefinable::Null,
428 c: Undefinable::Value(7),
429 })
430 .unwrap();
431 assert_eq!(value, serde_json::json!({ "b": null, "c": 7 }));
432 }
433
434 #[test]
435 fn priority_maps_ints_both_ways() {
436 assert_eq!(
437 serde_json::from_str::<Priority>("1").unwrap(),
438 Priority::Urgent
439 );
440 assert_eq!(serde_json::to_string(&Priority::Low).unwrap(), "4");
441 assert_eq!(
442 serde_json::from_str::<Priority>("9").unwrap(),
443 Priority::Other(9)
444 );
445 }
446
447 #[test]
448 fn string_enums_keep_unrecognized_values() {
449 let state: WorkflowStateType = serde_json::from_str("\"started\"").unwrap();
450 assert_eq!(state, WorkflowStateType::Started);
451 let novel: WorkflowStateType = serde_json::from_str("\"paused\"").unwrap();
452 assert_eq!(novel, WorkflowStateType::Unrecognized("paused".into()));
453 assert_eq!(
454 serde_json::to_string(&ProjectHealth::OnTrack).unwrap(),
455 "\"onTrack\""
456 );
457 }
458
459 #[test]
460 fn nodes_unwraps_connections() {
461 #[derive(Deserialize)]
462 struct Holder {
463 #[serde(deserialize_with = "crate::types::nodes")]
464 labels: Vec<String>,
465 }
466 let holder: Holder =
467 serde_json::from_value(serde_json::json!({ "labels": { "nodes": ["a", "b"] } }))
468 .unwrap();
469 assert_eq!(holder.labels, vec!["a", "b"]);
470 }
471
472 #[test]
473 fn ensure_success_maps_false() {
474 assert!(ensure_success("Test", true).is_ok());
475 assert!(matches!(
476 ensure_success("Test", false),
477 Err(crate::Error::MutationFailed { operation: "Test" })
478 ));
479 }
480}