garmin_cli/storage/mod.rs
1//! Storage layer for Garmin data
2//!
3//! This module provides time-partitioned Parquet storage for Garmin data,
4//! enabling concurrent read access during sync operations.
5//!
6//! ## Architecture
7//!
8//! - **Parquet files**: Time-partitioned storage for activities, health, performance, etc.
9//! - **SQLite**: Sync state and task queue for operational data
10//!
11//! ## Storage Layout
12//!
13//! ```text
14//! ~/.local/share/garmin/
15//! ├── sync.db # SQLite for sync state + task queue
16//! ├── profiles.parquet # Single file (small, rarely changes)
17//! ├── activities/
18//! │ ├── 2024-W48.parquet # Weekly partitions
19//! │ └── ...
20//! ├── track_points/
21//! │ ├── 2024-12-01.parquet # Daily partitions
22//! │ └── ...
23//! ├── daily_health/
24//! │ ├── 2024-12.parquet # Monthly partitions
25//! │ └── ...
26//! ├── performance_metrics/
27//! │ ├── 2024-12.parquet # Monthly partitions
28//! │ └── ...
29//! └── weight/
30//! ├── 2024-12.parquet # Monthly partitions
31//! └── ...
32//! ```
33//!
34//! ## Concurrent Access
35//!
36//! Parquet files are written atomically (temp file + rename), so readers always
37//! see consistent data. External apps can query data using DuckDB:
38//!
39//! ```sql
40//! SELECT * FROM 'activities/*.parquet' WHERE start_time_local > '2024-12-01';
41//! ```
42
43mod parquet;
44mod partitions;
45mod sync_db;
46
47pub use parquet::ParquetStore;
48pub use partitions::EntityType;
49pub use sync_db::SyncDb;
50
51use std::path::PathBuf;
52
53use crate::error::Result;
54
55/// Get the default storage path
56pub fn default_storage_path() -> PathBuf {
57 dirs::data_local_dir()
58 .unwrap_or_else(|| PathBuf::from("."))
59 .join("garmin")
60}
61
62/// Get the default sync database path
63pub fn default_sync_db_path() -> PathBuf {
64 default_storage_path().join("sync.db")
65}
66
67/// Storage manager combining Parquet store and sync database
68pub struct Storage {
69 pub parquet: ParquetStore,
70 pub sync_db: SyncDb,
71}
72
73impl Storage {
74 /// Open storage at the default location
75 pub fn open_default() -> Result<Self> {
76 let base_path = default_storage_path();
77 Self::open(base_path)
78 }
79
80 /// Open storage at a custom location
81 pub fn open(base_path: PathBuf) -> Result<Self> {
82 std::fs::create_dir_all(&base_path).map_err(|e| {
83 crate::error::GarminError::Database(format!(
84 "Failed to create storage directory: {}",
85 e
86 ))
87 })?;
88
89 let parquet = ParquetStore::new(&base_path);
90 let sync_db = SyncDb::open(base_path.join("sync.db"))?;
91
92 Ok(Self { parquet, sync_db })
93 }
94
95 /// Open in-memory storage (for testing)
96 pub fn open_in_memory(temp_path: PathBuf) -> Result<Self> {
97 std::fs::create_dir_all(&temp_path).map_err(|e| {
98 crate::error::GarminError::Database(format!("Failed to create temp directory: {}", e))
99 })?;
100
101 let parquet = ParquetStore::new(&temp_path);
102 let sync_db = SyncDb::open_in_memory()?;
103
104 Ok(Self { parquet, sync_db })
105 }
106
107 /// Create a new storage handle that shares the Parquet store but opens a new SyncDb connection.
108 pub fn clone_with_new_db(&self) -> Result<Self> {
109 let parquet = self.parquet.clone();
110 let sync_db = SyncDb::open(self.base_path().join("sync.db"))?;
111 Ok(Self { parquet, sync_db })
112 }
113
114 /// Get the base path for external readers
115 pub fn base_path(&self) -> &std::path::Path {
116 self.parquet.base_path()
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use tempfile::TempDir;
124
125 #[test]
126 fn test_storage_open() {
127 let temp = TempDir::new().unwrap();
128 let storage = Storage::open(temp.path().to_path_buf()).unwrap();
129 assert!(storage.base_path().exists());
130 }
131}