Skip to main content

garmin_cli/cli/commands/
sync.rs

1//! Sync commands for garmin-cli
2
3use chrono::NaiveDate;
4use std::path::Path;
5
6use crate::client::GarminClient;
7use crate::config::CredentialStore;
8use crate::error::Result;
9use crate::storage::{default_storage_path, Storage, SyncDb};
10use crate::sync::progress::SyncMode;
11use crate::sync::{SyncEngine, SyncOptions, TaskQueue};
12
13use super::auth::refresh_token;
14
15/// Run sync operation
16#[allow(clippy::too_many_arguments)]
17pub async fn run(
18    profile: Option<String>,
19    storage_path: Option<String>,
20    activities: bool,
21    health: bool,
22    performance: bool,
23    from: Option<String>,
24    to: Option<String>,
25    dry_run: bool,
26    backfill: bool,
27    force: bool,
28) -> Result<()> {
29    let store = CredentialStore::new(profile.clone())?;
30    let (oauth1, oauth2) = refresh_token(&store).await?;
31
32    // Open storage
33    let storage_path = storage_path
34        .map(std::path::PathBuf::from)
35        .unwrap_or_else(default_storage_path);
36
37    let storage = Storage::open(storage_path)?;
38
39    // Determine sync mode
40    let mode = if backfill {
41        SyncMode::Backfill
42    } else {
43        SyncMode::Latest
44    };
45
46    // Build sync options
47    let sync_all = !activities && !health && !performance;
48    let opts = SyncOptions {
49        sync_activities: activities || sync_all,
50        sync_health: health || sync_all,
51        sync_performance: performance || sync_all,
52        from_date: from.as_ref().and_then(|s| parse_date(s)),
53        to_date: to.as_ref().and_then(|s| parse_date(s)),
54        dry_run,
55        force,
56        concurrency: 4,
57        mode,
58    };
59
60    // Create sync engine
61    let client = GarminClient::new(&oauth1.domain);
62    let mut engine = SyncEngine::with_storage(storage, client, oauth2)?;
63    engine.run(opts).await?;
64
65    Ok(())
66}
67
68/// Show sync status
69pub async fn status(profile: Option<String>, storage_path: Option<String>) -> Result<()> {
70    let storage_path = storage_path
71        .map(std::path::PathBuf::from)
72        .unwrap_or_else(default_storage_path);
73
74    if !storage_path.exists() {
75        println!("No storage found at: {}", storage_path.display());
76        println!("Run 'garmin sync run' to create one.");
77        return Ok(());
78    }
79
80    let sync_db_path = storage_path.join("sync.db");
81    if !sync_db_path.exists() {
82        println!("No sync database found at: {}", sync_db_path.display());
83        println!("Run 'garmin sync run' to create one.");
84        return Ok(());
85    }
86
87    let sync_db = SyncDb::open(&sync_db_path)?;
88
89    // Resolve profile for status reporting.
90    // CLI profile names are credential profiles and may not match Garmin display_name in sync.db.
91    let requested_profile = profile.as_deref();
92    let mut profile_note: Option<String> = None;
93    let (profile_name, profile_id) = match requested_profile {
94        Some(name) => match sync_db.get_profile_id(name)? {
95            Some(id) => (name.to_string(), Some(id)),
96            None => match sync_db.get_latest_profile()? {
97                Some((id, resolved_name)) => {
98                    profile_note = Some(format!(
99                        "Requested profile '{}' not found in sync database; showing latest synced profile '{}'.",
100                        name, resolved_name
101                    ));
102                    (resolved_name, Some(id))
103                }
104                None => {
105                    profile_note = Some(format!(
106                        "Requested profile '{}' not found in sync database.",
107                        name
108                    ));
109                    (name.to_string(), None)
110                }
111            },
112        },
113        None => match sync_db.get_latest_profile()? {
114            Some((id, resolved_name)) => (resolved_name, Some(id)),
115            None => ("default".to_string(), None),
116        },
117    };
118
119    // Count Parquet files
120    let activity_files = count_partition_files(&storage_path, "activities");
121    let health_files = count_partition_files(&storage_path, "daily_health");
122    let performance_files = count_partition_files(&storage_path, "performance_metrics");
123    let track_files = count_partition_files(&storage_path, "track_points");
124
125    // Get pending tasks
126    let pending_count = if let Some(pid) = profile_id {
127        sync_db.count_pending_tasks(pid, None)?
128    } else {
129        0
130    };
131
132    println!("Storage: {}", storage_path.display());
133    println!("Profile: {}", profile_name);
134    if let Some(note) = profile_note {
135        println!("Note: {}", note);
136    }
137    println!();
138    println!("Parquet files:");
139    println!("  Activity partitions:    {:>4}", activity_files);
140    println!("  Health partitions:      {:>4}", health_files);
141    println!("  Performance partitions: {:>4}", performance_files);
142    println!("  Track point partitions: {:>4}", track_files);
143    println!();
144    if pending_count > 0 {
145        println!("Pending sync tasks: {}", pending_count);
146    }
147
148    Ok(())
149}
150
151/// Parse date string to NaiveDate
152fn parse_date(s: &str) -> Option<NaiveDate> {
153    NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()
154}
155
156fn count_partition_files(storage_path: &Path, dirname: &str) -> usize {
157    let partition_path = storage_path.join(dirname);
158    if !partition_path.exists() {
159        return 0;
160    }
161
162    std::fs::read_dir(&partition_path)
163        .map(|entries| entries.filter(|entry| entry.is_ok()).count())
164        .unwrap_or(0)
165}
166
167/// Reset failed tasks to pending
168pub async fn reset(storage_path: Option<String>) -> Result<()> {
169    let storage_path = storage_path
170        .map(std::path::PathBuf::from)
171        .unwrap_or_else(default_storage_path);
172
173    let sync_db_path = storage_path.join("sync.db");
174    if !sync_db_path.exists() {
175        println!("No sync database found at: {}", sync_db_path.display());
176        return Ok(());
177    }
178
179    let sync_db = SyncDb::open(&sync_db_path)?;
180    let queue = TaskQueue::new(sync_db, 1, None); // profile_id doesn't matter for reset
181
182    let reset_count = queue.reset_failed()?;
183    println!("Reset {} failed tasks to pending", reset_count);
184
185    Ok(())
186}
187
188/// Clear all pending tasks
189pub async fn clear(storage_path: Option<String>) -> Result<()> {
190    let storage_path = storage_path
191        .map(std::path::PathBuf::from)
192        .unwrap_or_else(default_storage_path);
193
194    let sync_db_path = storage_path.join("sync.db");
195    if !sync_db_path.exists() {
196        println!("No sync database found at: {}", sync_db_path.display());
197        return Ok(());
198    }
199
200    let sync_db = SyncDb::open(&sync_db_path)?;
201    let queue = TaskQueue::new(sync_db, 1, None); // profile_id doesn't matter for clear
202
203    let cleared = queue.clear_pending()?;
204    println!("Cleared {} pending tasks", cleared);
205
206    Ok(())
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn count_partition_files_returns_zero_for_missing_directory() {
215        let temp_dir = tempfile::tempdir().unwrap();
216        assert_eq!(count_partition_files(temp_dir.path(), "activities"), 0);
217    }
218
219    #[test]
220    fn count_partition_files_counts_existing_entries() {
221        let temp_dir = tempfile::tempdir().unwrap();
222        let activities_dir = temp_dir.path().join("activities");
223        std::fs::create_dir(&activities_dir).unwrap();
224        std::fs::write(activities_dir.join("2026-W10.parquet"), b"test").unwrap();
225        std::fs::write(activities_dir.join("2026-W11.parquet"), b"test").unwrap();
226
227        assert_eq!(count_partition_files(temp_dir.path(), "activities"), 2);
228    }
229}