Skip to main content

lineark_sdk/
error.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4/// A single GraphQL error from the API response.
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct GraphQLError {
7    pub message: String,
8    #[serde(default)]
9    pub extensions: Option<serde_json::Value>,
10}
11
12/// Errors that can occur when interacting with the Linear API.
13#[derive(Debug)]
14pub enum LinearError {
15    /// Authentication failed (invalid or expired token).
16    Authentication(String),
17    /// Request was rate-limited.
18    RateLimited {
19        retry_after: Option<f64>,
20        message: String,
21    },
22    /// Invalid input (bad arguments to a mutation).
23    InvalidInput(String),
24    /// Forbidden (insufficient permissions).
25    Forbidden(String),
26    /// Network or HTTP transport error.
27    Network(reqwest::Error),
28    /// GraphQL errors returned by the API.
29    GraphQL(Vec<GraphQLError>),
30    /// The requested data path was not found in the response.
31    MissingData(String),
32    /// Non-2xx HTTP response not covered by a more specific variant.
33    HttpError { status: u16, body: String },
34    /// Auth configuration error (no token found).
35    AuthConfig(String),
36}
37
38impl fmt::Display for LinearError {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            Self::Authentication(msg) => write!(f, "Authentication error: {}", msg),
42            Self::RateLimited { message, .. } => write!(f, "Rate limited: {}", message),
43            Self::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
44            Self::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
45            Self::Network(e) => write!(f, "Network error: {}", e),
46            Self::GraphQL(errors) => {
47                let msgs: Vec<String> = errors
48                    .iter()
49                    .map(|e| {
50                        if let Some(ext) = &e.extensions {
51                            format!("{} ({})", e.message, ext)
52                        } else {
53                            e.message.clone()
54                        }
55                    })
56                    .collect();
57                write!(f, "GraphQL errors: {}", msgs.join("; "))
58            }
59            Self::HttpError { status, body } => {
60                write!(f, "HTTP error {}: {}", status, body)
61            }
62            Self::MissingData(path) => write!(f, "Missing data at path: {}", path),
63            Self::AuthConfig(msg) => write!(f, "Auth configuration error: {}", msg),
64        }
65    }
66}
67
68impl std::error::Error for LinearError {
69    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
70        match self {
71            Self::Network(e) => Some(e),
72            _ => None,
73        }
74    }
75}
76
77impl From<reqwest::Error> for LinearError {
78    fn from(e: reqwest::Error) -> Self {
79        Self::Network(e)
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn display_authentication_error() {
89        let err = LinearError::Authentication("Invalid token".to_string());
90        assert_eq!(err.to_string(), "Authentication error: Invalid token");
91    }
92
93    #[test]
94    fn display_rate_limited_error() {
95        let err = LinearError::RateLimited {
96            retry_after: Some(30.0),
97            message: "Too many requests".to_string(),
98        };
99        assert_eq!(err.to_string(), "Rate limited: Too many requests");
100    }
101
102    #[test]
103    fn display_invalid_input_error() {
104        let err = LinearError::InvalidInput("bad field".to_string());
105        assert_eq!(err.to_string(), "Invalid input: bad field");
106    }
107
108    #[test]
109    fn display_forbidden_error() {
110        let err = LinearError::Forbidden("not allowed".to_string());
111        assert_eq!(err.to_string(), "Forbidden: not allowed");
112    }
113
114    #[test]
115    fn display_graphql_error_single() {
116        let err = LinearError::GraphQL(vec![GraphQLError {
117            message: "Field not found".to_string(),
118            extensions: None,
119        }]);
120        assert_eq!(err.to_string(), "GraphQL errors: Field not found");
121    }
122
123    #[test]
124    fn display_graphql_error_with_extensions() {
125        let err = LinearError::GraphQL(vec![GraphQLError {
126            message: "Error".to_string(),
127            extensions: Some(serde_json::json!({"code": "VALIDATION"})),
128        }]);
129        let display = err.to_string();
130        assert!(display.contains("Error"));
131        assert!(display.contains("VALIDATION"));
132    }
133
134    #[test]
135    fn display_graphql_error_multiple() {
136        let err = LinearError::GraphQL(vec![
137            GraphQLError {
138                message: "Error 1".to_string(),
139                extensions: None,
140            },
141            GraphQLError {
142                message: "Error 2".to_string(),
143                extensions: None,
144            },
145        ]);
146        let display = err.to_string();
147        assert!(display.contains("Error 1"));
148        assert!(display.contains("Error 2"));
149        assert!(display.contains("; "));
150    }
151
152    #[test]
153    fn display_http_error() {
154        let err = LinearError::HttpError {
155            status: 500,
156            body: "Internal Server Error".to_string(),
157        };
158        assert_eq!(err.to_string(), "HTTP error 500: Internal Server Error");
159    }
160
161    #[test]
162    fn display_missing_data_error() {
163        let err = LinearError::MissingData("No 'viewer' in response data".to_string());
164        assert_eq!(
165            err.to_string(),
166            "Missing data at path: No 'viewer' in response data"
167        );
168    }
169
170    #[test]
171    fn display_auth_config_error() {
172        let err = LinearError::AuthConfig("Token file not found".to_string());
173        assert_eq!(
174            err.to_string(),
175            "Auth configuration error: Token file not found"
176        );
177    }
178
179    #[test]
180    fn graphql_error_deserializes() {
181        let json = r#"{"message": "Something failed", "extensions": {"code": "BAD_INPUT"}}"#;
182        let err: GraphQLError = serde_json::from_str(json).unwrap();
183        assert_eq!(err.message, "Something failed");
184        assert!(err.extensions.is_some());
185    }
186
187    #[test]
188    fn graphql_error_deserializes_without_extensions() {
189        let json = r#"{"message": "Something failed"}"#;
190        let err: GraphQLError = serde_json::from_str(json).unwrap();
191        assert_eq!(err.message, "Something failed");
192        assert!(err.extensions.is_none());
193    }
194
195    #[test]
196    fn graphql_error_serializes() {
197        let err = GraphQLError {
198            message: "test".to_string(),
199            extensions: None,
200        };
201        let json = serde_json::to_value(&err).unwrap();
202        assert_eq!(json["message"], "test");
203    }
204
205    #[test]
206    fn linear_error_is_std_error() {
207        let err = LinearError::Authentication("test".to_string());
208        let _: &dyn std::error::Error = &err;
209    }
210
211    #[test]
212    fn network_error_has_source() {
213        // We can't easily construct a reqwest::Error directly, but we can verify
214        // the source() method returns None for non-Network variants.
215        let err = LinearError::Authentication("test".to_string());
216        assert!(std::error::Error::source(&err).is_none());
217    }
218}