Skip to main content

garmin_cli/
error.rs

1use thiserror::Error;
2
3/// Main error type for garmin-cli
4#[derive(Error, Debug)]
5pub enum GarminError {
6    #[error("Authentication error: {0}")]
7    Authentication(String),
8
9    #[error("Authentication required. Please run 'garmin auth login' first.")]
10    NotAuthenticated,
11
12    #[error("MFA required")]
13    MfaRequired,
14
15    #[error("Rate limited. Please wait before retrying.")]
16    RateLimited,
17
18    #[error("Not found: {0}")]
19    NotFound(String),
20
21    #[error("API error {status}: {message}")]
22    Api { status: u16, message: String },
23
24    #[error("HTTP error: {0}")]
25    Http(#[from] reqwest::Error),
26
27    #[error("Invalid response: {0}")]
28    InvalidResponse(String),
29
30    #[error("JSON error: {0}")]
31    Json(#[from] serde_json::Error),
32
33    #[error("IO error: {0}")]
34    Io(#[from] std::io::Error),
35
36    #[error("Configuration error: {0}")]
37    Config(String),
38
39    #[error("Database error: {0}")]
40    Database(String),
41
42    #[error("Keyring error: {0}")]
43    Keyring(String),
44
45    #[error("Invalid date format: {0}. Expected YYYY-MM-DD")]
46    InvalidDateFormat(String),
47
48    #[error("Invalid parameter: {0}")]
49    InvalidParameter(String),
50
51    #[error("{0}")]
52    Other(String),
53}
54
55pub type Result<T> = std::result::Result<T, GarminError>;
56
57#[derive(Debug, PartialEq, Eq)]
58struct DbLockInfo {
59    path: Option<String>,
60    holder: Option<String>,
61    pid: Option<u32>,
62}
63
64fn parse_db_lock_info(message: &str) -> Option<DbLockInfo> {
65    let mut info = DbLockInfo {
66        path: None,
67        holder: None,
68        pid: None,
69    };
70
71    if message.contains("Could not set lock on file") {
72        let path_start = message.find("file \"").map(|idx| idx + 6);
73        let path_end =
74            path_start.and_then(|start| message[start..].find('"').map(|end| start + end));
75        if let (Some(start), Some(end)) = (path_start, path_end) {
76            info.path = Some(message[start..end].to_string());
77        }
78
79        let holder_start = message
80            .find("Conflicting lock is held in ")
81            .map(|idx| idx + 31);
82        let holder_end =
83            holder_start.and_then(|start| message[start..].find(" (PID ").map(|end| start + end));
84        if let (Some(start), Some(end)) = (holder_start, holder_end) {
85            info.holder = Some(message[start..end].to_string());
86        }
87
88        let pid_start = message.find("(PID ").map(|idx| idx + 5);
89        let pid_end = pid_start.and_then(|start| message[start..].find(')').map(|end| start + end));
90        if let (Some(start), Some(end)) = (pid_start, pid_end) {
91            info.pid = message[start..end].parse::<u32>().ok();
92        }
93
94        return Some(info);
95    }
96
97    if message.contains("database is locked") || message.contains("database is busy") {
98        return Some(info);
99    }
100
101    None
102}
103
104/// Provide a friendlier, user-focused error message when possible.
105pub fn format_user_error(error: &GarminError) -> String {
106    match error {
107        GarminError::Database(message) => {
108            if let Some(info) = parse_db_lock_info(message) {
109                let mut details = String::from(
110                    "Database is locked by another garmin process. If another sync is running, wait for it to finish or stop it, then retry.",
111                );
112
113                if let Some(pid) = info.pid {
114                    details.push_str(&format!(" (PID {})", pid));
115                }
116
117                if let Some(holder) = info.holder {
118                    details.push_str(&format!("\nLock holder: {}", holder));
119                }
120
121                if let Some(path) = info.path {
122                    details.push_str(&format!("\nLocked file: {}", path));
123                }
124
125                return details;
126            }
127            error.to_string()
128        }
129        _ => error.to_string(),
130    }
131}
132
133impl GarminError {
134    /// Create an authentication error from a message
135    pub fn auth(msg: impl Into<String>) -> Self {
136        Self::Authentication(msg.into())
137    }
138
139    /// Create a configuration error from a message
140    pub fn config(msg: impl Into<String>) -> Self {
141        Self::Config(msg.into())
142    }
143
144    /// Create an invalid response error from a message
145    pub fn invalid_response(msg: impl Into<String>) -> Self {
146        Self::InvalidResponse(msg.into())
147    }
148
149    /// Create an invalid parameter error from a message
150    pub fn invalid_param(msg: impl Into<String>) -> Self {
151        Self::InvalidParameter(msg.into())
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_error_display() {
161        let err = GarminError::Authentication("Invalid credentials".to_string());
162        assert_eq!(err.to_string(), "Authentication error: Invalid credentials");
163    }
164
165    #[test]
166    fn test_not_authenticated_error() {
167        let err = GarminError::NotAuthenticated;
168        assert!(err.to_string().contains("garmin auth login"));
169    }
170
171    #[test]
172    fn test_rate_limited_error() {
173        let err = GarminError::RateLimited;
174        assert!(err.to_string().contains("Rate limited"));
175    }
176
177    #[test]
178    fn test_invalid_date_format_error() {
179        let err = GarminError::InvalidDateFormat("not-a-date".to_string());
180        assert!(err.to_string().contains("not-a-date"));
181        assert!(err.to_string().contains("YYYY-MM-DD"));
182    }
183
184    #[test]
185    fn test_error_constructors() {
186        let auth_err = GarminError::auth("test auth");
187        assert!(matches!(auth_err, GarminError::Authentication(_)));
188
189        let config_err = GarminError::config("test config");
190        assert!(matches!(config_err, GarminError::Config(_)));
191
192        let response_err = GarminError::invalid_response("bad response");
193        assert!(matches!(response_err, GarminError::InvalidResponse(_)));
194
195        let param_err = GarminError::invalid_param("bad param");
196        assert!(matches!(param_err, GarminError::InvalidParameter(_)));
197    }
198
199    #[test]
200    fn test_format_user_error_duckdb_lock() {
201        let msg = "IO Error: Could not set lock on file \"/Users/vicente/Library/Application Support/garmin/garmin.duckdb\": Conflicting lock is held in /opt/homebrew/Cellar/garmin/1.0.5/bin/garmin (PID 31358) by user vicente.";
202        let err = GarminError::Database(msg.to_string());
203        let formatted = format_user_error(&err);
204
205        assert!(formatted.contains("Database is locked"));
206        assert!(formatted.contains("PID 31358"));
207        assert!(formatted.contains("garmin.duckdb"));
208    }
209
210    #[test]
211    fn test_format_user_error_sqlite_lock() {
212        let msg = "database is locked";
213        let err = GarminError::Database(msg.to_string());
214        let formatted = format_user_error(&err);
215
216        assert!(formatted.contains("Database is locked"));
217    }
218}