1use thiserror::Error;
2
3#[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
104pub 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 pub fn auth(msg: impl Into<String>) -> Self {
136 Self::Authentication(msg.into())
137 }
138
139 pub fn config(msg: impl Into<String>) -> Self {
141 Self::Config(msg.into())
142 }
143
144 pub fn invalid_response(msg: impl Into<String>) -> Self {
146 Self::InvalidResponse(msg.into())
147 }
148
149 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}