use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::ids::{
IssueId, LabelId, ProjectId, ProjectMilestoneId, TeamId, UserId, WorkflowStateId,
};
const TIMELESS_DATE_FORMAT: &[time::format_description::BorrowedFormatItem<'static>] =
time::macros::format_description!("[year]-[month]-[day]");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimelessDate(pub time::Date);
impl std::fmt::Display for TimelessDate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let formatted = self
.0
.format(TIMELESS_DATE_FORMAT)
.map_err(|_| std::fmt::Error)?;
f.write_str(&formatted)
}
}
impl std::str::FromStr for TimelessDate {
type Err = time::error::Parse;
fn from_str(s: &str) -> Result<Self, Self::Err> {
time::Date::parse(s, TIMELESS_DATE_FORMAT).map(Self)
}
}
impl From<time::Date> for TimelessDate {
fn from(date: time::Date) -> Self {
Self(date)
}
}
impl Serialize for TimelessDate {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for TimelessDate {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub enum Undefinable<T> {
#[default]
Undefined,
Null,
Value(T),
}
impl<T> Undefinable<T> {
pub fn is_undefined(&self) -> bool {
matches!(self, Undefinable::Undefined)
}
}
impl<T> From<T> for Undefinable<T> {
fn from(value: T) -> Self {
Undefinable::Value(value)
}
}
impl<T: Serialize> Serialize for Undefinable<T> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Undefinable::Undefined | Undefinable::Null => serializer.serialize_none(),
Undefinable::Value(value) => value.serialize(serializer),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(from = "u8", into = "u8")]
#[non_exhaustive]
pub enum Priority {
None,
Urgent,
High,
Medium,
Low,
Other(u8),
}
impl From<u8> for Priority {
fn from(value: u8) -> Self {
match value {
0 => Priority::None,
1 => Priority::Urgent,
2 => Priority::High,
3 => Priority::Medium,
4 => Priority::Low,
other => Priority::Other(other),
}
}
}
impl From<Priority> for u8 {
fn from(value: Priority) -> Self {
match value {
Priority::None => 0,
Priority::Urgent => 1,
Priority::High => 2,
Priority::Medium => 3,
Priority::Low => 4,
Priority::Other(other) => other,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum PaginationOrderBy {
CreatedAt,
UpdatedAt,
}
macro_rules! string_enum {
(
$(#[$meta:meta])*
$name:ident {
$($(#[$vmeta:meta])* $variant:ident => $wire:literal,)+
}
) => {
$(#[$meta])*
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(from = "String", into = "String")]
#[non_exhaustive]
pub enum $name {
$($(#[$vmeta])* $variant,)+
Unrecognized(String),
}
impl From<String> for $name {
fn from(s: String) -> Self {
match s.as_str() {
$($wire => Self::$variant,)+
_ => Self::Unrecognized(s),
}
}
}
impl From<$name> for String {
fn from(v: $name) -> String {
match v {
$($name::$variant => $wire.to_string(),)+
$name::Unrecognized(s) => s,
}
}
}
};
}
string_enum! {
WorkflowStateType {
Triage => "triage",
Backlog => "backlog",
Unstarted => "unstarted",
Started => "started",
Completed => "completed",
Canceled => "canceled",
}
}
string_enum! {
IssueRelationType {
Blocks => "blocks",
Duplicate => "duplicate",
Related => "related",
Similar => "similar",
}
}
string_enum! {
ProjectStatusType {
Backlog => "backlog",
Planned => "planned",
Started => "started",
Paused => "paused",
Completed => "completed",
Canceled => "canceled",
}
}
string_enum! {
ProjectHealth {
OnTrack => "onTrack",
AtRisk => "atRisk",
OffTrack => "offTrack",
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct UserRef {
pub id: UserId,
pub name: String,
pub display_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct TeamRef {
pub id: TeamId,
pub key: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct ProjectRef {
pub id: ProjectId,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct IssueStub {
pub id: IssueId,
pub identifier: String,
pub title: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LabelRef {
pub id: LabelId,
pub name: String,
pub color: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct StateRef {
pub id: WorkflowStateId,
pub name: String,
#[serde(rename = "type")]
pub state_type: WorkflowStateType,
pub color: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct MilestoneRef {
pub id: ProjectMilestoneId,
pub name: String,
}
pub fn nodes<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
#[derive(Deserialize)]
struct Nodes<T> {
nodes: Vec<T>,
}
Ok(Nodes::deserialize(deserializer)?.nodes)
}
pub(crate) fn ensure_success(operation: &'static str, success: bool) -> crate::Result<()> {
if success {
Ok(())
} else {
Err(crate::Error::MutationFailed { operation })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timeless_date_roundtrip() {
let date: TimelessDate = "2026-07-05".parse().unwrap();
assert_eq!(date.to_string(), "2026-07-05");
assert_eq!(serde_json::to_string(&date).unwrap(), "\"2026-07-05\"");
let back: TimelessDate = serde_json::from_str("\"2026-07-05\"").unwrap();
assert_eq!(back, date);
assert!("not-a-date".parse::<TimelessDate>().is_err());
}
#[test]
fn undefinable_serializes_null_and_value() {
#[derive(Serialize)]
struct Input {
#[serde(skip_serializing_if = "Undefinable::is_undefined")]
a: Undefinable<u32>,
#[serde(skip_serializing_if = "Undefinable::is_undefined")]
b: Undefinable<u32>,
#[serde(skip_serializing_if = "Undefinable::is_undefined")]
c: Undefinable<u32>,
}
let value = serde_json::to_value(Input {
a: Undefinable::Undefined,
b: Undefinable::Null,
c: Undefinable::Value(7),
})
.unwrap();
assert_eq!(value, serde_json::json!({ "b": null, "c": 7 }));
}
#[test]
fn priority_maps_ints_both_ways() {
assert_eq!(
serde_json::from_str::<Priority>("1").unwrap(),
Priority::Urgent
);
assert_eq!(serde_json::to_string(&Priority::Low).unwrap(), "4");
assert_eq!(
serde_json::from_str::<Priority>("9").unwrap(),
Priority::Other(9)
);
}
#[test]
fn string_enums_keep_unrecognized_values() {
let state: WorkflowStateType = serde_json::from_str("\"started\"").unwrap();
assert_eq!(state, WorkflowStateType::Started);
let novel: WorkflowStateType = serde_json::from_str("\"paused\"").unwrap();
assert_eq!(novel, WorkflowStateType::Unrecognized("paused".into()));
assert_eq!(
serde_json::to_string(&ProjectHealth::OnTrack).unwrap(),
"\"onTrack\""
);
}
#[test]
fn nodes_unwraps_connections() {
#[derive(Deserialize)]
struct Holder {
#[serde(deserialize_with = "crate::types::nodes")]
labels: Vec<String>,
}
let holder: Holder =
serde_json::from_value(serde_json::json!({ "labels": { "nodes": ["a", "b"] } }))
.unwrap();
assert_eq!(holder.labels, vec!["a", "b"]);
}
#[test]
fn ensure_success_maps_false() {
assert!(ensure_success("Test", true).is_ok());
assert!(matches!(
ensure_success("Test", false),
Err(crate::Error::MutationFailed { operation: "Test" })
));
}
}