Skip to main content

linear_api/
types.rs

1//! Shared scalar types, server enums, three-state input support
2//! ([`Undefinable`]), and the canonical Ref/Stub structs used across modules.
3
4use 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/// Linear's `TimelessDate` scalar: a calendar date with no time component,
14/// serialized as `"YYYY-MM-DD"`.
15///
16/// A newtype (rather than a serde `with` module) so it composes inside
17/// `Option<_>`, [`Undefinable<_>`], and `Vec<_>` without field attributes.
18#[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/// GraphQL nullable-input tri-state: absent (leave unchanged) / `null`
59/// (clear) / value (set).
60///
61/// Input convention across this crate: **create inputs** use `Option<T>` with
62/// `#[serde(skip_serializing_if = "Option::is_none")]` (absent-when-`None`);
63/// **update inputs** use `Undefinable<T>` for clearable fields, with
64/// `#[serde(skip_serializing_if = "Undefinable::is_undefined", default)]` and
65/// `#[builder(default, into)]`.
66#[derive(Debug, Clone, Default, PartialEq)]
67pub enum Undefinable<T> {
68    /// Field is omitted from the request: leave unchanged.
69    #[default]
70    Undefined,
71    /// Field is sent as `null`: clear the value.
72    Null,
73    /// Field is sent with a concrete value: set it.
74    Value(T),
75}
76
77impl<T> Undefinable<T> {
78    /// `true` when the field should be omitted from serialization entirely.
79    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            // `Undefined` is never reached when fields carry
94            // `#[serde(skip_serializing_if = "Undefinable::is_undefined")]`;
95            // serializing it as `null` is the safe fallback.
96            Undefinable::Undefined | Undefinable::Null => serializer.serialize_none(),
97            Undefinable::Value(value) => value.serialize(serializer),
98        }
99    }
100}
101
102/// Issue priority. On the wire this is an integer `0..=4`:
103/// 0 = no priority, 1 = urgent, 2 = high, 3 = medium, 4 = low.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
105#[serde(from = "u8", into = "u8")]
106#[non_exhaustive]
107pub enum Priority {
108    /// No priority set (0).
109    None,
110    /// Urgent (1).
111    Urgent,
112    /// High (2).
113    High,
114    /// Medium (3).
115    Medium,
116    /// Low (4).
117    Low,
118    /// A numeric value outside `0..=4` (future-proofing).
119    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/// Sort order for paginated list queries.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151#[non_exhaustive]
152pub enum PaginationOrderBy {
153    /// Order by creation time (`"createdAt"`).
154    CreatedAt,
155    /// Order by last update time (`"updatedAt"`).
156    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            /// A wire value this crate does not know about (server-added
173            /// variants deserialize here instead of failing).
174            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    /// The kind of a workflow state (column category on a Linear board).
199    WorkflowStateType {
200        /// Triage inbox.
201        Triage => "triage",
202        /// Backlog.
203        Backlog => "backlog",
204        /// Not started yet.
205        Unstarted => "unstarted",
206        /// In progress.
207        Started => "started",
208        /// Done.
209        Completed => "completed",
210        /// Canceled.
211        Canceled => "canceled",
212    }
213}
214
215string_enum! {
216    /// The kind of an issue relation.
217    ///
218    /// There is **no** `blockedBy` value — direction is positional:
219    /// `issueId` *blocks* `relatedIssueId` in `issueRelationCreate`.
220    IssueRelationType {
221        /// The issue blocks the related issue.
222        Blocks => "blocks",
223        /// The issue duplicates the related issue.
224        Duplicate => "duplicate",
225        /// The issues are related.
226        Related => "related",
227        /// The issues are similar.
228        Similar => "similar",
229    }
230}
231
232string_enum! {
233    /// The kind of a project status.
234    ProjectStatusType {
235        /// Backlog.
236        Backlog => "backlog",
237        /// Planned.
238        Planned => "planned",
239        /// In progress.
240        Started => "started",
241        /// Paused.
242        Paused => "paused",
243        /// Done.
244        Completed => "completed",
245        /// Canceled.
246        Canceled => "canceled",
247    }
248}
249
250string_enum! {
251    /// Project health as reported in project updates.
252    ProjectHealth {
253        /// On track (`"onTrack"`).
254        OnTrack => "onTrack",
255        /// At risk (`"atRisk"`).
256        AtRisk => "atRisk",
257        /// Off track (`"offTrack"`).
258        OffTrack => "offTrack",
259    }
260}
261
262/// Minimal reference to a user.
263///
264/// Canonical fragment:
265/// `fragment UserRefFields on User { id name displayName }`
266#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268#[non_exhaustive]
269pub struct UserRef {
270    /// User ID.
271    pub id: UserId,
272    /// Full name.
273    pub name: String,
274    /// Display (handle) name.
275    pub display_name: String,
276}
277
278/// Minimal reference to a team.
279///
280/// Canonical fragment:
281/// `fragment TeamRefFields on Team { id key name }`
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284#[non_exhaustive]
285pub struct TeamRef {
286    /// Team ID.
287    pub id: TeamId,
288    /// Team key, e.g. `"ENG"`.
289    pub key: String,
290    /// Team name.
291    pub name: String,
292}
293
294/// Minimal reference to a project.
295///
296/// Canonical fragment:
297/// `fragment ProjectRefFields on Project { id name }`
298#[derive(Debug, Clone, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase")]
300#[non_exhaustive]
301pub struct ProjectRef {
302    /// Project ID.
303    pub id: ProjectId,
304    /// Project name.
305    pub name: String,
306}
307
308/// Minimal reference to an issue.
309///
310/// Canonical fragment:
311/// `fragment IssueStubFields on Issue { id identifier title }`
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314#[non_exhaustive]
315pub struct IssueStub {
316    /// Issue ID.
317    pub id: IssueId,
318    /// Human identifier, e.g. `"ENG-123"`.
319    pub identifier: String,
320    /// Issue title.
321    pub title: String,
322}
323
324/// Minimal reference to an issue label.
325///
326/// Canonical fragment:
327/// `fragment LabelRefFields on IssueLabel { id name color }`
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(rename_all = "camelCase")]
330#[non_exhaustive]
331pub struct LabelRef {
332    /// Label ID.
333    pub id: LabelId,
334    /// Label name.
335    pub name: String,
336    /// Label color as a hex string.
337    pub color: String,
338}
339
340/// Minimal reference to a workflow state.
341///
342/// Canonical fragment:
343/// `fragment StateRefFields on WorkflowState { id name type color }`
344#[derive(Debug, Clone, Serialize, Deserialize)]
345#[serde(rename_all = "camelCase")]
346#[non_exhaustive]
347pub struct StateRef {
348    /// Workflow state ID.
349    pub id: WorkflowStateId,
350    /// State name, e.g. `"In Progress"`.
351    pub name: String,
352    /// State category.
353    #[serde(rename = "type")]
354    pub state_type: WorkflowStateType,
355    /// State color as a hex string.
356    pub color: String,
357}
358
359/// Minimal reference to a project milestone.
360///
361/// Canonical fragment:
362/// `fragment MilestoneRefFields on ProjectMilestone { id name }`
363#[derive(Debug, Clone, Serialize, Deserialize)]
364#[serde(rename_all = "camelCase")]
365#[non_exhaustive]
366pub struct MilestoneRef {
367    /// Milestone ID.
368    pub id: ProjectMilestoneId,
369    /// Milestone name.
370    pub name: String,
371}
372
373/// Deserializes a GraphQL connection object `{"nodes": [...]}` directly into
374/// a `Vec<T>`.
375///
376/// Use as `#[serde(deserialize_with = "crate::types::nodes")]` on embedded
377/// connection fields such as an issue's `labels`.
378pub 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
390/// Maps a mutation payload's `success: false` (with no GraphQL errors) to
391/// [`Error::MutationFailed`](crate::Error::MutationFailed).
392pub(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}