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 (oauth1, oauth2) = refresh_token(&store).await?;
15
16    let client = GarminClient::new(&oauth1.domain);
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 (oauth1, oauth2) = refresh_token(&store).await?;
68
69    let client = GarminClient::new(&oauth1.domain);
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/// Download activity file
81pub async fn download(
82    id: u64,
83    format: &str,
84    output: Option<String>,
85    profile: Option<String>,
86) -> Result<()> {
87    let store = CredentialStore::new(profile)?;
88    let (oauth1, oauth2) = refresh_token(&store).await?;
89
90    let client = GarminClient::new(&oauth1.domain);
91
92    // Build path based on format
93    let (path, extension) = match format.to_lowercase().as_str() {
94        "fit" => (format!("/download-service/files/activity/{}", id), "zip"),
95        "gpx" => (
96            format!("/download-service/export/gpx/activity/{}", id),
97            "gpx",
98        ),
99        "tcx" => (
100            format!("/download-service/export/tcx/activity/{}", id),
101            "tcx",
102        ),
103        "kml" => (
104            format!("/download-service/export/kml/activity/{}", id),
105            "kml",
106        ),
107        _ => {
108            return Err(GarminError::invalid_response(format!(
109                "Unknown format: {}. Supported: fit, gpx, tcx, kml",
110                format
111            )));
112        }
113    };
114
115    println!(
116        "Downloading activity {} as {}...",
117        id,
118        format.to_uppercase()
119    );
120
121    let bytes = client.download(&oauth2, &path).await?;
122
123    // Determine output path
124    let output_path = output.unwrap_or_else(|| format!("activity_{}.{}", id, extension));
125
126    // Write to file
127    tokio::fs::write(&output_path, &bytes)
128        .await
129        .map_err(|e| GarminError::invalid_response(format!("Failed to write file: {}", e)))?;
130
131    println!("Saved to: {}", output_path);
132    println!("Size: {} bytes", bytes.len());
133
134    Ok(())
135}
136
137/// Upload activity file
138pub async fn upload(file: &str, profile: Option<String>) -> Result<()> {
139    let store = CredentialStore::new(profile)?;
140    let (oauth1, oauth2) = refresh_token(&store).await?;
141
142    let file_path = Path::new(file);
143
144    if !file_path.exists() {
145        return Err(GarminError::invalid_response(format!(
146            "File not found: {}",
147            file
148        )));
149    }
150
151    let client = GarminClient::new(&oauth1.domain);
152
153    println!("Uploading {}...", file);
154
155    let result = client
156        .upload(&oauth2, "/upload-service/upload/.fit", file_path)
157        .await?;
158
159    // Try to extract useful info from response
160    if let Some(detailed) = result.get("detailedImportResult") {
161        if let Some(successes) = detailed.get("successes").and_then(|s| s.as_array()) {
162            if !successes.is_empty() {
163                println!("Upload successful!");
164                for success in successes {
165                    if let Some(id) = success.get("internalId").and_then(|i| i.as_u64()) {
166                        println!("Created activity ID: {}", id);
167                    }
168                }
169                return Ok(());
170            }
171        }
172
173        if let Some(failures) = detailed.get("failures").and_then(|f| f.as_array()) {
174            if !failures.is_empty() {
175                println!("Upload had failures:");
176                for failure in failures {
177                    println!("  {}", failure);
178                }
179            }
180        }
181    }
182
183    // Fallback: print full response
184    println!("Response: {}", serde_json::to_string_pretty(&result)?);
185
186    Ok(())
187}
188
189/// Truncate string to max length
190fn truncate(s: &str, max_len: usize) -> String {
191    if s.len() <= max_len {
192        s.to_string()
193    } else {
194        format!("{}...", &s[..max_len - 3])
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn test_truncate() {
204        assert_eq!(truncate("short", 10), "short");
205        assert_eq!(truncate("very long string here", 10), "very lo...");
206    }
207}