use crate::client::GarminClient;
use crate::config::CredentialStore;
use crate::error::{GarminError, Result};
use crate::models::ActivitySummary;
use std::path::Path;
use super::auth::refresh_token;
pub async fn list(limit: u32, start: u32, profile: Option<String>) -> Result<()> {
let store = CredentialStore::new(profile)?;
let (_, oauth2) = refresh_token(&store).await?;
let client = GarminClient::new();
let path = format!(
"/activitylist-service/activities/search/activities?limit={}&start={}",
limit, start
);
let activities: Vec<ActivitySummary> = client.get_json(&oauth2, &path).await?;
if activities.is_empty() {
println!("No activities found.");
return Ok(());
}
println!(
"{:<12} {:<10} {:<15} {:>10} {:>12} {:>8}",
"ID", "Date", "Type", "Distance", "Duration", "HR"
);
println!("{}", "-".repeat(75));
for activity in &activities {
let distance = activity
.distance_km()
.map(|d| format!("{:.2} km", d))
.unwrap_or_else(|| "-".to_string());
let hr = activity
.average_hr
.map(|h| format!("{:.0}", h))
.unwrap_or_else(|| "-".to_string());
println!(
"{:<12} {:<10} {:<15} {:>10} {:>12} {:>8}",
activity.activity_id,
activity.date(),
truncate(&activity.type_key(), 15),
distance,
activity.duration_formatted(),
hr
);
}
println!("\nShowing {} activities", activities.len());
Ok(())
}
pub async fn get(id: u64, profile: Option<String>) -> Result<()> {
let store = CredentialStore::new(profile)?;
let (_, oauth2) = refresh_token(&store).await?;
let client = GarminClient::new();
let path = format!("/activity-service/activity/{}", id);
let activity: serde_json::Value = client.get_json(&oauth2, &path).await?;
println!("{}", serde_json::to_string_pretty(&activity)?);
Ok(())
}
pub async fn note_get(id: u64, profile: Option<String>) -> Result<()> {
let store = CredentialStore::new(profile)?;
let (_, oauth2) = refresh_token(&store).await?;
let client = GarminClient::new();
let path = format!("/activity-service/activity/{}", id);
let activity: serde_json::Value = client.get_json(&oauth2, &path).await?;
if let Some(note) = activity.get("description").and_then(|v| v.as_str()) {
let note = note.trim();
if !note.is_empty() {
println!("{}", note);
}
}
Ok(())
}
pub async fn note_set(id: u64, note: &str, profile: Option<String>) -> Result<()> {
set_description(id, note, profile).await
}
pub async fn note_clear(id: u64, profile: Option<String>) -> Result<()> {
set_description(id, "", profile).await
}
async fn set_description(id: u64, description: &str, profile: Option<String>) -> Result<()> {
let store = CredentialStore::new(profile)?;
let (_, oauth2) = refresh_token(&store).await?;
let client = GarminClient::new();
let path = format!("/activity-service/activity/{}", id);
let body = serde_json::json!({
"activityId": id,
"description": description,
});
client.put_json(&oauth2, &path, &body).await?;
if description.is_empty() {
println!("Cleared note for activity {}.", id);
} else {
println!("Updated note for activity {}.", id);
}
Ok(())
}
pub async fn download(
id: u64,
format: &str,
output: Option<String>,
profile: Option<String>,
) -> Result<()> {
let store = CredentialStore::new(profile)?;
let (_, oauth2) = refresh_token(&store).await?;
let client = GarminClient::new();
let format = format.to_lowercase();
let (path, extension) = download_endpoint(id, &format)?;
println!(
"Downloading activity {} as {}...",
id,
format.to_uppercase()
);
let bytes = client.download(&oauth2, &path).await?;
let output_path = output.unwrap_or_else(|| format!("activity_{}.{}", id, extension));
tokio::fs::write(&output_path, &bytes)
.await
.map_err(|e| GarminError::invalid_response(format!("Failed to write file: {}", e)))?;
println!("Saved to: {}", output_path);
println!("Size: {} bytes", bytes.len());
Ok(())
}
fn download_endpoint(id: u64, format: &str) -> Result<(String, &'static str)> {
match format {
"fit" => Ok((
format!("/download-service/files/activity/{}", id),
"fit.zip",
)),
"gpx" => Ok((
format!("/download-service/export/gpx/activity/{}", id),
"gpx",
)),
"tcx" => Ok((
format!("/download-service/export/tcx/activity/{}", id),
"tcx",
)),
"kml" => Ok((
format!("/download-service/export/kml/activity/{}", id),
"kml",
)),
_ => Err(GarminError::invalid_response(format!(
"Unknown format: {}. Supported: fit, gpx, tcx, kml",
format
))),
}
}
pub async fn upload(file: &str, profile: Option<String>) -> Result<()> {
let store = CredentialStore::new(profile)?;
let (_, oauth2) = refresh_token(&store).await?;
let file_path = Path::new(file);
if !file_path.exists() {
return Err(GarminError::invalid_response(format!(
"File not found: {}",
file
)));
}
let client = GarminClient::new();
println!("Uploading {}...", file);
let result = client
.upload(&oauth2, "/upload-service/upload/.fit", file_path)
.await?;
if let Some(detailed) = result.get("detailedImportResult") {
if let Some(successes) = detailed.get("successes").and_then(|s| s.as_array()) {
if !successes.is_empty() {
println!("Upload successful!");
for success in successes {
if let Some(id) = success.get("internalId").and_then(|i| i.as_u64()) {
println!("Created activity ID: {}", id);
}
}
return Ok(());
}
}
if let Some(failures) = detailed.get("failures").and_then(|f| f.as_array()) {
if !failures.is_empty() {
println!("Upload had failures:");
for failure in failures {
println!(" {}", failure);
}
}
}
}
println!("Response: {}", serde_json::to_string_pretty(&result)?);
Ok(())
}
fn truncate(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len - 3])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_truncate() {
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("very long string here", 10), "very lo...");
}
#[test]
fn test_download_endpoint_names_garmin_fit_archive() {
let (path, extension) = download_endpoint(123, "fit").unwrap();
assert_eq!(path, "/download-service/files/activity/123");
assert_eq!(extension, "fit.zip");
}
#[test]
fn test_download_endpoint_rejects_unknown_format() {
let err = download_endpoint(123, "csv").unwrap_err();
assert!(err.to_string().contains("Unknown format: csv"));
}
}