Skip to main content

garmin_cli/cli/commands/
activities.rs

1//! Activity commands for garmin-cli
2
3use 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
11/// List activities
12pub 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    // Print header
30    println!(
31        "{:<12} {:<10} {:<15} {:>10} {:>12} {:>8}",
32        "ID", "Date", "Type", "Distance", "Duration", "HR"
33    );
34    println!("{}", "-".repeat(75));
35
36    // Print each activity
37    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
64/// Get activity details
65pub 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    // Pretty print the JSON
75    println!("{}", serde_json::to_string_pretty(&activity)?);
76
77    Ok(())
78}
79
80/// Print an activity's note. Garmin's mobile app calls this a "note"; the API
81/// stores it as the top-level `description` field. Prints only the note text to
82/// stdout (script-friendly), or nothing when no note is set.
83pub 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
102/// Set an activity's note (Garmin activity `description`).
103pub async fn note_set(id: u64, note: &str, profile: Option<String>) -> Result<()> {
104    set_description(id, note, profile).await
105}
106
107/// Clear an activity's note (Garmin activity `description`).
108pub async fn note_clear(id: u64, profile: Option<String>) -> Result<()> {
109    set_description(id, "", profile).await
110}
111
112/// PUT the activity description. An empty string clears the note.
113async 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
135/// Download activity file
136pub 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    // Determine output path
159    let output_path = output.unwrap_or_else(|| format!("activity_{}.{}", id, extension));
160
161    // Write to file
162    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
197/// Upload activity file
198pub 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    // Try to extract useful info from response
220    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    // Fallback: print full response
244    println!("Response: {}", serde_json::to_string_pretty(&result)?);
245
246    Ok(())
247}
248
249/// Truncate string to max length
250fn 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}