1use crate::client::GarminClient;
4use crate::config::CredentialStore;
5use crate::error::{GarminError, Result};
6use crate::models::ActivitySummary;
7use std::path::Path;
8
9use super::auth::refresh_token;
10
11pub async fn list(limit: u32, start: u32, profile: Option<String>) -> Result<()> {
13 let store = CredentialStore::new(profile)?;
14 let (_, oauth2) = refresh_token(&store).await?;
15
16 let client = GarminClient::new();
17 let path = format!(
18 "/activitylist-service/activities/search/activities?limit={}&start={}",
19 limit, start
20 );
21
22 let activities: Vec<ActivitySummary> = client.get_json(&oauth2, &path).await?;
23
24 if activities.is_empty() {
25 println!("No activities found.");
26 return Ok(());
27 }
28
29 println!(
31 "{:<12} {:<10} {:<15} {:>10} {:>12} {:>8}",
32 "ID", "Date", "Type", "Distance", "Duration", "HR"
33 );
34 println!("{}", "-".repeat(75));
35
36 for activity in &activities {
38 let distance = activity
39 .distance_km()
40 .map(|d| format!("{:.2} km", d))
41 .unwrap_or_else(|| "-".to_string());
42
43 let hr = activity
44 .average_hr
45 .map(|h| format!("{:.0}", h))
46 .unwrap_or_else(|| "-".to_string());
47
48 println!(
49 "{:<12} {:<10} {:<15} {:>10} {:>12} {:>8}",
50 activity.activity_id,
51 activity.date(),
52 truncate(&activity.type_key(), 15),
53 distance,
54 activity.duration_formatted(),
55 hr
56 );
57 }
58
59 println!("\nShowing {} activities", activities.len());
60
61 Ok(())
62}
63
64pub async fn get(id: u64, profile: Option<String>) -> Result<()> {
66 let store = CredentialStore::new(profile)?;
67 let (_, oauth2) = refresh_token(&store).await?;
68
69 let client = GarminClient::new();
70 let path = format!("/activity-service/activity/{}", id);
71
72 let activity: serde_json::Value = client.get_json(&oauth2, &path).await?;
73
74 println!("{}", serde_json::to_string_pretty(&activity)?);
76
77 Ok(())
78}
79
80pub async fn note_get(id: u64, profile: Option<String>) -> Result<()> {
84 let store = CredentialStore::new(profile)?;
85 let (_, oauth2) = refresh_token(&store).await?;
86
87 let client = GarminClient::new();
88 let path = format!("/activity-service/activity/{}", id);
89
90 let activity: serde_json::Value = client.get_json(&oauth2, &path).await?;
91
92 if let Some(note) = activity.get("description").and_then(|v| v.as_str()) {
93 let note = note.trim();
94 if !note.is_empty() {
95 println!("{}", note);
96 }
97 }
98
99 Ok(())
100}
101
102pub async fn note_set(id: u64, note: &str, profile: Option<String>) -> Result<()> {
104 set_description(id, note, profile).await
105}
106
107pub async fn note_clear(id: u64, profile: Option<String>) -> Result<()> {
109 set_description(id, "", profile).await
110}
111
112async fn set_description(id: u64, description: &str, profile: Option<String>) -> Result<()> {
114 let store = CredentialStore::new(profile)?;
115 let (_, oauth2) = refresh_token(&store).await?;
116
117 let client = GarminClient::new();
118 let path = format!("/activity-service/activity/{}", id);
119 let body = serde_json::json!({
120 "activityId": id,
121 "description": description,
122 });
123
124 client.put_json(&oauth2, &path, &body).await?;
125
126 if description.is_empty() {
127 println!("Cleared note for activity {}.", id);
128 } else {
129 println!("Updated note for activity {}.", id);
130 }
131
132 Ok(())
133}
134
135pub async fn download(
137 id: u64,
138 format: &str,
139 output: Option<String>,
140 profile: Option<String>,
141) -> Result<()> {
142 let store = CredentialStore::new(profile)?;
143 let (_, oauth2) = refresh_token(&store).await?;
144
145 let client = GarminClient::new();
146
147 let format = format.to_lowercase();
148 let (path, extension) = download_endpoint(id, &format)?;
149
150 println!(
151 "Downloading activity {} as {}...",
152 id,
153 format.to_uppercase()
154 );
155
156 let bytes = client.download(&oauth2, &path).await?;
157
158 let output_path = output.unwrap_or_else(|| format!("activity_{}.{}", id, extension));
160
161 tokio::fs::write(&output_path, &bytes)
163 .await
164 .map_err(|e| GarminError::invalid_response(format!("Failed to write file: {}", e)))?;
165
166 println!("Saved to: {}", output_path);
167 println!("Size: {} bytes", bytes.len());
168
169 Ok(())
170}
171
172fn download_endpoint(id: u64, format: &str) -> Result<(String, &'static str)> {
173 match format {
174 "fit" => Ok((
175 format!("/download-service/files/activity/{}", id),
176 "fit.zip",
177 )),
178 "gpx" => Ok((
179 format!("/download-service/export/gpx/activity/{}", id),
180 "gpx",
181 )),
182 "tcx" => Ok((
183 format!("/download-service/export/tcx/activity/{}", id),
184 "tcx",
185 )),
186 "kml" => Ok((
187 format!("/download-service/export/kml/activity/{}", id),
188 "kml",
189 )),
190 _ => Err(GarminError::invalid_response(format!(
191 "Unknown format: {}. Supported: fit, gpx, tcx, kml",
192 format
193 ))),
194 }
195}
196
197pub async fn upload(file: &str, profile: Option<String>) -> Result<()> {
199 let store = CredentialStore::new(profile)?;
200 let (_, oauth2) = refresh_token(&store).await?;
201
202 let file_path = Path::new(file);
203
204 if !file_path.exists() {
205 return Err(GarminError::invalid_response(format!(
206 "File not found: {}",
207 file
208 )));
209 }
210
211 let client = GarminClient::new();
212
213 println!("Uploading {}...", file);
214
215 let result = client
216 .upload(&oauth2, "/upload-service/upload/.fit", file_path)
217 .await?;
218
219 if let Some(detailed) = result.get("detailedImportResult") {
221 if let Some(successes) = detailed.get("successes").and_then(|s| s.as_array()) {
222 if !successes.is_empty() {
223 println!("Upload successful!");
224 for success in successes {
225 if let Some(id) = success.get("internalId").and_then(|i| i.as_u64()) {
226 println!("Created activity ID: {}", id);
227 }
228 }
229 return Ok(());
230 }
231 }
232
233 if let Some(failures) = detailed.get("failures").and_then(|f| f.as_array()) {
234 if !failures.is_empty() {
235 println!("Upload had failures:");
236 for failure in failures {
237 println!(" {}", failure);
238 }
239 }
240 }
241 }
242
243 println!("Response: {}", serde_json::to_string_pretty(&result)?);
245
246 Ok(())
247}
248
249fn truncate(s: &str, max_len: usize) -> String {
251 if s.len() <= max_len {
252 s.to_string()
253 } else {
254 format!("{}...", &s[..max_len - 3])
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn test_truncate() {
264 assert_eq!(truncate("short", 10), "short");
265 assert_eq!(truncate("very long string here", 10), "very lo...");
266 }
267
268 #[test]
269 fn test_download_endpoint_names_garmin_fit_archive() {
270 let (path, extension) = download_endpoint(123, "fit").unwrap();
271
272 assert_eq!(path, "/download-service/files/activity/123");
273 assert_eq!(extension, "fit.zip");
274 }
275
276 #[test]
277 fn test_download_endpoint_rejects_unknown_format() {
278 let err = download_endpoint(123, "csv").unwrap_err();
279
280 assert!(err.to_string().contains("Unknown format: csv"));
281 }
282}