From 47a2a3f8f13444e7f7120b49221bf03afbf721a2 Mon Sep 17 00:00:00 2001 From: Bart Trojanowski Date: Fri, 4 Sep 2026 18:43:57 +0000 Subject: [PATCH 1/4] Add DateScan helpers for cache-aware date listing resolve_date_scan turns a scan-days policy into Full, CacheOnly, or a hybrid [oldest, latest] window. latest_cached_date is the newest cache filename. These are pure so get_dates can skip the full jrange walk. --- src/workouts.rs | 69 ++++++++++++++++++++++++ tests/test_date_utils.rs | 110 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 2 deletions(-) diff --git a/src/workouts.rs b/src/workouts.rs index dccdec1..2004d7e 100644 --- a/src/workouts.rs +++ b/src/workouts.rs @@ -247,6 +247,75 @@ pub fn get_dates_from_cache(uid: u32, latest: Option, oldest: Option Option { + let dates = get_dates_from_cache(uid, None, None, 1, false).ok()?; + dates.first().and_then(|s| parse_ymd(s)) +} + +/// How `get_dates` should combine cache and network listing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DateScan { + /// Walk the requested range (or full history) via `jrange`. + Full, + /// Use cached dates only. + CacheOnly, + /// Union cached dates with a bounded network scan of `[oldest, latest]`. + Hybrid { oldest: NaiveDate, latest: NaiveDate }, +} + +/// Decide how to list workout dates given `-s/--scan-days`. +/// +/// - `scan_days < 0`: full network listing +/// - `scan_days == 0`: scan from last cached date through `today` (full listing if cache is empty or disabled) +/// - `scan_days > 0`: scan `[today - scan_days, today]` +pub fn resolve_date_scan( + scan_days: i32, + use_cache: bool, + last_cached: Option, + today: NaiveDate, + range_oldest: Option, + range_latest: Option, +) -> DateScan { + if scan_days < 0 { + return DateScan::Full; + } + + if scan_days == 0 && (!use_cache || last_cached.is_none()) { + return DateScan::Full; + } + + if scan_days == 0 && last_cached.is_some_and(|lc| lc >= today) { + return DateScan::CacheOnly; + } + + let window_oldest = if scan_days == 0 { + last_cached.unwrap_or(today) + } else { + today + .checked_sub_signed(Duration::days(scan_days as i64)) + .unwrap_or_else(|| NaiveDate::from_ymd_opt(1, 1, 1).unwrap()) + }; + + let mut oldest = window_oldest; + let mut latest = today; + if let Some(ro) = range_oldest { + oldest = oldest.max(ro); + } + if let Some(rl) = range_latest { + latest = latest.min(rl); + } + if latest > today { + latest = today; + } + + if latest < oldest { + DateScan::CacheOnly + } else { + DateScan::Hybrid { oldest, latest } + } +} + pub fn lookup_cached_jday(uid: u32, date: &str, verbose: bool) -> Option { if let Some(content) = read_cached_jday_text(uid, date) { // Use cached user preference to parse the cached workout diff --git a/tests/test_date_utils.rs b/tests/test_date_utils.rs index f61525d..b3d1d15 100644 --- a/tests/test_date_utils.rs +++ b/tests/test_date_utils.rs @@ -1,6 +1,10 @@ -use chrono::NaiveDate; +use chrono::{Duration, NaiveDate}; use wxrust::utils::{parse_date_boundary, parse_date_range}; -use wxrust::workouts::{jrange_windows, JRANGE_MAX_WEEKS}; +use wxrust::workouts::{jrange_windows, resolve_date_scan, DateScan, JRANGE_MAX_WEEKS}; + +fn ymd(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} #[test] fn test_parse_date_boundary_full_date() { @@ -211,3 +215,105 @@ fn test_jrange_windows_year_needs_two() { let first_start = latest - chrono::Duration::days(JRANGE_MAX_WEEKS as i64 * 7); assert_eq!(windows[1].0, first_start.format("%Y-%m-%d").to_string()); } + +#[test] +fn test_resolve_date_scan_negative_is_full() { + let today = ymd(2026, 9, 4); + assert_eq!( + resolve_date_scan(-1, true, Some(today), today, None, None), + DateScan::Full + ); +} + +#[test] +fn test_resolve_date_scan_zero_without_cache_is_full() { + let today = ymd(2026, 9, 4); + assert_eq!( + resolve_date_scan(0, false, Some(today), today, None, None), + DateScan::Full + ); + assert_eq!( + resolve_date_scan(0, true, None, today, None, None), + DateScan::Full + ); +} + +#[test] +fn test_resolve_date_scan_zero_skips_when_current() { + let today = ymd(2026, 9, 4); + assert_eq!( + resolve_date_scan(0, true, Some(today), today, None, None), + DateScan::CacheOnly + ); +} + +#[test] +fn test_resolve_date_scan_zero_since_last_cached() { + let today = ymd(2026, 9, 4); + let last = ymd(2026, 8, 20); + assert_eq!( + resolve_date_scan(0, true, Some(last), today, None, None), + DateScan::Hybrid { oldest: last, latest: today } + ); +} + +#[test] +fn test_resolve_date_scan_seven_days() { + let today = ymd(2026, 9, 4); + assert_eq!( + resolve_date_scan(7, true, Some(today), today, None, None), + DateScan::Hybrid { + oldest: today - Duration::days(7), + latest: today, + } + ); +} + +#[test] +fn test_resolve_date_scan_seven_without_cache() { + let today = ymd(2026, 9, 4); + assert_eq!( + resolve_date_scan(7, false, None, today, None, None), + DateScan::Hybrid { + oldest: today - Duration::days(7), + latest: today, + } + ); +} + +#[test] +fn test_resolve_date_scan_window_misses_historical_range() { + let today = ymd(2026, 9, 4); + let last = ymd(2026, 9, 1); + assert_eq!( + resolve_date_scan( + 0, + true, + Some(last), + today, + Some(ymd(2020, 1, 1)), + Some(ymd(2020, 12, 31)), + ), + DateScan::CacheOnly + ); +} + +#[test] +fn test_resolve_date_scan_intersects_requested_range() { + let today = ymd(2026, 9, 4); + let last = ymd(2026, 1, 15); + assert_eq!( + resolve_date_scan( + 0, + true, + Some(last), + today, + Some(ymd(2026, 1, 1)), + Some(ymd(2026, 12, 31)), + ), + DateScan::Hybrid { + oldest: last, + latest: today, + } + ); +} From 28a0047c0049192e769746cb6bbf4132502b8ea8 Mon Sep 17 00:00:00 2001 From: Bart Trojanowski Date: Fri, 4 Sep 2026 18:44:01 +0000 Subject: [PATCH 2/4] Scan recent days for new workouts instead of full history Default scan_days=0 lists dates from cache and only networks from the last cached day through today, so table/list/fetch stay local on a warm cache. -s N scans [today-N, today]; -s -1 restores sequential jrange. Empty cache or --no-cache with -s 0 still walks the full range. --- src/api.rs | 2 + src/fetch.rs | 1 + src/main.rs | 8 ++ src/workouts.rs | 39 +++++++ tests/test_fetch.rs | 6 + tests/test_workouts.rs | 260 ++++++++++++++++++++++++++++++++++++++++- 6 files changed, 315 insertions(+), 1 deletion(-) diff --git a/src/api.rs b/src/api.rs index 6b34a61..008b351 100644 --- a/src/api.rs +++ b/src/api.rs @@ -77,6 +77,8 @@ pub struct DataAccess<'a, C: ApiClient> { pub use_network: bool, pub use_cache: bool, pub write_cache: bool, + /// Days to scan for new workout dates (`0` = since last cached, `-1` = full history). + pub scan_days: i32, } #[derive(Clone)] diff --git a/src/fetch.rs b/src/fetch.rs index 9a8c2a2..6533af7 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -213,6 +213,7 @@ fn without_cache<'a, C: ApiClient>( use_network: data_access.use_network, use_cache: data_access.use_cache && !skip_cache, write_cache: data_access.write_cache, + scan_days: data_access.scan_days, } } diff --git a/src/main.rs b/src/main.rs index 5d82f57..4e2f24c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,10 @@ struct Args { #[arg(short = 'W', long)] no_cache_write: bool, + /// Days to scan for new workouts (0=since last cached, -1=full history) + #[arg(short = 's', long = "scan-days", default_value_t = 0, allow_hyphen_values = true, value_name = "DAYS")] + scan_days: i32, + /// When to color output: auto, always, never #[arg(long, default_value = "auto")] color: String, @@ -270,6 +274,9 @@ async fn main() -> Result<(), Box> { if args.no_network && args.no_cache { utils::exit_with_error("Error: --no-network and --no-cache are mutually exclusive"); } + if args.scan_days < -1 { + utils::exit_with_error("Error: --scan-days must be -1 or greater"); + } unsafe { std::env::set_var("WXRUST_COLOR", &args.color); } @@ -314,6 +321,7 @@ async fn main() -> Result<(), Box> { use_network: !args.no_network, use_cache: !args.no_cache, write_cache: !args.no_cache_write, + scan_days: args.scan_days, }; match args.command { diff --git a/src/workouts.rs b/src/workouts.rs index 2004d7e..92ee993 100644 --- a/src/workouts.rs +++ b/src/workouts.rs @@ -607,6 +607,45 @@ pub async fn get_dates(data_access: &crate::api::DataA return get_dates_from_cache(uid, latest, oldest, count, reverse); } + let today = Utc::now().date_naive(); + let last_cached = if data_access.use_cache { + latest_cached_date(uid) + } else { + None + }; + let range_oldest = oldest.as_deref().and_then(parse_ymd); + let range_latest = latest.as_deref().and_then(parse_ymd); + + match resolve_date_scan( + data_access.scan_days, + data_access.use_cache, + last_cached, + today, + range_oldest, + range_latest, + ) { + DateScan::CacheOnly => { + return if data_access.use_cache { + get_dates_from_cache(uid, latest, oldest, count, reverse) + } else { + Ok(vec![]) + }; + } + DateScan::Hybrid { oldest: scan_oldest, latest: scan_latest } => { + let mut dates = if data_access.use_cache { + get_dates_from_cache(uid, latest.clone(), oldest.clone(), 0, false)? + } else { + vec![] + }; + dates.extend(fetch_jrange_windows(data_access, scan_oldest, scan_latest).await?); + dates.sort(); + dates.dedup(); + let filtered = filter_dates_by_range(dates, oldest.as_deref(), latest.as_deref()); + return Ok(limit_and_sort_dates(filtered, count, reverse)); + } + DateScan::Full => {} + } + // Bounded ranges can be covered by independent jrange windows in parallel. if let (Some(latest_s), Some(oldest_s)) = (latest.as_deref(), oldest.as_deref()) && let (Some(latest_d), Some(oldest_d)) = (parse_ymd(latest_s), parse_ymd(oldest_s)) diff --git a/tests/test_fetch.rs b/tests/test_fetch.rs index b8fbfbc..a0e4fe2 100644 --- a/tests/test_fetch.rs +++ b/tests/test_fetch.rs @@ -80,6 +80,7 @@ async fn test_fetch_command_skips_cached() { use_network: false, use_cache: true, write_cache: false, + scan_days: 0, }; let dates = vec!["2023-10-01".to_string()]; @@ -132,6 +133,7 @@ async fn test_fetch_command_fetches_and_caches() { use_network: true, use_cache: false, write_cache: false, + scan_days: 0, }; let dates = vec!["2023-10-01".to_string()]; @@ -160,6 +162,7 @@ async fn test_fetch_command_no_dates() { use_network: false, use_cache: true, write_cache: false, + scan_days: 0, }; let result = wxrust::fetch::fetch_command(&data_access, &["2023-10-01".to_string()], false, false, None, false, false).await; @@ -218,6 +221,7 @@ async fn test_fetch_command_force_refetches() { use_network: true, use_cache: true, write_cache: false, + scan_days: 0, }; let dates = vec!["2023-10-01".to_string()]; @@ -269,6 +273,7 @@ async fn test_fetch_command_without_force_skips_network() { use_network: true, use_cache: true, write_cache: false, + scan_days: 0, }; let dates = vec!["2023-10-01".to_string()]; @@ -371,6 +376,7 @@ async fn test_fetch_diff_identical_cache_is_ok() { use_network: true, use_cache: true, write_cache: false, + scan_days: 0, }; let dates = vec!["2023-10-01".to_string()]; diff --git a/tests/test_workouts.rs b/tests/test_workouts.rs index 052ff6e..ef177fd 100644 --- a/tests/test_workouts.rs +++ b/tests/test_workouts.rs @@ -1,5 +1,6 @@ use mockall::mock; -use wxrust::workouts::{get_jday, get_dates, get_dates_from_cache, get_jdays, get_jdays_batch, get_jdays_with_callback, read_cached_user_wants_kg, read_cached_user_wants_kg_or, write_cached_user_wants_kg, forget_cached_user_wants_kg, cached_jday_exists, read_cached_jday_text, format_cached_jday_text, write_cached_jday, jday_alias, chunk_dates, build_jday_query, build_batch_jday_query, JDAY_BATCH_SIZE}; +use wxrust::workouts::{get_jday, get_dates, get_dates_from_cache, get_jdays, get_jdays_batch, get_jdays_with_callback, read_cached_user_wants_kg, read_cached_user_wants_kg_or, write_cached_user_wants_kg, forget_cached_user_wants_kg, cached_jday_exists, read_cached_jday_text, format_cached_jday_text, write_cached_jday, jday_alias, chunk_dates, build_jday_query, build_batch_jday_query, latest_cached_date, JDAY_BATCH_SIZE}; +use chrono::{Duration, Utc}; use wxrust::models::{GraphQLResponse, WorkoutData, JDay, EBlock, ExerciseWrapper, Exercise, Set, User}; use base64::{Engine, engine::general_purpose}; use tempfile::TempDir; @@ -57,6 +58,7 @@ async fn test_get_jday_graphql_error() { use_network: true, use_cache: true, write_cache: true, + scan_days: 0, }; let result = get_jday(&data_access, "2023-10-01", false).await; @@ -128,6 +130,7 @@ async fn test_get_jday_success() { use_network: true, use_cache: true, write_cache: true, + scan_days: 0, }; let result = get_jday(&data_access, "2023-10-01", false).await; @@ -178,6 +181,7 @@ async fn test_get_jday_no_workout() { use_network: true, use_cache: true, write_cache: true, + scan_days: 0, }; let result = get_jday(&data_access, "2023-10-01", false).await; @@ -217,6 +221,7 @@ async fn test_get_jday_invalid_token() { use_network: true, use_cache: true, write_cache: true, + scan_days: 0, }; let result = get_jday(&data_access, "2023-10-01", false).await; assert!(result.is_err()); @@ -265,6 +270,7 @@ async fn test_get_dates_success() { use_network: true, use_cache: true, write_cache: true, + scan_days: 0, }; let result = get_dates(&data_access, None, None, 1, false).await; @@ -311,6 +317,7 @@ async fn test_get_dates_bounded_range() { use_network: true, use_cache: true, write_cache: true, + scan_days: 0, }; let result = get_dates( @@ -355,6 +362,7 @@ async fn test_get_dates_invalid_token() { use_network: true, use_cache: true, write_cache: true, + scan_days: 0, }; let result = get_dates(&data_access, None, None, 1, false).await; assert!(result.is_err()); @@ -681,6 +689,7 @@ async fn test_resolve_user_wants_kg_with_token() { use_network: true, use_cache: true, write_cache: true, + scan_days: 0, }; let result = wxrust::workouts::resolve_user_wants_kg(&data_access).await; @@ -711,6 +720,7 @@ async fn test_resolve_user_wants_kg_without_token() { use_network: false, use_cache: true, write_cache: false, + scan_days: 0, }; let result = wxrust::workouts::resolve_user_wants_kg(&data_access).await; @@ -748,6 +758,7 @@ async fn test_get_dates_from_ranges() { use_network: false, // Force cache usage use_cache: true, write_cache: false, + scan_days: 0, }; let ranges = vec!["2023-10-01..2023-10-02".to_string(), "2023-10-05".to_string()]; @@ -911,6 +922,7 @@ async fn test_get_jdays_empty() { use_network: true, use_cache: true, write_cache: false, + scan_days: 0, }; let result = get_jdays(&data_access, &[], false).await; assert!(result.is_ok()); @@ -928,6 +940,7 @@ async fn test_get_jdays_batch_empty() { use_network: true, use_cache: true, write_cache: false, + scan_days: 0, }; let result = get_jdays_batch(&data_access, &[], false).await; assert!(result.is_ok()); @@ -963,6 +976,7 @@ async fn test_get_jdays_batch_success() { use_network: true, use_cache: false, write_cache: false, + scan_days: 0, }; let dates = vec!["2023-10-01".to_string(), "2023-10-02".to_string()]; @@ -1005,6 +1019,7 @@ async fn test_get_jdays_batch_missing_workout() { use_network: true, use_cache: false, write_cache: false, + scan_days: 0, }; let dates = vec!["2023-10-01".to_string()]; @@ -1034,6 +1049,7 @@ async fn test_get_jdays_batch_graphql_error() { use_network: true, use_cache: false, write_cache: false, + scan_days: 0, }; let dates = vec!["2023-10-01".to_string()]; @@ -1078,6 +1094,7 @@ async fn test_get_jdays_with_callback_chunks() { use_network: true, use_cache: false, write_cache: false, + scan_days: 0, }; let dates = vec![ @@ -1138,6 +1155,7 @@ async fn test_get_jdays_batch_uses_cache() { use_network: true, use_cache: true, write_cache: false, + scan_days: 0, }; let dates = vec!["2023-10-01".to_string()]; @@ -1154,3 +1172,243 @@ async fn test_get_jdays_batch_uses_cache() { unsafe { std::env::remove_var("XDG_CACHE_HOME"); } } } + +fn restore_xdg_cache(original: Result) { + if let Ok(original) = original { + unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + } else { + unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + } +} + +fn ymd_string(date: chrono::NaiveDate) -> String { + date.format("%Y-%m-%d").to_string() +} + +fn jrange_on(date: String) -> wxrust::models::JRangeDayData { + wxrust::models::JRangeDayData { on: Some(date) } +} + +#[tokio::test] +async fn test_latest_cached_date() { + let _guard = ENV_MUTEX.lock().await; + let temp_dir = TempDir::new().unwrap(); + let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); + unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + + assert_eq!(latest_cached_date(123), None); + + let cache_dir = temp_dir.path().join("wxrust").join("123"); + fs::create_dir_all(&cache_dir).unwrap(); + fs::write(cache_dir.join("2025-01-01.txt"), "a").unwrap(); + fs::write(cache_dir.join("2025-03-15.txt"), "b").unwrap(); + fs::write(cache_dir.join("2024-12-31.txt"), "c").unwrap(); + + assert_eq!( + latest_cached_date(123), + chrono::NaiveDate::from_ymd_opt(2025, 3, 15) + ); + + restore_xdg_cache(original_xdg_cache); +} + +#[tokio::test] +async fn test_get_dates_scan_zero_skips_network_when_current() { + let _guard = ENV_MUTEX.lock().await; + let temp_dir = TempDir::new().unwrap(); + let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); + unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + + let today = Utc::now().date_naive(); + let old = today - Duration::days(40); + let cache_dir = temp_dir.path().join("wxrust").join("123"); + fs::create_dir_all(&cache_dir).unwrap(); + fs::write(cache_dir.join(format!("{}.txt", ymd_string(old))), "old").unwrap(); + fs::write(cache_dir.join(format!("{}.txt", ymd_string(today))), "today").unwrap(); + + let mock_client = MockApiClient::new(); + let data_access = wxrust::api::DataAccess { + client: &mock_client, + token: Some("token"), + uid: Some(123), + use_network: true, + use_cache: true, + write_cache: false, + scan_days: 0, + }; + + let result = get_dates(&data_access, None, None, 10000, false).await.unwrap(); + assert_eq!(result, vec![ymd_string(old), ymd_string(today)]); + + restore_xdg_cache(original_xdg_cache); +} + +#[tokio::test] +async fn test_get_dates_scan_zero_merges_since_last_cached() { + let _guard = ENV_MUTEX.lock().await; + let temp_dir = TempDir::new().unwrap(); + let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); + unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + + let today = Utc::now().date_naive(); + let yesterday = today - Duration::days(1); + let old = today - Duration::days(40); + let cache_dir = temp_dir.path().join("wxrust").join("123"); + fs::create_dir_all(&cache_dir).unwrap(); + fs::write(cache_dir.join(format!("{}.txt", ymd_string(old))), "old").unwrap(); + fs::write(cache_dir.join(format!("{}.txt", ymd_string(yesterday))), "y").unwrap(); + + let today_s = ymd_string(today); + let mut mock_client = MockApiClient::new(); + mock_client + .expect_graphql_request::() + .times(1) + .returning(move |_, _, _| { + Ok(GraphQLResponse { + data: Some(wxrust::models::GetJRangeData { + jrange: Some(wxrust::models::JRangeData { + days: Some(vec![jrange_on(today_s.clone())]), + }), + }), + errors: None, + }) + }); + + let data_access = wxrust::api::DataAccess { + client: &mock_client, + token: Some("token"), + uid: Some(123), + use_network: true, + use_cache: true, + write_cache: false, + scan_days: 0, + }; + + let result = get_dates(&data_access, None, None, 10000, false).await.unwrap(); + assert_eq!( + result, + vec![ymd_string(old), ymd_string(yesterday), ymd_string(today)] + ); + + restore_xdg_cache(original_xdg_cache); +} + +#[tokio::test] +async fn test_get_dates_scan_seven_merges_cache() { + let _guard = ENV_MUTEX.lock().await; + let temp_dir = TempDir::new().unwrap(); + let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); + unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + + let today = Utc::now().date_naive(); + let old = chrono::NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); + let cache_dir = temp_dir.path().join("wxrust").join("123"); + fs::create_dir_all(&cache_dir).unwrap(); + fs::write(cache_dir.join(format!("{}.txt", ymd_string(old))), "old").unwrap(); + fs::write(cache_dir.join(format!("{}.txt", ymd_string(today))), "today").unwrap(); + + let today_s = ymd_string(today); + let mut mock_client = MockApiClient::new(); + mock_client + .expect_graphql_request::() + .times(1) + .returning(move |_, _, _| { + Ok(GraphQLResponse { + data: Some(wxrust::models::GetJRangeData { + jrange: Some(wxrust::models::JRangeData { + days: Some(vec![jrange_on(today_s.clone())]), + }), + }), + errors: None, + }) + }); + + let data_access = wxrust::api::DataAccess { + client: &mock_client, + token: Some("token"), + uid: Some(123), + use_network: true, + use_cache: true, + write_cache: false, + scan_days: 7, + }; + + let result = get_dates(&data_access, None, None, 10000, false).await.unwrap(); + assert_eq!(result, vec![ymd_string(old), ymd_string(today)]); + + restore_xdg_cache(original_xdg_cache); +} + +#[tokio::test] +async fn test_get_dates_scan_zero_no_cache_is_full() { + let _guard = ENV_MUTEX.lock().await; + let temp_dir = TempDir::new().unwrap(); + let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); + unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + + let mut mock_client = MockApiClient::new(); + mock_client + .expect_graphql_request::() + .times(1) + .returning(|_, _, _| { + Ok(GraphQLResponse { + data: Some(wxrust::models::GetJRangeData { + jrange: Some(wxrust::models::JRangeData { + days: Some(vec![jrange_on("2023-10-01".to_string())]), + }), + }), + errors: None, + }) + }); + + let data_access = wxrust::api::DataAccess { + client: &mock_client, + token: Some("token"), + uid: Some(123), + use_network: true, + use_cache: false, + write_cache: false, + scan_days: 0, + }; + + let result = get_dates(&data_access, None, None, 1, false).await.unwrap(); + assert_eq!(result, vec!["2023-10-01".to_string()]); + + restore_xdg_cache(original_xdg_cache); +} + +#[tokio::test] +async fn test_get_dates_scan_zero_historical_range_is_cache_only() { + let _guard = ENV_MUTEX.lock().await; + let temp_dir = TempDir::new().unwrap(); + let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); + unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + + let today = Utc::now().date_naive(); + let cache_dir = temp_dir.path().join("wxrust").join("123"); + fs::create_dir_all(&cache_dir).unwrap(); + fs::write(cache_dir.join("2020-06-01.txt"), "old").unwrap(); + fs::write(cache_dir.join(format!("{}.txt", ymd_string(today))), "today").unwrap(); + + let mock_client = MockApiClient::new(); + let data_access = wxrust::api::DataAccess { + client: &mock_client, + token: Some("token"), + uid: Some(123), + use_network: true, + use_cache: true, + write_cache: false, + scan_days: 0, + }; + + let result = get_dates( + &data_access, + Some("2020-12-31".to_string()), + Some("2020-01-01".to_string()), + 10000, + false, + ).await.unwrap(); + assert_eq!(result, vec!["2020-06-01".to_string()]); + + restore_xdg_cache(original_xdg_cache); +} From 24eb0500f08c8ce11bd3fded6b2182d0304fd16c Mon Sep 17 00:00:00 2001 From: Bart Trojanowski Date: Fri, 4 Sep 2026 18:44:04 +0000 Subject: [PATCH 3/4] Document -s/--scan-days date listing Default 0 scans since the last cached workout; -1 is full history. Note that the flag must appear before the subcommand because list/show already use -s for --summary. --- AGENTS.md | 6 ++++-- README.md | 12 +++++++++--- smoke/000-help/expected.stdout | 1 + 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6e1afe0..c38d026 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ decodes the JWT token for user ID, queries the GraphQL API for workout data, and - Weights: #FF7900 (255,121,0) - Reps: #00BBF9 (0,187,249) - Sets: #F15BB5 (241,91,181) -- **Performance**: Reuse HTTP client across requests to maintain connection pooling and avoid TCP overhead. Session-level caching of user preferences (`user_wants_kg`); `getSession` is lazy, not on every command. Fetch packs up to 10 `jday` queries per GraphQL request (aliases) and runs 8 requests concurrently (`JDAY_BATCH_SIZE=10`, `FETCH_CONCURRENCY=8`). Bounded `jrange` listing (`get_dates` with both oldest and latest, e.g. `table deadlift 2026`) splits the span into independent 32-week windows and fetches them concurrently (`jrange_windows`). `table` / `heatmap` / filtered `list` load workout bodies via `get_jdays` (cache hits stay local; misses use the same batched path as `fetch`) instead of one GraphQL request per date. Sequential `jday` fetching of 152 workouts took ~19s; batched+concurrent takes ~1.2s for the workout downloads. Network slowness on a warm cache is date listing + auth, not parsing. HTTP client uses a larger idle pool, TCP_NODELAY, and gzip. +- **Performance**: Reuse HTTP client across requests to maintain connection pooling and avoid TCP overhead. Session-level caching of user preferences (`user_wants_kg`); `getSession` is lazy, not on every command. Fetch packs up to 10 `jday` queries per GraphQL request (aliases) and runs 8 requests concurrently (`JDAY_BATCH_SIZE=10`, `FETCH_CONCURRENCY=8`). Date listing (`get_dates`) defaults to `-s 0`: cache filenames plus a bounded `jrange` from the last cached date through today (`resolve_date_scan` / `DateScan::{Full,CacheOnly,Hybrid}`). `-s -1` restores a full network listing; bounded ranges then use concurrent 32-week windows (`jrange_windows`). `table` / `heatmap` / filtered `list` load workout bodies via `get_jdays` (cache hits stay local; misses use the same batched path as `fetch`) instead of one GraphQL request per date. Sequential `jday` fetching of 152 workouts took ~19s; batched+concurrent takes ~1.2s for the workout downloads. With a warm cache and default `-s 0`, `table deadlift` stays local (sub-1s); the old 40s cost was unbounded sequential `jrange`. HTTP client uses a larger idle pool, TCP_NODELAY, and gzip. - **Rust Implementation**: Used reqwest for HTTP with client reuse, serde for JSON, base64 for JWT decoding, ansi_term for colors, atty for TTY detection. Handled GraphQL responses, error checking, and inline color application during text generation. ## Referenced Links @@ -83,6 +83,7 @@ You can look in `weightxreps-client/src/data/generated---db-types-and-hooks.tsx` - Optional body weight line in workout parsing; workouts without "@ bw" are allowed and set bw to None - Robust workout parsing that treats invalid exercise blocks (lone # or #exercise with no valid sets) as comments - Data access control options: `--force-authentication` (`-a`), `--no-network` (`-N`), `--no-cache` (`-C`), `--no-cache-write` (`-W`) for flexible offline/online operation modes +- **`-s/--scan-days`** (default `0`): how many days to network-scan for new workout dates before using the cache date list. Must appear before the subcommand (`wxrust -s 7 table deadlift`); `list`/`show` `-s` is `--summary`. `-s 0` scans since the last cached date (skips the network if that date is today); `-s N` scans `[today-N, today]`; `-s -1` is the old full-history `jrange` walk. `-s 0` with `--no-cache` or an empty cache falls back to full scan. `--no-network` ignores this flag. Does not detect edits to already-cached days (API has no mtime). - Unit-aware parsing: Parser uses cached user unit preference (`user_wants_kg`) to correctly interpret weights without explicit units when reading from cache or importing files, preventing 2.2x multiplier errors in offline mode - Table command for PR progression: Displays personal records over time with 1RM calculations (Brzycki formula), date/exercise filtering, age-based color gradient (256-color ANSI), projected weights for rep ranges 1-10, deterministic processing in chronological order, and deduplication of same-day same-rep PRs (keeps only the best weight per day per rep count) - Heatmap command: Displays calendar heatmap of workout intensity with mutually exclusive metric options (--sets, --reps, --volume, --weight, --onerm; default: onerm), date/exercise filtering, color scheme options (--green for RGB green gradient, defaulting to solarized table-style gradient), symbol gradients for no-color mode, adapted from clinvoice-rs heatmap implementation @@ -176,6 +177,7 @@ Recent refactoring extracted common code into helper functions to improve mainta - `workouts::resolve_user_wants_kg`: Consolidates logic for determining user weight unit preference, checking network token then falling back to cache. - `workouts::get_dates_from_ranges`: Unified logic for parsing date ranges and fetching/calculating dates, used by both `list` and `fetch` commands. - `workouts::jrange_windows` / `fetch_jrange_windows`: Split a bounded date range into concurrent `jrange` week-windows (max 32 weeks each). + - `workouts::latest_cached_date` / `resolve_date_scan`: Newest cache filename and the `-s/--scan-days` policy (`DateScan::{Full,CacheOnly,Hybrid}`). - `workouts::format_cached_jday_text` / `read_cached_jday_text`: Cache file text used by `write_cached_jday` and `fetch --diff`. - `fetch::format_text_diff`: Unified-style diff; returns `None` when local and server texts are identical. - `utils::create_progress_bar`: Standardized progress bar creation using `indicatif`. @@ -213,6 +215,6 @@ Unlike the C version which shows separate tables per filter, the Rust implementa - Support for other set types (WxD, WxT, etc.). - Support for tags, time/distance sets. - DELETE keyword handling in cache management. -- Cache invalidation without a remote mtime: refetch recent dates always, or ask upstream for `updatedAt` on `JLog` / `JRangeDayData`. +- Cache invalidation without a remote mtime: `-s/--scan-days` finds new dates but not edits to already-cached days; ask upstream for `updatedAt` on `JLog` / `JRangeDayData`. diff --git a/README.md b/README.md index 84e9d97..c588d95 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,14 @@ Create a `credentials.txt` file with your WeightXReps account email on the first - `-N, --no-network`: Skip network access, use cache only (workouts and dates come from local cache) - `-C, --no-cache`: Skip cache lookup, fetch all data from server (writes still happen unless `--no-cache-write` is also used) - `-W, --no-cache-write`: Disable cache writes (reads still happen unless `--no-cache` is also used) +- `-s, --scan-days `: How many days to scan for new workout dates before using the cache (default: `0`). Must appear before the subcommand, like other global flags (`wxrust -s 7 table deadlift`). `list`/`show` `-s` is still `--summary`. + - `-s 0`: scan from the last cached workout through today (skips the network if the cache already has today) + - `-s 7`: scan the last 7 days (and still use cached dates for older history) + - `-s -1`: list dates from the server over the full requested range + - `-s 0` with `--no-cache` or an empty cache falls back to a full scan - `--color `: Control color output (default: auto, based on TTY) -**Note:** `--no-network` and `--no-cache` are mutually exclusive. +**Note:** `--no-network` and `--no-cache` are mutually exclusive. `--no-network` ignores `--scan-days`. ### Commands @@ -87,7 +92,8 @@ Create a `credentials.txt` file with your WeightXReps account email on the first #### Fetch Workouts - Fetch and cache workouts for 2025: `wxrust fetch 2025` -- Fetch all workouts: `wxrust fetch` +- Fetch new workouts since the last cached date: `wxrust fetch` (default `-s 0`) +- Fetch the full history: `wxrust -s -1 fetch` - Show diff between local and server: `wxrust fetch --diff 2025-10` (only workouts whose cache file would change) - Force re-download: `wxrust fetch --force 2025` - Import from text export file: `wxrust fetch --file export.txt` @@ -105,7 +111,7 @@ Display a progression table showing personal records (PRs) over time for specifi - Filter by date range: `wxrust table 2025` - Combine date and exercise filters: `wxrust table 2025 deadlift` -`table` (and `heatmap` / filtered `list`) list dates with concurrent `jrange` windows, then load workouts through the same batched `jday` path as `fetch`. Cached days stay local; `--no-network` skips the date listing round-trip entirely. +`table` (and `heatmap` / filtered `list`) list dates from the local cache and scan only recent days for new workouts (`--scan-days`, default 0). `-s -1` lists dates with concurrent `jrange` windows over the full range. Workout bodies load through the same batched `jday` path as `fetch`; cached days stay local. `--no-network` skips the date listing round-trip entirely. Arguments are automatically classified as dates or exercise filters: - **Date formats**: `YYYY`, `YYYY-MM`, `YYYY.MM`, `YYYYMM`, `YYYY-MM-DD`, `YYYY.MM.DD`, `YYYYMMDD` diff --git a/smoke/000-help/expected.stdout b/smoke/000-help/expected.stdout index ec7cf19..df3e670 100644 --- a/smoke/000-help/expected.stdout +++ b/smoke/000-help/expected.stdout @@ -16,6 +16,7 @@ Options: -N, --no-network Do not connect to the server; use local cache only -C, --no-cache Do not read workouts from the local cache -W, --no-cache-write Do not write fetched workouts to the local cache + -s, --scan-days Days to scan for new workouts (0=since last cached, -1=full history) [default: 0] --color When to color output: auto, always, never [default: auto] -v, --verbose Enable debug output -h, --help Print help From 0a5e129b435ddf24c6dce7d0de9157bfa90674b6 Mon Sep 17 00:00:00 2001 From: Bart Trojanowski Date: Fri, 4 Sep 2026 18:48:45 +0000 Subject: [PATCH 4/4] Reformat with rustfmt Add rustfmt to dependencies.sh and run it over src/ and tests/. --- dependencies.sh | 2 +- src/api.rs | 120 ++++++--- src/auth.rs | 59 +++-- src/credentials.rs | 11 +- src/fetch.rs | 16 +- src/formatters.rs | 107 +++++--- src/heatmap.rs | 40 ++- src/lib.rs | 14 +- src/list.rs | 23 +- src/main.rs | 71 +++-- src/models.rs | 2 +- src/parsers.rs | 98 ++++--- src/table.rs | 143 ++++++---- src/utils.rs | 26 +- src/workouts.rs | 202 ++++++++++---- tests/test_api.rs | 34 +-- tests/test_auth.rs | 26 +- tests/test_auth_integration.rs | 45 ++-- tests/test_date_utils.rs | 7 +- tests/test_fetch.rs | 101 +++++-- tests/test_formatters.rs | 179 ++++++++++--- tests/test_heatmap.rs | 32 +-- tests/test_parsers.rs | 172 ++++++++---- tests/test_table.rs | 14 +- tests/test_workouts.rs | 471 ++++++++++++++++++++++++--------- 25 files changed, 1415 insertions(+), 600 deletions(-) diff --git a/dependencies.sh b/dependencies.sh index bfc0e19..98e47b2 100755 --- a/dependencies.sh +++ b/dependencies.sh @@ -5,7 +5,7 @@ SUDO= [ "$(id -u)" = 0 ] || SUDO=sudo NEED=( cargo libssl-dev pkg-config ) -WANT=( rust-gdb entr rust-clippy ) +WANT=( rust-gdb entr rust-clippy rustfmt ) set -x $SUDO apt update diff --git a/src/api.rs b/src/api.rs index 008b351..f6e4b69 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,8 +1,8 @@ +use ansi_term::Colour; use async_trait::async_trait; use serde::de::DeserializeOwned; -use ansi_term::Colour; -use tokio::sync::OnceCell; use std::sync::atomic::{AtomicU64, Ordering}; +use tokio::sync::OnceCell; static TRANSFER_BYTES: AtomicU64 = AtomicU64::new(0); static TRANSFER_REQUESTS: AtomicU64 = AtomicU64::new(0); @@ -25,16 +25,29 @@ fn record_transfer(bytes: usize) { TRANSFER_REQUESTS.fetch_add(1, Ordering::Relaxed); } -use crate::models::{GraphQLRequest, GraphQLResponse, WorkoutRequest, WorkoutResponse, UserBasicInfoData, User}; use crate::formatters::STDERR_COLOR_ENABLED; -use crate::workouts::{write_cached_user_wants_kg, read_cached_user_wants_kg}; +use crate::models::{ + GraphQLRequest, GraphQLResponse, User, UserBasicInfoData, WorkoutRequest, WorkoutResponse, +}; +use crate::workouts::{read_cached_user_wants_kg, write_cached_user_wants_kg}; #[cfg_attr(tarpaulin, ignore)] #[async_trait] pub trait ApiClient: Send + Sync { - async fn login_request(&self, request: &GraphQLRequest) -> Result, Box>; - async fn graphql_request(&self, token: &str, query: &str, variables: Option) -> Result, Box>; - async fn get_user_info(&self, token: &str) -> Result>; + async fn login_request( + &self, + request: &GraphQLRequest, + ) -> Result, Box>; + async fn graphql_request( + &self, + token: &str, + query: &str, + variables: Option, + ) -> Result, Box>; + async fn get_user_info( + &self, + token: &str, + ) -> Result>; async fn user_wants_kg(&self, token: &str) -> bool; } @@ -42,7 +55,10 @@ fn log_verbose_request(query: &str, variables: Option<&serde_json::Value>, verbo if verbose { let mut output = format!("Query:\n{}", query); if let Some(vars) = variables { - output += &format!("\nVariables: {}", serde_json::to_string_pretty(vars).unwrap_or("Failed".to_string())); + output += &format!( + "\nVariables: {}", + serde_json::to_string_pretty(vars).unwrap_or("Failed".to_string()) + ); } let colored = if *STDERR_COLOR_ENABLED { Colour::Blue.paint(output).to_string() @@ -105,9 +121,17 @@ impl ReqwestClient { #[cfg_attr(tarpaulin, ignore)] #[async_trait] impl ApiClient for ReqwestClient { - async fn login_request(&self, request: &GraphQLRequest) -> Result, Box> { - log_verbose_request(&request.query, Some(&serde_json::to_value(&request.variables).unwrap()), self.verbose); - let response = self.client + async fn login_request( + &self, + request: &GraphQLRequest, + ) -> Result, Box> { + log_verbose_request( + &request.query, + Some(&serde_json::to_value(&request.variables).unwrap()), + self.verbose, + ); + let response = self + .client .post("https://weightxreps.net/api/graphql") .json(request) .send() @@ -120,14 +144,20 @@ impl ApiClient for ReqwestClient { Ok(body) } - async fn graphql_request(&self, token: &str, query: &str, variables: Option) -> Result, Box> { + async fn graphql_request( + &self, + token: &str, + query: &str, + variables: Option, + ) -> Result, Box> { log_verbose_request(query, variables.as_ref(), self.verbose); let request_body = if let Some(vars) = variables { serde_json::json!({ "query": query, "variables": vars }) } else { serde_json::json!({ "query": query }) }; - let response = self.client + let response = self + .client .post("https://weightxreps.net/api/graphql") .header("Authorization", format!("Bearer {}", token)) .json(&request_body) @@ -141,9 +171,14 @@ impl ApiClient for ReqwestClient { Ok(body) } - async fn get_user_info(&self, token: &str) -> Result> { - let user = self.user_info.get_or_try_init(|| async { - let query = r#" + async fn get_user_info( + &self, + token: &str, + ) -> Result> { + let user = self + .user_info + .get_or_try_init(|| async { + let query = r#" query { getSession { user { @@ -152,23 +187,28 @@ impl ApiClient for ReqwestClient { } } "#; - let response: GraphQLResponse = self.graphql_request(token, query, None).await?; - if let Some(errors) = response.errors { - return Err::>(format!("GraphQL errors: {:?}", errors).into()); - } - // Default to kg if not available - if let Some(data) = response.data { - let mut usekg = 1; - if let Some(session) = data.get_session - && let Some(val) = session.user.usekg { + let response: GraphQLResponse = + self.graphql_request(token, query, None).await?; + if let Some(errors) = response.errors { + return Err::>( + format!("GraphQL errors: {:?}", errors).into(), + ); + } + // Default to kg if not available + if let Some(data) = response.data { + let mut usekg = 1; + if let Some(session) = data.get_session + && let Some(val) = session.user.usekg + { write_cached_user_wants_kg(val != 0); usekg = val; } - Ok(User { usekg: Some(usekg) }) - } else { - Err("No data in response".into()) - } - }).await?; + Ok(User { usekg: Some(usekg) }) + } else { + Err("No data in response".into()) + } + }) + .await?; Ok(user.clone()) } @@ -179,24 +219,36 @@ impl ApiClient for ReqwestClient { let user = self.get_user_info(token).await; match user { Ok(ref u) => return u.usekg.unwrap_or(1) == 1, - Err(_) => return false + Err(_) => return false, } } } #[cfg_attr(tarpaulin, ignore)] -pub async fn login_request(client: &C, request: &GraphQLRequest) -> Result, Box> { +pub async fn login_request( + client: &C, + request: &GraphQLRequest, +) -> Result, Box> { client.login_request(request).await } #[cfg_attr(tarpaulin, ignore)] -pub async fn graphql_request(client: &C, token: &str, query: &str, variables: Option) -> Result, Box> { +pub async fn graphql_request( + client: &C, + token: &str, + query: &str, + variables: Option, +) -> Result, Box> { client.graphql_request(token, query, variables).await } #[cfg_attr(tarpaulin, ignore)] #[allow(dead_code)] -pub async fn workout_request(client: &reqwest::Client, token: &str, request: &WorkoutRequest) -> Result> { +pub async fn workout_request( + client: &reqwest::Client, + token: &str, + request: &WorkoutRequest, +) -> Result> { let response = client .post("https://weightxreps.net/api/graphql") .header("Authorization", format!("Bearer {}", token)) diff --git a/src/auth.rs b/src/auth.rs index e8b4834..8abf426 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -19,35 +19,54 @@ struct CachedToken { exp: u64, } -pub async fn login(client: &C, credentials_path: &str, token_path: &str, force_auth: bool) -> Result { +pub async fn login( + client: &C, + credentials_path: &str, + token_path: &str, + force_auth: bool, +) -> Result { // Check if token file exists and is valid (unless force_auth is true) if !force_auth && let Ok(contents) = fs::read_to_string(token_path) - && let Ok(cached) = serde_json::from_str::(&contents) { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - if cached.exp > now { - return Ok(cached.token); - } - } + && let Ok(cached) = serde_json::from_str::(&contents) + { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + if cached.exp > now { + return Ok(cached.token); + } + } // Perform login - let credentials = fs::read_to_string(credentials_path).map_err(|_| format!("{} not found. Please create it with email on first line and password on second.", credentials_path))?; + let credentials = fs::read_to_string(credentials_path).map_err(|_| { + format!( + "{} not found. Please create it with email on first line and password on second.", + credentials_path + ) + })?; let lines: Vec<&str> = credentials.lines().collect(); if lines.len() < 2 { - return Err(format!("{} must have at least 2 lines: email and password", credentials_path)); + return Err(format!( + "{} must have at least 2 lines: email and password", + credentials_path + )); } let email = lines[0].to_string(); let password = lines[1].to_string(); let request = models::GraphQLRequest { query: "mutation login($u: String!, $p: String!) { login(u: $u, p: $p) }".to_string(), - variables: models::LoginVariables { u: email, p: password }, + variables: models::LoginVariables { + u: email, + p: password, + }, }; - let response = api::login_request(client, &request).await.map_err(|e| e.to_string())?; + let response = api::login_request(client, &request) + .await + .map_err(|e| e.to_string())?; if let Some(data) = response.data { let token = data.login; @@ -66,7 +85,11 @@ pub async fn login(client: &C, credentials_path: &str, fs::write(token_path, json).map_err(|e| e.to_string())?; Ok(token) } else if let Some(errors) = response.errors { - Err(errors.into_iter().map(|e| e.message).collect::>().join("; ")) + Err(errors + .into_iter() + .map(|e| e.message) + .collect::>() + .join("; ")) } else { Err("Unexpected response".to_string()) } @@ -84,8 +107,10 @@ pub fn decode_token(token: &str) -> Result> { } pub fn load_uid_from_cache(token_path: &str) -> Result { - let contents = fs::read_to_string(token_path).map_err(|_| format!("Token file {} not found", token_path))?; - let cached: CachedToken = serde_json::from_str(&contents).map_err(|_| "Invalid token cache format".to_string())?; + let contents = fs::read_to_string(token_path) + .map_err(|_| format!("Token file {} not found", token_path))?; + let cached: CachedToken = + serde_json::from_str(&contents).map_err(|_| "Invalid token cache format".to_string())?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() diff --git a/src/credentials.rs b/src/credentials.rs index d58f2f7..9174dc1 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -1,6 +1,6 @@ +use lazy_static::lazy_static; use std::path::{Path, PathBuf}; use std::sync::Mutex; -use lazy_static::lazy_static; lazy_static! { static ref CREDENTIALS_PATH: Mutex> = Mutex::new(None); @@ -30,7 +30,12 @@ pub fn get_credentials_path() -> Result { // Fallback to ~/.config if let Ok(home) = std::env::var("HOME") { - paths.push(PathBuf::from(home).join(".config").join("wxrust").join("credentials.txt")); + paths.push( + PathBuf::from(home) + .join(".config") + .join("wxrust") + .join("credentials.txt"), + ); } // Current directory @@ -45,4 +50,4 @@ pub fn get_credentials_path() -> Result { } Err("Credentials file not found.".to_string()) -} \ No newline at end of file +} diff --git a/src/fetch.rs b/src/fetch.rs index 6533af7..a44b0ec 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -101,7 +101,10 @@ pub async fn fetch_command( pb.finish_with_message("Done"); if stats { - println!("{}", format_transfer_stats(need.len() as u64, bytes, elapsed_secs)); + println!( + "{}", + format_transfer_stats(need.len() as u64, bytes, elapsed_secs) + ); } Ok(()) @@ -130,7 +133,10 @@ async fn fetch_diff( pb.finish_and_clear(); if stats { - println!("{}", format_transfer_stats(fetched.len() as u64, bytes, elapsed_secs)); + println!( + "{}", + format_transfer_stats(fetched.len() as u64, bytes, elapsed_secs) + ); } for (date, server_jday) in fetched { @@ -151,7 +157,8 @@ async fn fetch_diff( } fn fetch_from_file(uid: u32, file_path: &str, _verbose: bool) -> Result<(), String> { - let content = fs::read_to_string(file_path).map_err(|e| format!("Failed to read file: {}", e))?; + let content = + fs::read_to_string(file_path).map_err(|e| format!("Failed to read file: {}", e))?; let workouts = parse_file_export(&content)?; @@ -192,7 +199,8 @@ fn parse_file_export(content: &str) -> Result, Strin } let workout_text = format!("{}\n{}", date, workout_lines.join("\n")); - let jday = parsers::parse_workout_with_options(&workout_text, &options).map_err(|e| format!("Failed to parse workout for {}: {}", date, e))?; + let jday = parsers::parse_workout_with_options(&workout_text, &options) + .map_err(|e| format!("Failed to parse workout for {}: {}", date, e))?; workouts_list.push((date, jday)); } else { i += 1; diff --git a/src/formatters.rs b/src/formatters.rs index 4d23eb7..6042522 100644 --- a/src/formatters.rs +++ b/src/formatters.rs @@ -1,12 +1,12 @@ -use std::collections::HashMap; -use lazy_static::lazy_static; -use ansi_term::Colour; use crate::parsers::LBS_PER_KG; +use ansi_term::Colour; +use lazy_static::lazy_static; +use std::collections::HashMap; //use crate::models::{JDay, Set, Exercise, EBlock, User}; -use crate::models::{JDay, Set, Exercise, EBlock}; -use crate::workouts::read_cached_user_wants_kg_or; +use crate::models::{EBlock, Exercise, JDay, Set}; use crate::table::matches_any_filter; +use crate::workouts::read_cached_user_wants_kg_or; #[derive(Clone)] pub struct FormatOptions { @@ -55,7 +55,6 @@ lazy_static! { _ => atty::is(atty::Stream::Stdout), } }; - pub static ref STDERR_COLOR_ENABLED: bool = { let color_arg = std::env::var("WXRUST_COLOR").unwrap_or("auto".to_string()); match color_arg.as_str() { @@ -124,8 +123,6 @@ fn color_sets_internal(s: &str, options: &FormatOptions) -> String { } } - - pub fn format_weight(w: f32, w_in_lbs: bool, options: &FormatOptions) -> String { let display_in_lbs = !options.user_wants_kg; let num = if w_in_lbs && display_in_lbs { @@ -147,7 +144,12 @@ pub fn format_weight(w: f32, w_in_lbs: bool, options: &FormatOptions) -> String } } -pub fn format_weight_with_bw(w: f32, w_in_lbs: bool, usebw: i32, options: &FormatOptions) -> String { +pub fn format_weight_with_bw( + w: f32, + w_in_lbs: bool, + usebw: i32, + options: &FormatOptions, +) -> String { if usebw != 0 { if w > 0.0 { if usebw > 0 { @@ -169,7 +171,14 @@ pub fn format_set(set: &Set) -> String { } #[allow(dead_code)] -pub fn format_failed_set(w: f32, w_in_lbs: bool, usebw: i32, r: u32, s: u32, options: &FormatOptions) -> String { +pub fn format_failed_set( + w: f32, + w_in_lbs: bool, + usebw: i32, + r: u32, + s: u32, + options: &FormatOptions, +) -> String { let wbw = format_weight_with_bw(w, w_in_lbs, usebw, options); let rxs = format!("{} x {} x {}", wbw, r, s); if options.color_enabled { @@ -204,9 +213,10 @@ fn format_set_internal(set: &Set, options: &FormatOptions) -> String { line += &format!(" @{}", rpe); } if let Some(c) = &set.c - && !c.is_empty() { - line += &format!(" {}", c); - } + && !c.is_empty() + { + line += &format!(" {}", c); + } line } @@ -236,7 +246,13 @@ fn compress_sets_internal(sets: &[Set], options: &FormatOptions) -> Vec let mut j = i + 1; while j < sets.len() { let next = &sets[j]; - if next.set_type.unwrap_or(0) != 0 || next.w != set.w || next.rpe != set.rpe || next.lb != set.lb || next.s != set.s || next.usebw != set.usebw { + if next.set_type.unwrap_or(0) != 0 + || next.w != set.w + || next.rpe != set.rpe + || next.lb != set.lb + || next.s != set.s + || next.usebw != set.usebw + { break; } same_weight.push(next.r.unwrap_or(0)); @@ -245,7 +261,11 @@ fn compress_sets_internal(sets: &[Set], options: &FormatOptions) -> Vec if same_weight.len() > 1 { let line = format_weight_with_bw(w, w_in_lbs, usebw, options); let w_str = color_weight_internal(&line, options); - let r_str = same_weight.iter().map(|&r| color_reps_internal(&r.to_string(), options)).collect::>().join(", "); + let r_str = same_weight + .iter() + .map(|&r| color_reps_internal(&r.to_string(), options)) + .collect::>() + .join(", "); let mut line = format!("{} x {}", w_str, r_str); if rpe > 0.0 { line += &format!(" @{}", rpe); @@ -258,17 +278,27 @@ fn compress_sets_internal(sets: &[Set], options: &FormatOptions) -> Vec let mut j = i + 1; while j < sets.len() { let next = &sets[j]; - if next.set_type.unwrap_or(0) != 0 || next.r != set.r || next.rpe != set.rpe || next.lb != set.lb || next.s != set.s || next.usebw != set.usebw { + if next.set_type.unwrap_or(0) != 0 + || next.r != set.r + || next.rpe != set.rpe + || next.lb != set.lb + || next.s != set.s + || next.usebw != set.usebw + { break; } same_rep.push(next.w.unwrap_or(0.0)); j += 1; } if same_rep.len() > 1 { - let w_str = same_rep.iter().map(|&w| { - let line = format_weight_with_bw(w, w_in_lbs, usebw, options); - color_weight_internal(&line, options) - }).collect::>().join(", "); + let w_str = same_rep + .iter() + .map(|&w| { + let line = format_weight_with_bw(w, w_in_lbs, usebw, options); + color_weight_internal(&line, options) + }) + .collect::>() + .join(", "); let r_str = color_reps_internal(&r.to_string(), options); let mut line = format!("{} x {}", w_str, r_str); if rpe > 0.0 { @@ -333,9 +363,15 @@ fn summarize_workout_internal(jday: &JDay, options: &FormatOptions, filters: &[S } if max_weight > 0.0 { let w_in_lbs = false; //eblock.sets.iter().any(|s| s.lb.unwrap_or(0.0) == 1.0); - let w_str = color_weight_internal(&format_weight(max_weight, w_in_lbs, options), options); + let w_str = + color_weight_internal(&format_weight(max_weight, w_in_lbs, options), options); let r_str = color_reps_internal(&max_reps.to_string(), options); - summaries.push(format!("#{} {}x{}", color_exercise_internal(&ex.name, options), w_str, r_str)); + summaries.push(format!( + "#{} {}x{}", + color_exercise_internal(&ex.name, options), + w_str, + r_str + )); } } } @@ -355,17 +391,22 @@ fn format_workout_internal(date: &str, jday: &JDay, options: &FormatOptions) -> result = result.replace(&placeholder, &formatted); } let mut output = vec![color_date_internal(date, options)]; - if let Some(bw) = jday.bw - && bw > 0.0 { - let num = if options.user_wants_kg { bw } else { bw * LBS_PER_KG }; - let unit_str = if options.user_wants_kg { "kg" } else { "lbs" }; - let bwtxt = if options.show_unit_name { - format!("{:.*} {}", options.bw_precision, num, unit_str) - } else { - format!("{:.*}", options.bw_precision, num) - }; - output.push(format!("@ {} bw", color_bw_internal(&bwtxt, options))); - } + if let Some(bw) = jday.bw + && bw > 0.0 + { + let num = if options.user_wants_kg { + bw + } else { + bw * LBS_PER_KG + }; + let unit_str = if options.user_wants_kg { "kg" } else { "lbs" }; + let bwtxt = if options.show_unit_name { + format!("{:.*} {}", options.bw_precision, num, unit_str) + } else { + format!("{:.*}", options.bw_precision, num) + }; + output.push(format!("@ {} bw", color_bw_internal(&bwtxt, options))); + } output.push(result); output.join("\n") } diff --git a/src/heatmap.rs b/src/heatmap.rs index f780a35..05f9e32 100644 --- a/src/heatmap.rs +++ b/src/heatmap.rs @@ -1,13 +1,13 @@ use std::collections::HashMap; -use chrono::{Datelike, NaiveDate, Weekday, Month}; +use chrono::{Datelike, Month, NaiveDate, Weekday}; use num_traits::cast::FromPrimitive; use crate::api::{ApiClient, DataAccess}; -use crate::models::{JDay, Exercise}; -use crate::workouts; -use crate::utils; +use crate::models::{Exercise, JDay}; use crate::table::{calculate_1rm, parse_date_and_filter_arguments}; +use crate::utils; +use crate::workouts; /// Metric to use for intensity calculation #[derive(Debug, Clone, Copy)] @@ -35,7 +35,10 @@ pub fn compute_metric(jday: &JDay, metric: Metric, filters: &[String]) -> f64 { // Check if this exercise matches our filters if !filters.is_empty() { let name_lower = ex.name.to_lowercase(); - if !filters.iter().any(|f| name_lower.contains(&f.to_lowercase())) { + if !filters + .iter() + .any(|f| name_lower.contains(&f.to_lowercase())) + { continue; } } @@ -82,7 +85,6 @@ pub async fn handle_heatmap( args: &[String], verbose: bool, ) { - // Parse arguments into dates and exercise filters let (date_args, filters) = parse_date_and_filter_arguments(args); @@ -164,7 +166,10 @@ pub async fn handle_heatmap( let end_date = *date_list.last().unwrap(); // Find min and max values - let max_value = daily_values.values().cloned().fold(f64::NEG_INFINITY, f64::max); + let max_value = daily_values + .values() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); let min_value = daily_values.values().cloned().fold(f64::INFINITY, f64::min); if verbose { @@ -177,7 +182,14 @@ pub async fn handle_heatmap( } // Draw the heatmap - draw_heatmap(daily_values, start_date, end_date, min_value, max_value, green); + draw_heatmap( + daily_values, + start_date, + end_date, + min_value, + max_value, + green, + ); } /// Draw the heatmap to the console. @@ -268,7 +280,9 @@ fn draw_heatmap( format!("\x1b[38;2;0;{};0m", intensity) } else { // Solarized: use table gradient colors (default) - let gradient_index = (intensity as usize * (crate::table::GRADIENT.len() - 1) / 255).min(crate::table::GRADIENT.len() - 1); + let gradient_index = + (intensity as usize * (crate::table::GRADIENT.len() - 1) / 255) + .min(crate::table::GRADIENT.len() - 1); format!("\x1b[38;5;{}m", crate::table::GRADIENT[gradient_index]) }; format!("{} ◀▶\x1b[0m", color_code) @@ -281,7 +295,8 @@ fn draw_heatmap( 2 => " ◂▸", 3 => " ◃▹", _ => " ◀▶", - }.to_string() + } + .to_string() } } _ => { @@ -303,7 +318,10 @@ fn draw_heatmap( let mut last_month = 0; for date in &week_dates { if date.month() != last_month { - print!("{:3}", Month::from_u32(date.month()).unwrap().name()[..3].to_string()); + print!( + "{:3}", + Month::from_u32(date.month()).unwrap().name()[..3].to_string() + ); last_month = date.month(); } else { print!(" "); diff --git a/src/lib.rs b/src/lib.rs index 485b58b..b73ed06 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,11 @@ -pub mod models; -pub mod formatters; -pub mod auth; pub mod api; -pub mod workouts; -pub mod utils; +pub mod auth; pub mod credentials; -pub mod parsers; pub mod fetch; +pub mod formatters; +pub mod heatmap; +pub mod models; +pub mod parsers; pub mod table; -pub mod heatmap; \ No newline at end of file +pub mod utils; +pub mod workouts; diff --git a/src/list.rs b/src/list.rs index 180b851..fd15c17 100644 --- a/src/list.rs +++ b/src/list.rs @@ -1,8 +1,8 @@ use crate::api::ApiClient; -use crate::workouts; -use crate::utils; use crate::formatters; -use crate::table::{parse_date_and_filter_arguments, matches_any_filter}; +use crate::table::{matches_any_filter, parse_date_and_filter_arguments}; +use crate::utils; +use crate::workouts; pub async fn handle_list( list: &crate::ListArgs, @@ -57,14 +57,17 @@ pub async fn handle_list( let workouts: Vec<_> = if filters.is_empty() { workouts } else { - workouts.into_iter().filter(|(_, jday)| { - jday.eblocks.iter().any(|eblock| { - jday.exercises.iter().any(|ex_wrap| { - ex_wrap.exercise.id == eblock.eid - && matches_any_filter(&ex_wrap.exercise.name, &filters) + workouts + .into_iter() + .filter(|(_, jday)| { + jday.eblocks.iter().any(|eblock| { + jday.exercises.iter().any(|ex_wrap| { + ex_wrap.exercise.id == eblock.eid + && matches_any_filter(&ex_wrap.exercise.name, &filters) + }) }) }) - }).collect() + .collect() }; if workouts.is_empty() { @@ -91,4 +94,4 @@ pub async fn handle_list( println!("{}", date); } } -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 4e2f24c..09bda9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,19 @@ -mod models; -mod formatters; -mod auth; mod api; -mod workouts; -mod utils; -mod parsers; +mod auth; mod fetch; -mod table; +mod formatters; mod heatmap; mod list; +mod models; +mod parsers; +mod table; +mod utils; +mod workouts; use clap::{Parser, Subcommand}; -use wxrust::credentials; use crate::api::ReqwestClient; +use wxrust::credentials; #[derive(Parser)] #[command(name = "wxrust")] @@ -40,7 +40,13 @@ struct Args { no_cache_write: bool, /// Days to scan for new workouts (0=since last cached, -1=full history) - #[arg(short = 's', long = "scan-days", default_value_t = 0, allow_hyphen_values = true, value_name = "DAYS")] + #[arg( + short = 's', + long = "scan-days", + default_value_t = 0, + allow_hyphen_values = true, + value_name = "DAYS" + )] scan_days: i32, /// When to color output: auto, always, never @@ -206,8 +212,6 @@ struct HeatmapArgs { args: Vec, } - - async fn handle_show( show: &ShowArgs, data_access: api::DataAccess<'_, ReqwestClient>, @@ -260,7 +264,9 @@ async fn handle_fetch( fetch_args.file.as_deref(), verbose, fetch_args.stats, - ).await { + ) + .await + { utils::exit_with_error(e); } } @@ -278,7 +284,9 @@ async fn main() -> Result<(), Box> { utils::exit_with_error("Error: --scan-days must be -1 or greater"); } - unsafe { std::env::set_var("WXRUST_COLOR", &args.color); } + unsafe { + std::env::set_var("WXRUST_COLOR", &args.color); + } let token_path = match workouts::get_cache_base_dir() { Ok(dir) => dir.join("token").to_string_lossy().to_string(), @@ -299,9 +307,14 @@ async fn main() -> Result<(), Box> { Err(e) => { eprintln!("ERROR: {}", e); eprintln!(); - eprintln!("Please create it with email on first line and password on second line at one of these locations:"); + eprintln!( + "Please create it with email on first line and password on second line at one of these locations:" + ); if let Some(config_dir) = dirs::config_dir() { - eprintln!("- {}", config_dir.join("wxrust").join("credentials.txt").display()); + eprintln!( + "- {}", + config_dir.join("wxrust").join("credentials.txt").display() + ); } if let Ok(home) = std::env::var("HOME") { eprintln!("- {}/.config/wxrust/credentials.txt", home); @@ -312,7 +325,14 @@ async fn main() -> Result<(), Box> { }; let client = ReqwestClient::new_with_verbose(args.verbose); - let (token, uid) = setup_auth_and_data_access(&client, &credentials_path, &token_path, args.no_network, args.force_auth).await; + let (token, uid) = setup_auth_and_data_access( + &client, + &credentials_path, + &token_path, + args.no_network, + args.force_auth, + ) + .await; let data_access = api::DataAccess { client: &client, @@ -327,16 +347,22 @@ async fn main() -> Result<(), Box> { match args.command { Commands::List(list) => { list::handle_list(&list, data_access, args.verbose).await; - }, + } Commands::Show(show) => { handle_show(&show, data_access, args.verbose).await; - }, + } Commands::Fetch(fetch_args) => { handle_fetch(&fetch_args, data_access, args.verbose).await; - }, + } Commands::Table(table_args) => { - table::handle_table(data_access, &table_args.args, &table_args.dream, args.verbose).await; - }, + table::handle_table( + data_access, + &table_args.args, + &table_args.dream, + args.verbose, + ) + .await; + } Commands::Heatmap(heatmap_args) => { // Determine metric - default to OneRm let metric = if heatmap_args.sets { @@ -359,7 +385,8 @@ async fn main() -> Result<(), Box> { heatmap_args.green, &heatmap_args.args, args.verbose, - ).await; + ) + .await; } } diff --git a/src/models.rs b/src/models.rs index 1fbd295..75997af 100644 --- a/src/models.rs +++ b/src/models.rs @@ -127,4 +127,4 @@ pub struct UserBasicInfoData { #[derive(Deserialize)] pub struct SessionInfo { pub user: User, -} \ No newline at end of file +} diff --git a/src/parsers.rs b/src/parsers.rs index 340c4c4..e485cf4 100644 --- a/src/parsers.rs +++ b/src/parsers.rs @@ -1,6 +1,6 @@ -use crate::models::{JDay, EBlock, ExerciseWrapper, Exercise, Set}; -use regex::Regex; +use crate::models::{EBlock, Exercise, ExerciseWrapper, JDay, Set}; use lazy_static::lazy_static; +use regex::Regex; pub const LBS_PER_KG: f32 = 2.20462; @@ -21,11 +21,17 @@ impl ParserOptions { } pub fn default() -> Self { - Self { user_wants_kg: true } + Self { + user_wants_kg: true, + } } } -pub fn parse_bw_line(lines: &[&str], i: &mut usize, options: &ParserOptions) -> Result, String> { +pub fn parse_bw_line( + lines: &[&str], + i: &mut usize, + options: &ParserOptions, +) -> Result, String> { // Skip empty lines while *i < lines.len() && lines[*i].trim().is_empty() { *i += 1; @@ -38,11 +44,13 @@ pub fn parse_bw_line(lines: &[&str], i: &mut usize, options: &ParserOptions) -> let convert_to_kg = match caps.get(2) { Some(unit_match) if unit_match.as_str() == "lbs" => true, Some(unit_match) if unit_match.as_str() == "kg" => false, - None if !options.user_wants_kg => true, // no units and user wants lbs, assume lbs and convert to kg - _ => false, // no units and user wants kg, assume kg + None if !options.user_wants_kg => true, // no units and user wants lbs, assume lbs and convert to kg + _ => false, // no units and user wants kg, assume kg }; if let Some(num_match) = caps.get(1) { - let bw_val: f32 = num_match.as_str().parse().map_err(|_| format!("Line {}: Invalid bw number: {}", *i + 1, num_match.as_str()))?; + let bw_val: f32 = num_match.as_str().parse().map_err(|_| { + format!("Line {}: Invalid bw number: {}", *i + 1, num_match.as_str()) + })?; let bw = if convert_to_kg { Some(bw_val / LBS_PER_KG) } else { @@ -111,7 +119,7 @@ pub fn parse_workout_with_options(text: &str, options: &ParserOptions) -> Result id: eid.clone(), name: name.clone(), ex_type: None, - } + }, }); log_lines.push(format!("EBLOCK:{}", eid)); @@ -120,7 +128,11 @@ pub fn parse_workout_with_options(text: &str, options: &ParserOptions) -> Result i += 1; let mut sets = Vec::new(); let mut failed_lines = Vec::new(); - while i < lines.len() && !lines[i].starts_with('#') && !lines[i].starts_with("//") && !lines[i].trim().is_empty() { + while i < lines.len() + && !lines[i].starts_with('#') + && !lines[i].starts_with("//") + && !lines[i].trim().is_empty() + { let line = lines[i]; let set_line = line.trim(); match parse_set_line_with_options(set_line, options) { @@ -145,10 +157,7 @@ pub fn parse_workout_with_options(text: &str, options: &ParserOptions) -> Result } else { // Valid sets found, add failed lines to log log_lines.extend(failed_lines); - eblocks.push(EBlock { - eid, - sets, - }); + eblocks.push(EBlock { eid, sets }); } } else { log_lines.push(line.to_string()); @@ -181,7 +190,12 @@ pub fn parse_workout_with_options(text: &str, options: &ParserOptions) -> Result // 405, 406 x 2 cccc - ( Set { w=405, r=2, s=1, c="" }, Set { w=406, r=2, s=1, c="cccc" } ) pub fn is_weight_part(s: &str) -> bool { - s.chars().all(|c| c.is_ascii_digit() || c == ',' || c == '.' || c == '+' || c == '-') || s.to_lowercase().starts_with("bw") || s.to_lowercase().contains("kg") || s.to_lowercase().contains("lb") || s.to_lowercase().contains("lbs") + s.chars() + .all(|c| c.is_ascii_digit() || c == ',' || c == '.' || c == '+' || c == '-') + || s.to_lowercase().starts_with("bw") + || s.to_lowercase().contains("kg") + || s.to_lowercase().contains("lb") + || s.to_lowercase().contains("lbs") } pub fn parse_weight(s: &str) -> Result<(f32, bool, i32), String> { @@ -210,8 +224,16 @@ pub fn parse_weight(s: &str) -> Result<(f32, bool, i32), String> { } else { let mut lb = false; let num_end = s.find(|c: char| !c.is_ascii_digit() && c != '.'); - let num_str = if let Some(end) = num_end { &s[..end] } else { s }; - let unit = if let Some(end) = num_end { &s[end..].trim().to_lowercase() } else { "" }; + let num_str = if let Some(end) = num_end { + &s[..end] + } else { + s + }; + let unit = if let Some(end) = num_end { + &s[end..].trim().to_lowercase() + } else { + "" + }; if unit == "lb" || unit == "lbs" { lb = true; } else if unit == "kg" { @@ -219,15 +241,15 @@ pub fn parse_weight(s: &str) -> Result<(f32, bool, i32), String> { } else if !unit.is_empty() { return Err(format!("Invalid unit: {}", unit)); } - let v: f32 = num_str.parse().map_err(|_| format!("Invalid weight: {}", num_str))?; + let v: f32 = num_str + .parse() + .map_err(|_| format!("Invalid weight: {}", num_str))?; Ok((v, lb, 0)) } } pub fn parse_weights(s: &str) -> Result, String> { - s.split(',') - .map(|p| parse_weight(p.trim())) - .collect() + s.split(',').map(|p| parse_weight(p.trim())).collect() } pub fn parse_reps_and_comment(s: &str) -> Result<(Vec, Option), String> { @@ -253,7 +275,9 @@ pub fn parse_reps_and_comment(s: &str) -> Result<(Vec, Option), Str break; } let num_str: String = chars[start..i].iter().collect(); - let num: u32 = num_str.parse().map_err(|_| format!("Invalid rep: {}", num_str))?; + let num: u32 = num_str + .parse() + .map_err(|_| format!("Invalid rep: {}", num_str))?; reps.push(num); // Skip whitespace while i < chars.len() && chars[i].is_whitespace() { @@ -303,7 +327,10 @@ pub fn parse_set_line(line: &str) -> Result, String> { parse_set_line_with_options(line, &ParserOptions::default()) } -pub fn parse_set_line_with_options(line: &str, options: &ParserOptions) -> Result, String> { +pub fn parse_set_line_with_options( + line: &str, + options: &ParserOptions, +) -> Result, String> { let line = line.trim(); let parts: Vec<&str> = line.split_whitespace().collect(); if parts.is_empty() { @@ -359,18 +386,23 @@ pub fn parse_set_line_with_options(line: &str, options: &ParserOptions) -> Resul let mut rpe = None; let mut final_comment = comment.clone(); if let Some(c) = &comment - && c.trim().starts_with('@') { - let trimmed = c.trim(); - let after_at = &trimmed[1..]; - let rpe_part_end = after_at.find(' ').unwrap_or(after_at.len()); - let rpe_part = &after_at[..rpe_part_end]; - if let Ok(r) = rpe_part.parse::() { - rpe = Some(r); - let rpe_full = &trimmed[..1 + rpe_part_end]; - let rest = trimmed.strip_prefix(rpe_full).unwrap_or(trimmed).trim(); - final_comment = if rest.is_empty() { None } else { Some(rest.to_string()) }; - } + && c.trim().starts_with('@') + { + let trimmed = c.trim(); + let after_at = &trimmed[1..]; + let rpe_part_end = after_at.find(' ').unwrap_or(after_at.len()); + let rpe_part = &after_at[..rpe_part_end]; + if let Ok(r) = rpe_part.parse::() { + rpe = Some(r); + let rpe_full = &trimmed[..1 + rpe_part_end]; + let rest = trimmed.strip_prefix(rpe_full).unwrap_or(trimmed).trim(); + final_comment = if rest.is_empty() { + None + } else { + Some(rest.to_string()) + }; } + } // Determine lb if any weights have lbs units // When user_wants_kg is false, weights without explicit units are in lbs let weights_without_unit_are_lbs = !options.user_wants_kg; diff --git a/src/table.rs b/src/table.rs index 1e28bef..b612acc 100644 --- a/src/table.rs +++ b/src/table.rs @@ -1,16 +1,16 @@ use std::collections::HashMap; use std::ops::Index; -use chrono::{NaiveDate, Utc, Datelike}; -use lazy_static::lazy_static; use ansi_term::Colour; +use chrono::{Datelike, NaiveDate, Utc}; +use lazy_static::lazy_static; use crate::api::{ApiClient, DataAccess}; -use crate::models::{JDay, EBlock, Exercise, ExerciseWrapper}; -use crate::workouts; -use crate::utils; use crate::formatters::STDERR_COLOR_ENABLED; +use crate::models::{EBlock, Exercise, ExerciseWrapper, JDay}; use crate::parsers::{LBS_PER_KG, ParserOptions, parse_set_line_with_options}; +use crate::utils; +use crate::workouts; /// Maximum reps to track for rep-specific PRs const MAX_REPS: usize = 10; @@ -20,8 +20,7 @@ const ONERM_FACTOR: f32 = 36.0; /// Color gradient for age-based coloring (256-color ANSI codes) /// From oldest (cool colors) to newest (warm colors) pub const GRADIENT: [u8; 20] = [ - 0x23, 0x24, 0x25, 0x20, 0x21, 0x3f, 0x39, 0x5d, - 0x81, 0xa5, 0xc9, 0xc8, 0xc7, 0xc6, 0xc5, 0xc4, + 0x23, 0x24, 0x25, 0x20, 0x21, 0x3f, 0x39, 0x5d, 0x81, 0xa5, 0xc9, 0xc8, 0xc7, 0xc6, 0xc5, 0xc4, 0xca, 0xd0, 0xd6, 0xdc, ]; @@ -170,37 +169,42 @@ impl TableState { } let ent_1rm = calculate_1rm(weight, reps); - let r = if reps > MAX_REPS as u32 { MAX_REPS } else { reps as usize }; + let r = if reps > MAX_REPS as u32 { + MAX_REPS + } else { + reps as usize + }; // Check if this is a new PR for this rep range let is_new_rep_pr = r <= MAX_REPS && self.best_1rm_for_reps[r] < ent_1rm; - if ! is_new_rep_pr && !is_dream { return; }; + if !is_new_rep_pr && !is_dream { + return; + }; // Check if this PR replaces an existing PR on the same day let old_index = self.best_1rm_index[r]; - let mut replacing_old_index = - if old_index >= 0 { + let mut replacing_old_index = if old_index >= 0 { let old_index = old_index as usize; let old_record = self.records.index(old_index); old_record.date == date - } else { false }; + } else { + false + }; if replacing_old_index { let old_index = old_index as usize; if self.records[old_index].best_reps == reps { - self.records[old_index].date = date.to_string(); self.records[old_index].best_weight = weight; self.records[old_index].best_1rm = ent_1rm; - } else { replacing_old_index = false; } } - if ! replacing_old_index { + if !replacing_old_index { let record = Record { date: date.to_string(), exercise_name: exercise_name.to_string(), @@ -239,7 +243,9 @@ pub fn matches_any_filter(exercise_name: &str, filters: &[String]) -> bool { return true; // No filters = match all } let name_lower = exercise_name.to_lowercase(); - filters.iter().any(|f| name_lower.contains(&f.to_lowercase())) + filters + .iter() + .any(|f| name_lower.contains(&f.to_lowercase())) } // ============================================================================ @@ -305,11 +311,7 @@ fn bg_256(color: u8) -> String { /// Reset color escape sequence fn col_reset() -> &'static str { - if *COLOR_ENABLED { - "\x1b[0m" - } else { - "" - } + if *COLOR_ENABLED { "\x1b[0m" } else { "" } } /// Calculate color index based on days since start and total days @@ -342,7 +344,11 @@ pub fn format_table(state: &TableState, filters: &[String], user_wants_kg: bool) // Sort records by 1RM (ascending, like C code) let mut sorted_records = state.records.clone(); - sorted_records.sort_by(|a, b| a.best_1rm.partial_cmp(&b.best_1rm).unwrap_or(std::cmp::Ordering::Equal)); + sorted_records.sort_by(|a, b| { + a.best_1rm + .partial_cmp(&b.best_1rm) + .unwrap_or(std::cmp::Ordering::Equal) + }); // Calculate date range let dates: Vec<&str> = sorted_records.iter().map(|r| r.date.as_str()).collect(); @@ -364,8 +370,12 @@ pub fn format_table(state: &TableState, filters: &[String], user_wants_kg: bool) } else { filters.join(", ") }; - output.push_str(&format!("There were {} records of {}, over {} days\n", - sorted_records.len(), filter_str, total_days)); + output.push_str(&format!( + "There were {} records of {}, over {} days\n", + sorted_records.len(), + filter_str, + total_days + )); // Track best weight lifted for each rep range let mut best_lifted: [f32; MAX_REPS + 1] = [0.0; MAX_REPS + 1]; @@ -385,17 +395,25 @@ pub fn format_table(state: &TableState, filters: &[String], user_wants_kg: bool) }; let col = get_gradient_color(days_since_start, total_days, days_ago); - let colbg = if record.is_dream { bg_256(DREAM_BG) } else { bg_256(0) }; + let colbg = if record.is_dream { + bg_256(DREAM_BG) + } else { + bg_256(0) + }; let coltxt = format!("{}{}", colbg, fg_256(col)); let coldim = format!("{}{}", colbg, fg_256(BRIGHT_BLACK)); - let reset = format!("{}{}", colbg, fg_256(15)); + let reset = format!("{}{}", colbg, fg_256(15)); // Convert weights for display let display_weight = convert_weight_for_display(record.best_weight, user_wants_kg); let display_1rm = convert_weight_for_display(record.best_1rm, user_wants_kg); // Track best weight for this rep range (in display units) - let r = if record.best_reps > MAX_REPS as u32 { MAX_REPS } else { record.best_reps as usize }; + let r = if record.best_reps > MAX_REPS as u32 { + MAX_REPS + } else { + record.best_reps as usize + }; if r <= MAX_REPS && best_lifted[r] < display_weight { best_lifted[r] = display_weight; best_col[r] = col; @@ -403,8 +421,10 @@ pub fn format_table(state: &TableState, filters: &[String], user_wants_kg: bool) // Format margin for high-rep sets (> MAX_REPS) let margin = if record.best_reps > MAX_REPS as u32 { - format!(" {}{:.0}{} x {}", - coltxt, display_weight, reset, record.best_reps) + format!( + " {}{:.0}{} x {}", + coltxt, display_weight, reset, record.best_reps + ) } else { String::new() }; @@ -419,12 +439,19 @@ pub fn format_table(state: &TableState, filters: &[String], user_wants_kg: bool) }; // Main row - output.push_str(&format!("{}{}{} | {}{:4}{} | {} | {:4} | {:>5} | {:5} |", - bg_256(17), fg_256(226), - "date", "days", "BW", "lift", "1RM", + output.push_str(&format!( + "{}{}{:<10} | {:>4} | {:>5} | {:5} |", + bg_256(17), + fg_256(226), + "date", + "days", + "BW", + "lift", + "1RM", lift_width = lift_width, )); for rep in 1..=MAX_REPS { @@ -458,25 +491,30 @@ pub fn format_table(state: &TableState, filters: &[String], user_wants_kg: bool) output.push_str(&format!("{}\n", col_reset())); // Best weight lifted row - output.push_str(&format!("{:>width$} |", + output.push_str(&format!( + "{:>width$} |", "best weight lifted", width = 36 + lift_width, )); for rep in 1..=MAX_REPS { let mut best = true; - for after in rep+1..MAX_REPS { + for after in rep + 1..MAX_REPS { if best_lifted[rep] <= best_lifted[after] { best = false; break; } } - let colrep = if best { fg_256(best_col[rep]) } else { fg_256(BRIGHT_BLACK) }; + let colrep = if best { + fg_256(best_col[rep]) + } else { + fg_256(BRIGHT_BLACK) + }; let reset = col_reset(); let str = if best_lifted[rep] > 0.0 { - &format!(" {}{:3.0}{} |", colrep, best_lifted[rep], reset) - } else { - " |" - }; + &format!(" {}{:3.0}{} |", colrep, best_lifted[rep], reset) + } else { + " |" + }; output.push_str(str); } output.push('\n'); @@ -515,10 +553,8 @@ pub async fn handle_table( workouts::get_dates_from_ranges(&data_access, &date_args).await } }; - let (dates_result, user_wants_kg) = tokio::join!( - dates_fut, - workouts::resolve_user_wants_kg(&data_access), - ); + let (dates_result, user_wants_kg) = + tokio::join!(dates_fut, workouts::resolve_user_wants_kg(&data_access),); let dates = match dates_result { Ok(d) => d, @@ -556,7 +592,12 @@ pub async fn handle_table( for dream in dreams { // Use today's date let today = Utc::now().date_naive(); - let date = format!("{:04}-{:02}-{:02}", today.year(), today.month(), today.day()); + let date = format!( + "{:04}-{:02}-{:02}", + today.year(), + today.month(), + today.day() + ); if verbose { let msg = format!("Dream set: {}", dream); @@ -580,7 +621,10 @@ pub async fn handle_table( }; // Use first filter as the exercise name, or "Dream" if no filters - let exercise_name = filters.first().cloned().unwrap_or_else(|| "Dream".to_string()); + let exercise_name = filters + .first() + .cloned() + .unwrap_or_else(|| "Dream".to_string()); let exercise_id = exercise_name.clone(); let jday = JDay { @@ -617,4 +661,3 @@ pub async fn handle_table( let output = format_table(&state, &filters, user_wants_kg); print!("{}", output); } - diff --git a/src/utils.rs b/src/utils.rs index 45a79ff..e1f73dc 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,4 @@ -use chrono::{NaiveDate, Datelike}; +use chrono::{Datelike, NaiveDate}; pub fn exit_with_error(error: E) -> ! { eprintln!("{}", error); @@ -45,10 +45,18 @@ pub fn parse_date_boundary(s: &str, end: bool) -> Result { let compact = parts[0]; if compact.len() == 8 { // YYYYMMDD - (compact[0..4].to_string(), compact[4..6].to_string(), compact[6..8].to_string()) + ( + compact[0..4].to_string(), + compact[4..6].to_string(), + compact[6..8].to_string(), + ) } else if compact.len() == 6 { // YYYYMM - (compact[0..4].to_string(), compact[4..6].to_string(), "".to_string()) + ( + compact[0..4].to_string(), + compact[4..6].to_string(), + "".to_string(), + ) } else if compact.len() == 4 { // YYYY (compact.to_string(), "".to_string(), "".to_string()) @@ -58,7 +66,11 @@ pub fn parse_date_boundary(s: &str, end: bool) -> Result { } else if parts.len() == 2 { (parts[0].to_string(), parts[1].to_string(), "".to_string()) } else if parts.len() == 3 { - (parts[0].to_string(), parts[1].to_string(), parts[2].to_string()) + ( + parts[0].to_string(), + parts[1].to_string(), + parts[2].to_string(), + ) } else if parts.is_empty() { return Err("Empty date string".to_string()); } else { @@ -88,15 +100,15 @@ pub fn parse_date_boundary(s: &str, end: bool) -> Result { NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap() - chrono::Duration::days(1) } else { NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap() - chrono::Duration::days(1) - }.day(); + } + .day(); let day = if end { last_day } else { 1 }; return Ok(NaiveDate::from_ymd_opt(year, month, day).unwrap()); } let day: u32 = day_str.parse().map_err(|_| "Invalid day")?; - NaiveDate::from_ymd_opt(year, month, day) - .ok_or_else(|| "Invalid date".to_string()) + NaiveDate::from_ymd_opt(year, month, day).ok_or_else(|| "Invalid date".to_string()) } pub fn create_progress_bar(len: u64) -> indicatif::ProgressBar { diff --git a/src/workouts.rs b/src/workouts.rs index 92ee993..5a4ff50 100644 --- a/src/workouts.rs +++ b/src/workouts.rs @@ -41,8 +41,13 @@ lazy_static! { static ref USER_WANTS_KG: Mutex> = Mutex::new(None); } -fn filter_dates_by_range(dates: Vec, oldest: Option<&str>, latest: Option<&str>) -> Vec { - dates.into_iter() +fn filter_dates_by_range( + dates: Vec, + oldest: Option<&str>, + latest: Option<&str>, +) -> Vec { + dates + .into_iter() .filter(|d| oldest.is_none_or(|old| d.as_str() >= old)) .filter(|d| latest.is_none_or(|lat| d.as_str() <= lat)) .collect() @@ -64,10 +69,16 @@ pub fn get_cache_base_dir() -> Result { if !dir.is_empty() { PathBuf::from(dir) } else { - std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".cache")).ok_or("No cache dir")? + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join(".cache")) + .ok_or("No cache dir")? } } else { - std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".cache")).ok_or("No cache dir")? + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join(".cache")) + .ok_or("No cache dir")? }; Ok(cache_dir.join("wxrust")) } @@ -83,7 +94,7 @@ pub fn read_cached_user_wants_kg() -> Option { None } else { let content = fs::read_to_string(&file_path).ok()?; -//eprintln!("#### RD {:?} -> {}", file_path, content.trim()); + //eprintln!("#### RD {:?} -> {}", file_path, content.trim()); match content.trim() { "0" => Some(false), "1" => Some(true), @@ -95,7 +106,7 @@ pub fn read_cached_user_wants_kg() -> Option { } }; } -//eprintln!("#### RD cache -> {:?}", *guard); + //eprintln!("#### RD cache -> {:?}", *guard); *guard } @@ -117,7 +128,7 @@ pub fn write_cached_user_wants_kg(value: bool) { let _ = fs::rename(&temp_path, &file_path); } } -//eprintln!("#### WR cache <- {}", value); + //eprintln!("#### WR cache <- {}", value); *USER_WANTS_KG.lock().unwrap() = Some(value); } @@ -137,7 +148,9 @@ fn get_cache_file_path(uid: u32, date: &str) -> Result { /// True if a cache file exists for this uid/date (does not parse the contents). pub fn cached_jday_exists(uid: u32, date: &str) -> bool { - get_cache_file_path(uid, date).map(|p| p.exists()).unwrap_or(false) + get_cache_file_path(uid, date) + .map(|p| p.exists()) + .unwrap_or(false) } pub fn jday_alias(index: usize) -> String { @@ -214,28 +227,39 @@ pub fn build_batch_jday_query(uid: u32, dates: &[String]) -> String { q } -pub fn get_dates_from_cache(uid: u32, latest: Option, oldest: Option, count: u32, reverse: bool) -> Result, String> { +pub fn get_dates_from_cache( + uid: u32, + latest: Option, + oldest: Option, + count: u32, + reverse: bool, +) -> Result, String> { let cache_dir = get_cache_dir(uid)?; if !cache_dir.exists() { return Ok(vec![]); } let mut dates: Vec = vec![]; - let entries = fs::read_dir(&cache_dir).map_err(|e| format!("Failed to read cache dir: {}", e))?; + let entries = + fs::read_dir(&cache_dir).map_err(|e| format!("Failed to read cache dir: {}", e))?; for entry in entries { let entry = entry.map_err(|e| format!("Failed to read dir entry: {}", e))?; let path = entry.path(); if path.is_file() && let Some(ext) = path.extension() - && ext == "txt" - && let Some(stem) = path.file_stem() - && let Some(date_str) = stem.to_str() { - // Basic validation: should be YYYY-MM-DD format - if date_str.len() == 10 && date_str.chars().nth(4) == Some('-') && date_str.chars().nth(7) == Some('-') { - dates.push(date_str.to_string()); - } - } + && ext == "txt" + && let Some(stem) = path.file_stem() + && let Some(date_str) = stem.to_str() + { + // Basic validation: should be YYYY-MM-DD format + if date_str.len() == 10 + && date_str.chars().nth(4) == Some('-') + && date_str.chars().nth(7) == Some('-') + { + dates.push(date_str.to_string()); + } + } } // Sort dates @@ -261,7 +285,10 @@ pub enum DateScan { /// Use cached dates only. CacheOnly, /// Union cached dates with a bounded network scan of `[oldest, latest]`. - Hybrid { oldest: NaiveDate, latest: NaiveDate }, + Hybrid { + oldest: NaiveDate, + latest: NaiveDate, + }, } /// Decide how to list workout dates given `-s/--scan-days`. @@ -322,17 +349,23 @@ pub fn lookup_cached_jday(uid: u32, date: &str, verbose: bool) -> Option(data_access: &crate::api::DataAccess<'_, C>, date: &str, verbose: bool) -> Result { +pub async fn get_jday( + data_access: &crate::api::DataAccess<'_, C>, + date: &str, + verbose: bool, +) -> Result { let uid = data_access.uid.ok_or("No user ID available")?; let client = data_access.client; // Check cache if allowed if data_access.use_cache - && let Some(jday) = lookup_cached_jday(uid, date, verbose) { - return Ok(jday); - } + && let Some(jday) = lookup_cached_jday(uid, date, verbose) + { + return Ok(jday); + } if !data_access.use_network { - return Err(format!("No workout found for {} (network access disabled)", date)); + return Err(format!( + "No workout found for {} (network access disabled)", + date + )); } - let token = data_access.token.ok_or("No token available for network request")?; + let token = data_access + .token + .ok_or("No token available for network request")?; let query = build_jday_query(uid, date); - let response: models::GraphQLResponse = api::graphql_request(client, token, &query, None).await.map_err(|e| e.to_string())?; + let response: models::GraphQLResponse = + api::graphql_request(client, token, &query, None) + .await + .map_err(|e| e.to_string())?; if let Some(errors) = response.errors { - return Err(errors.into_iter().map(|e| e.message).collect::>().join("; ")); + return Err(errors + .into_iter() + .map(|e| e.message) + .collect::>() + .join("; ")); } if let Some(data) = response.data { @@ -409,7 +459,9 @@ async fn fetch_jdays_from_network( dates: &[String], _verbose: bool, ) -> Result, String> { - let token = data_access.token.ok_or("No token available for network request")?; + let token = data_access + .token + .ok_or("No token available for network request")?; let query = build_batch_jday_query(uid, dates); let response: models::GraphQLResponse = api::graphql_request(data_access.client, token, &query, None) @@ -417,10 +469,16 @@ async fn fetch_jdays_from_network( .map_err(|e| e.to_string())?; if let Some(errors) = response.errors { - return Err(errors.into_iter().map(|e| e.message).collect::>().join("; ")); + return Err(errors + .into_iter() + .map(|e| e.message) + .collect::>() + .join("; ")); } - let mut data = response.data.ok_or_else(|| "Unexpected response.".to_string())?; + let mut data = response + .data + .ok_or_else(|| "Unexpected response.".to_string())?; let mut results = Vec::with_capacity(dates.len()); for (i, date) in dates.iter().enumerate() { let key = jday_alias(i); @@ -462,7 +520,10 @@ pub async fn get_jdays_batch( if !missing.is_empty() { if !data_access.use_network { - return Err(format!("No workout found for {} (network access disabled)", missing[0])); + return Err(format!( + "No workout found for {} (network access disabled)", + missing[0] + )); } let fetched = fetch_jdays_from_network(data_access, uid, &missing, verbose).await?; for (date, jday) in fetched { @@ -554,7 +615,11 @@ async fn fetch_jrange( .map_err(|e| e.to_string())?; if let Some(errors) = response.errors { - return Err(errors.into_iter().map(|e| e.message).collect::>().join("; ")); + return Err(errors + .into_iter() + .map(|e| e.message) + .collect::>() + .join("; ")); } let days = if let Some(data) = response.data { @@ -577,7 +642,9 @@ async fn fetch_jrange_windows( latest: NaiveDate, ) -> Result, String> { let uid = data_access.uid.ok_or("No user ID available")?; - let token = data_access.token.ok_or("No token available for network request")?; + let token = data_access + .token + .ok_or("No token available for network request")?; let windows = jrange_windows(oldest, latest); if windows.is_empty() { return Ok(vec![]); @@ -597,10 +664,20 @@ async fn fetch_jrange_windows( all_dates.dedup(); let oldest_s = oldest.format("%Y-%m-%d").to_string(); let latest_s = latest.format("%Y-%m-%d").to_string(); - Ok(filter_dates_by_range(all_dates, Some(&oldest_s), Some(&latest_s))) + Ok(filter_dates_by_range( + all_dates, + Some(&oldest_s), + Some(&latest_s), + )) } -pub async fn get_dates(data_access: &crate::api::DataAccess<'_, C>, latest: Option, oldest: Option, count: u32, reverse: bool) -> Result, String> { +pub async fn get_dates( + data_access: &crate::api::DataAccess<'_, C>, + latest: Option, + oldest: Option, + count: u32, + reverse: bool, +) -> Result, String> { let uid = data_access.uid.ok_or("No user ID available")?; if !data_access.use_network { @@ -631,7 +708,10 @@ pub async fn get_dates(data_access: &crate::api::DataA Ok(vec![]) }; } - DateScan::Hybrid { oldest: scan_oldest, latest: scan_latest } => { + DateScan::Hybrid { + oldest: scan_oldest, + latest: scan_latest, + } => { let mut dates = if data_access.use_cache { get_dates_from_cache(uid, latest.clone(), oldest.clone(), 0, false)? } else { @@ -654,11 +734,18 @@ pub async fn get_dates(data_access: &crate::api::DataA return Ok(limit_and_sort_dates(dates, count, reverse)); } - let token = data_access.token.ok_or("No token available for network request")?; + let token = data_access + .token + .ok_or("No token available for network request")?; let initial_ymd = latest.clone().unwrap_or_else(|| { let today = Utc::now().date_naive(); - format!("{:04}-{:02}-{:02}", today.year(), today.month(), today.day()) + format!( + "{:04}-{:02}-{:02}", + today.year(), + today.month(), + today.day() + ) }); let mut all_dates: Vec = Vec::new(); @@ -671,7 +758,8 @@ pub async fn get_dates(data_access: &crate::api::DataA } let batch_size = std::cmp::min(JRANGE_MAX_WEEKS as usize, want.max(1)); - let mut date_strings = fetch_jrange(data_access, uid, token, ¤t_ymd, batch_size as i32).await?; + let mut date_strings = + fetch_jrange(data_access, uid, token, ¤t_ymd, batch_size as i32).await?; if date_strings.is_empty() { break; @@ -681,7 +769,8 @@ pub async fn get_dates(data_access: &crate::api::DataA let date_count_before = all_dates.len(); - let filtered = filter_dates_by_range(date_strings.clone(), oldest.as_deref(), latest.as_deref()); + let filtered = + filter_dates_by_range(date_strings.clone(), oldest.as_deref(), latest.as_deref()); all_dates.extend(filtered); // Remove duplicates and sort @@ -701,9 +790,10 @@ pub async fn get_dates(data_access: &crate::api::DataA // Check if we reached the oldest if let Some(old) = &oldest && let Some(batch_oldest) = date_strings.first() - && batch_oldest < old { - break; - } + && batch_oldest < old + { + break; + } // Set next ymd to the oldest in this batch to get older dates if let Some(oldest_in_batch) = date_strings.first() { @@ -744,7 +834,15 @@ pub async fn get_dates_from_ranges( // limit the query to the number of days in the range let count = ((oldest - latest).num_days().abs() + 1) as u32; - let dates = match get_dates(data_access, Some(latest.to_string()), Some(oldest.to_string()), count, false).await { + let dates = match get_dates( + data_access, + Some(latest.to_string()), + Some(oldest.to_string()), + count, + false, + ) + .await + { Ok(d) => d, Err(e) => return Err(e), }; diff --git a/tests/test_api.rs b/tests/test_api.rs index 1d1d0d1..d50fd02 100644 --- a/tests/test_api.rs +++ b/tests/test_api.rs @@ -1,5 +1,5 @@ use mockall::mock; -use wxrust::api::{login_request, graphql_request}; +use wxrust::api::{graphql_request, login_request}; use wxrust::models::{GraphQLRequest, GraphQLResponse, LoginData, LoginVariables, User}; mock! { @@ -15,22 +15,24 @@ mock! { } } - - #[tokio::test] async fn test_login_request_free() { let mut mock_client = MockApiClient::new(); - mock_client - .expect_login_request() - .times(1) - .returning(|_| Ok(GraphQLResponse { - data: Some(LoginData { login: "token".to_string() }), + mock_client.expect_login_request().times(1).returning(|_| { + Ok(GraphQLResponse { + data: Some(LoginData { + login: "token".to_string(), + }), errors: None, - })); + }) + }); let request = GraphQLRequest { query: "mutation".to_string(), - variables: LoginVariables { u: "user".to_string(), p: "pass".to_string() }, + variables: LoginVariables { + u: "user".to_string(), + p: "pass".to_string(), + }, }; let result = login_request(&mock_client, &request).await; @@ -45,13 +47,15 @@ async fn test_graphql_request_free() { mock_client .expect_graphql_request::() .times(1) - .returning(|_, _, _| Ok(GraphQLResponse { - data: Some(serde_json::json!({"test": "data"})), - errors: None, - })); + .returning(|_, _, _| { + Ok(GraphQLResponse { + data: Some(serde_json::json!({"test": "data"})), + errors: None, + }) + }); let result = graphql_request(&mock_client, "token", "query", None).await; assert!(result.is_ok()); let response: GraphQLResponse = result.unwrap(); assert_eq!(response.data.unwrap()["test"], "data"); -} \ No newline at end of file +} diff --git a/tests/test_auth.rs b/tests/test_auth.rs index c8e2d27..b97c8d2 100644 --- a/tests/test_auth.rs +++ b/tests/test_auth.rs @@ -1,7 +1,7 @@ -use wxrust::auth::{decode_token, load_uid_from_cache}; use base64::{Engine, engine::general_purpose}; -use tempfile::TempDir; use std::fs; +use tempfile::TempDir; +use wxrust::auth::{decode_token, load_uid_from_cache}; #[test] fn test_decode_token_valid() { @@ -9,9 +9,11 @@ fn test_decode_token_valid() { // Header: {"alg":"HS256","typ":"JWT"} // Payload: {"id":123,"exp":2000000000} // Signature: dummy (not verified in decode_token) - let header = general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#.as_bytes()); - let payload = general_purpose::URL_SAFE_NO_PAD.encode(r#"{"id":123,"exp":2000000000}"#.as_bytes()); - let signature = "dummy_signature"; // Not used in decode + let header = + general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#.as_bytes()); + let payload = + general_purpose::URL_SAFE_NO_PAD.encode(r#"{"id":123,"exp":2000000000}"#.as_bytes()); + let signature = "dummy_signature"; // Not used in decode let token = format!("{}.{}.{}", header, payload, signature); let claims = decode_token(&token).unwrap(); @@ -47,11 +49,11 @@ fn test_decode_token_invalid_json() { fn test_load_uid_from_cache_success() { let temp_dir = TempDir::new().unwrap(); let token_path = temp_dir.path().join("token"); - + // Create a valid cached token (expires in year 2033) let cache_content = r#"{"token":"dummy.token.here","uid":456,"exp":2000000000}"#; fs::write(&token_path, cache_content).unwrap(); - + let result = load_uid_from_cache(&token_path.to_string_lossy()); assert!(result.is_ok()); assert_eq!(result.unwrap(), 456); @@ -68,9 +70,9 @@ fn test_load_uid_from_cache_file_not_found() { fn test_load_uid_from_cache_invalid_json() { let temp_dir = TempDir::new().unwrap(); let token_path = temp_dir.path().join("token"); - + fs::write(&token_path, "not valid json").unwrap(); - + let result = load_uid_from_cache(&token_path.to_string_lossy()); assert!(result.is_err()); assert!(result.unwrap_err().contains("Invalid token cache format")); @@ -80,12 +82,12 @@ fn test_load_uid_from_cache_invalid_json() { fn test_load_uid_from_cache_expired() { let temp_dir = TempDir::new().unwrap(); let token_path = temp_dir.path().join("token"); - + // Create an expired token (expired in 1990) let cache_content = r#"{"token":"dummy.token.here","uid":789,"exp":631152000}"#; fs::write(&token_path, cache_content).unwrap(); - + let result = load_uid_from_cache(&token_path.to_string_lossy()); assert!(result.is_err()); assert!(result.unwrap_err().contains("expired")); -} \ No newline at end of file +} diff --git a/tests/test_auth_integration.rs b/tests/test_auth_integration.rs index 68c04bb..23f7ffc 100644 --- a/tests/test_auth_integration.rs +++ b/tests/test_auth_integration.rs @@ -1,10 +1,10 @@ +use base64::{Engine, engine::general_purpose}; use mockall::mock; use std::fs; use tempfile::TempDir; use wxrust::auth::login; use wxrust::credentials; use wxrust::models::{GraphQLResponse, LoginData, User}; -use base64::{Engine, engine::general_purpose}; mock! { #[derive(Clone)] @@ -22,8 +22,10 @@ mock! { #[tokio::test] async fn test_login_success() { // Create a valid JWT token: header {"alg":"HS256","typ":"JWT"}, payload {"id":123,"exp":2000000000} - let header = general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#.as_bytes()); - let payload = general_purpose::URL_SAFE_NO_PAD.encode(r#"{"id":123,"exp":2000000000}"#.as_bytes()); + let header = + general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#.as_bytes()); + let payload = + general_purpose::URL_SAFE_NO_PAD.encode(r#"{"id":123,"exp":2000000000}"#.as_bytes()); let token = format!("{}.{}.{}", header, payload, "signature"); let mut mock_client = MockApiClient::new(); @@ -32,7 +34,9 @@ async fn test_login_success() { .times(1) .returning(move |_| { Ok(GraphQLResponse { - data: Some(LoginData { login: token.clone() }), + data: Some(LoginData { + login: token.clone(), + }), errors: None, }) }); @@ -46,7 +50,13 @@ async fn test_login_success() { credentials::set_credentials_path(&credentials_path.to_string_lossy()); let credentials_path = credentials::get_credentials_path().unwrap(); - let result = login(&mock_client, &credentials_path.as_str(), &token_path.to_string_lossy(), false).await; + let result = login( + &mock_client, + &credentials_path.as_str(), + &token_path.to_string_lossy(), + false, + ) + .await; assert!(result.is_ok()); let returned_token = result.unwrap(); assert!(returned_token.starts_with(&header)); @@ -58,15 +68,14 @@ async fn test_login_success() { #[tokio::test] async fn test_login_invalid_credentials() { let mut mock_client = MockApiClient::new(); - mock_client - .expect_login_request() - .times(1) - .returning(|_| { - Ok(GraphQLResponse { - data: None, - errors: Some(vec![wxrust::models::GraphQLError { message: "Invalid credentials".to_string() }]), - }) - }); + mock_client.expect_login_request().times(1).returning(|_| { + Ok(GraphQLResponse { + data: None, + errors: Some(vec![wxrust::models::GraphQLError { + message: "Invalid credentials".to_string(), + }]), + }) + }); let temp_dir = TempDir::new().unwrap(); let credentials_path = temp_dir.path().join("credentials.txt"); @@ -77,7 +86,13 @@ async fn test_login_invalid_credentials() { credentials::set_credentials_path(&credentials_path.to_string_lossy()); let credentials_path = credentials::get_credentials_path().unwrap(); - let result = login(&mock_client, &credentials_path.as_str(), &token_path.to_string_lossy(), false).await; + let result = login( + &mock_client, + &credentials_path.as_str(), + &token_path.to_string_lossy(), + false, + ) + .await; assert!(result.is_err()); assert!(result.unwrap_err().contains("Invalid credentials")); } diff --git a/tests/test_date_utils.rs b/tests/test_date_utils.rs index b3d1d15..85b221e 100644 --- a/tests/test_date_utils.rs +++ b/tests/test_date_utils.rs @@ -1,6 +1,6 @@ use chrono::{Duration, NaiveDate}; use wxrust::utils::{parse_date_boundary, parse_date_range}; -use wxrust::workouts::{jrange_windows, resolve_date_scan, DateScan, JRANGE_MAX_WEEKS}; +use wxrust::workouts::{DateScan, JRANGE_MAX_WEEKS, jrange_windows, resolve_date_scan}; fn ymd(y: i32, m: u32, d: u32) -> NaiveDate { NaiveDate::from_ymd_opt(y, m, d).unwrap() @@ -253,7 +253,10 @@ fn test_resolve_date_scan_zero_since_last_cached() { let last = ymd(2026, 8, 20); assert_eq!( resolve_date_scan(0, true, Some(last), today, None, None), - DateScan::Hybrid { oldest: last, latest: today } + DateScan::Hybrid { + oldest: last, + latest: today + } ); } diff --git a/tests/test_fetch.rs b/tests/test_fetch.rs index a0e4fe2..aac1aca 100644 --- a/tests/test_fetch.rs +++ b/tests/test_fetch.rs @@ -1,12 +1,14 @@ +use lazy_static::lazy_static; use mockall::mock; +use std::fs; use tempfile::TempDir; -use lazy_static::lazy_static; use tokio::sync::Mutex; -use wxrust::models::{GraphQLResponse, JDay, EBlock, ExerciseWrapper, Exercise, Set, User}; -use wxrust::workouts::{forget_cached_user_wants_kg, format_cached_jday_text, read_cached_jday_text}; -use wxrust::parsers::parse_workout; use wxrust::formatters::format_workout_for_cache; -use std::fs; +use wxrust::models::{EBlock, Exercise, ExerciseWrapper, GraphQLResponse, JDay, Set, User}; +use wxrust::parsers::parse_workout; +use wxrust::workouts::{ + forget_cached_user_wants_kg, format_cached_jday_text, read_cached_jday_text, +}; lazy_static! { static ref ENV_MUTEX: Mutex<()> = Mutex::new(()); @@ -51,9 +53,13 @@ fn sample_jday() -> JDay { fn restore_xdg(original: Result) { if let Ok(original) = original { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -62,7 +68,9 @@ async fn test_fetch_command_skips_cached() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); let cache_dir = temp_dir.path().join("wxrust").join("123"); @@ -70,7 +78,8 @@ async fn test_fetch_command_skips_cached() { fs::write( cache_dir.join("2023-10-01.txt"), "2023-10-01\n@ 80 kg bw\n#Squat\n135 x 5\n", - ).unwrap(); + ) + .unwrap(); let mock_client = MockApiClient::new(); let data_access = wxrust::api::DataAccess { @@ -84,7 +93,8 @@ async fn test_fetch_command_skips_cached() { }; let dates = vec!["2023-10-01".to_string()]; - let result = wxrust::fetch::fetch_command(&data_access, &dates, false, false, None, false, false).await; + let result = + wxrust::fetch::fetch_command(&data_access, &dates, false, false, None, false, false).await; assert!(result.is_ok()); restore_xdg(original_xdg_cache); @@ -95,7 +105,9 @@ async fn test_fetch_command_fetches_and_caches() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); let mut mock_client = MockApiClient::new(); @@ -137,11 +149,19 @@ async fn test_fetch_command_fetches_and_caches() { }; let dates = vec!["2023-10-01".to_string()]; - let result = wxrust::fetch::fetch_command(&data_access, &dates, false, false, None, false, false).await; + let result = + wxrust::fetch::fetch_command(&data_access, &dates, false, false, None, false, false).await; assert!(result.is_ok()); - let cache_path = temp_dir.path().join("wxrust").join("123").join("2023-10-01.txt"); - assert!(cache_path.exists(), "fetch should write cache even if write_cache is false"); + let cache_path = temp_dir + .path() + .join("wxrust") + .join("123") + .join("2023-10-01.txt"); + assert!( + cache_path.exists(), + "fetch should write cache even if write_cache is false" + ); restore_xdg(original_xdg_cache); } @@ -151,7 +171,9 @@ async fn test_fetch_command_no_dates() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); let mock_client = MockApiClient::new(); @@ -165,7 +187,16 @@ async fn test_fetch_command_no_dates() { scan_days: 0, }; - let result = wxrust::fetch::fetch_command(&data_access, &["2023-10-01".to_string()], false, false, None, false, false).await; + let result = wxrust::fetch::fetch_command( + &data_access, + &["2023-10-01".to_string()], + false, + false, + None, + false, + false, + ) + .await; assert!(result.is_ok()); restore_xdg(original_xdg_cache); @@ -176,7 +207,9 @@ async fn test_fetch_command_force_refetches() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); let cache_dir = temp_dir.path().join("wxrust").join("123"); @@ -184,7 +217,8 @@ async fn test_fetch_command_force_refetches() { fs::write( cache_dir.join("2023-10-01.txt"), "2023-10-01\n@ 80 kg bw\n#Squat\n100 x 5\n", - ).unwrap(); + ) + .unwrap(); let mut mock_client = MockApiClient::new(); mock_client @@ -225,7 +259,8 @@ async fn test_fetch_command_force_refetches() { }; let dates = vec!["2023-10-01".to_string()]; - let result = wxrust::fetch::fetch_command(&data_access, &dates, false, true, None, false, false).await; + let result = + wxrust::fetch::fetch_command(&data_access, &dates, false, true, None, false, false).await; assert!(result.is_ok()); restore_xdg(original_xdg_cache); @@ -236,7 +271,9 @@ async fn test_fetch_command_without_force_skips_network() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); let cache_dir = temp_dir.path().join("wxrust").join("123"); @@ -244,7 +281,8 @@ async fn test_fetch_command_without_force_skips_network() { fs::write( cache_dir.join("2023-10-01.txt"), "2023-10-01\n@ 80 kg bw\n#Squat\n100 x 5\n", - ).unwrap(); + ) + .unwrap(); let mut mock_client = MockApiClient::new(); mock_client @@ -277,7 +315,8 @@ async fn test_fetch_command_without_force_skips_network() { }; let dates = vec!["2023-10-01".to_string()]; - let result = wxrust::fetch::fetch_command(&data_access, &dates, false, false, None, false, false).await; + let result = + wxrust::fetch::fetch_command(&data_access, &dates, false, false, None, false, false).await; assert!(result.is_ok()); restore_xdg(original_xdg_cache); @@ -307,7 +346,9 @@ async fn test_format_text_diff_ignores_parse_roundtrip_newline() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); let jday = sample_jday(); let cache_text = format_cached_jday_text("2023-10-01", &jday); @@ -327,7 +368,9 @@ async fn test_fetch_diff_identical_cache_is_ok() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); let jday = sample_jday(); @@ -336,7 +379,8 @@ async fn test_fetch_diff_identical_cache_is_ok() { fs::write( cache_dir.join("2023-10-01.txt"), format_cached_jday_text("2023-10-01", &jday), - ).unwrap(); + ) + .unwrap(); let mut mock_client = MockApiClient::new(); mock_client @@ -365,9 +409,7 @@ async fn test_fetch_diff_identical_cache_is_ok() { errors: None, }) }); - mock_client - .expect_user_wants_kg() - .returning(|_| true); + mock_client.expect_user_wants_kg().returning(|_| true); let data_access = wxrust::api::DataAccess { client: &mock_client, @@ -380,7 +422,8 @@ async fn test_fetch_diff_identical_cache_is_ok() { }; let dates = vec!["2023-10-01".to_string()]; - let result = wxrust::fetch::fetch_command(&data_access, &dates, true, false, None, false, false).await; + let result = + wxrust::fetch::fetch_command(&data_access, &dates, true, false, None, false, false).await; assert!(result.is_ok()); assert_eq!( read_cached_jday_text(123, "2023-10-01").as_deref(), diff --git a/tests/test_formatters.rs b/tests/test_formatters.rs index ff9fed0..ea2d910 100644 --- a/tests/test_formatters.rs +++ b/tests/test_formatters.rs @@ -1,12 +1,12 @@ use wxrust::formatters::*; -use wxrust::models::{JDay, Set, Exercise, ExerciseWrapper, EBlock}; +use wxrust::models::{EBlock, Exercise, ExerciseWrapper, JDay, Set}; #[test] fn test_format_weight() { let options = FormatOptions::no_color(true); assert_eq!(format_weight(100.0, false, &options), "100"); - assert_eq!(format_weight(100.0, true, &options), "45"); // 100 lbs to kg ≈ 45.35, rounded to 45 - assert_eq!(format_weight(45.5, false, &options), "46"); // Rounded + assert_eq!(format_weight(100.0, true, &options), "45"); // 100 lbs to kg ≈ 45.35, rounded to 45 + assert_eq!(format_weight(45.5, false, &options), "46"); // Rounded } #[test] @@ -23,7 +23,9 @@ fn test_format_set() { // Without color: "135 x 5 @8 comment" // But with color, it will have ANSI codes // For test, disable color - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let formatted_no_color = format_set(&set); assert_eq!(formatted_no_color, "135 x 5 @8 comment"); } @@ -31,10 +33,24 @@ fn test_format_set() { #[test] fn test_compress_sets_same_weight() { let sets = vec![ - Set { w: Some(135.0), r: Some(5), s: Some(1), lb: Some(0.0), ..Default::default() }, - Set { w: Some(135.0), r: Some(3), s: Some(1), lb: Some(0.0), ..Default::default() }, + Set { + w: Some(135.0), + r: Some(5), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }, + Set { + w: Some(135.0), + r: Some(3), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }, ]; - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let compressed = compress_sets(&sets); assert_eq!(compressed, vec!["135 x 5, 3".to_string()]); } @@ -42,10 +58,24 @@ fn test_compress_sets_same_weight() { #[test] fn test_compress_sets_same_reps() { let sets = vec![ - Set { w: Some(135.0), r: Some(5), s: Some(1), lb: Some(0.0), ..Default::default() }, - Set { w: Some(145.0), r: Some(5), s: Some(1), lb: Some(0.0), ..Default::default() }, + Set { + w: Some(135.0), + r: Some(5), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }, + Set { + w: Some(145.0), + r: Some(5), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }, ]; - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let compressed = compress_sets(&sets); assert_eq!(compressed, vec!["135, 145 x 5".to_string()]); } @@ -53,10 +83,24 @@ fn test_compress_sets_same_reps() { #[test] fn test_compress_sets_no_compression() { let sets = vec![ - Set { w: Some(135.0), r: Some(5), s: Some(1), lb: Some(0.0), ..Default::default() }, - Set { w: Some(145.0), r: Some(3), s: Some(1), lb: Some(0.0), ..Default::default() }, + Set { + w: Some(135.0), + r: Some(5), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }, + Set { + w: Some(145.0), + r: Some(3), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }, ]; - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let compressed = compress_sets(&sets); assert_eq!(compressed.len(), 2); assert_eq!(compressed[0], "135 x 5"); @@ -66,11 +110,31 @@ fn test_compress_sets_no_compression() { #[test] fn test_compress_sets_separated_same_weight() { let sets = vec![ - Set { w: Some(135.0), r: Some(5), s: Some(1), lb: Some(0.0), ..Default::default() }, - Set { w: Some(155.0), r: Some(3), s: Some(1), lb: Some(0.0), ..Default::default() }, - Set { w: Some(135.0), r: Some(1), s: Some(1), lb: Some(0.0), ..Default::default() }, + Set { + w: Some(135.0), + r: Some(5), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }, + Set { + w: Some(155.0), + r: Some(3), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }, + Set { + w: Some(135.0), + r: Some(1), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }, ]; - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let compressed = compress_sets(&sets); assert_eq!(compressed.len(), 3); assert_eq!(compressed[0], "135 x 5"); @@ -78,8 +142,6 @@ fn test_compress_sets_separated_same_weight() { assert_eq!(compressed[2], "135 x 1"); } - - #[test] fn test_summarize_workout() { let exercise = Exercise { @@ -89,8 +151,20 @@ fn test_summarize_workout() { }; let ex_wrapper = ExerciseWrapper { exercise }; let sets = vec![ - Set { w: Some(135.0), r: Some(5), s: Some(1), lb: Some(0.0), ..Default::default() }, - Set { w: Some(145.0), r: Some(3), s: Some(1), lb: Some(0.0), ..Default::default() }, + Set { + w: Some(135.0), + r: Some(5), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }, + Set { + w: Some(145.0), + r: Some(3), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }, ]; let eblock = EBlock { eid: "ex1".to_string(), @@ -102,9 +176,11 @@ fn test_summarize_workout() { eblocks: vec![eblock], exercises: vec![ex_wrapper], }; - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let summary = summarize_workout(&jday, true, &[]); - assert_eq!(summary, "#Squat 145x3"); // Max weight 145, max reps 3 + assert_eq!(summary, "#Squat 145x3"); // Max weight 145, max reps 3 // Test with matching filter let summary_filtered = summarize_workout(&jday, true, &["squat".to_string()]); @@ -123,9 +199,13 @@ fn test_format_workout() { ex_type: Some("strength".to_string()), }; let ex_wrapper = ExerciseWrapper { exercise }; - let sets = vec![ - Set { w: Some(135.0), r: Some(5), s: Some(1), lb: Some(0.0), ..Default::default() }, - ]; + let sets = vec![Set { + w: Some(135.0), + r: Some(5), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }]; let eblock = EBlock { eid: "ex1".to_string(), sets, @@ -137,7 +217,9 @@ fn test_format_workout() { eblocks: vec![eblock], exercises: vec![ex_wrapper], }; - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let formatted = format_workout("2023-10-01", &jday, true); assert!(formatted.contains("#Squat\n135 x 5")); assert!(formatted.contains("Date: 2023-10-01")); @@ -156,14 +238,26 @@ fn test_format_workout_multiple_eblocks() { name: "Bench".to_string(), ex_type: Some("strength".to_string()), }; - let ex_wrapper1 = ExerciseWrapper { exercise: exercise1 }; - let ex_wrapper2 = ExerciseWrapper { exercise: exercise2 }; - let sets1 = vec![ - Set { w: Some(135.0), r: Some(5), s: Some(1), lb: Some(0.0), ..Default::default() }, - ]; - let sets2 = vec![ - Set { w: Some(100.0), r: Some(8), s: Some(1), lb: Some(0.0), ..Default::default() }, - ]; + let ex_wrapper1 = ExerciseWrapper { + exercise: exercise1, + }; + let ex_wrapper2 = ExerciseWrapper { + exercise: exercise2, + }; + let sets1 = vec![Set { + w: Some(135.0), + r: Some(5), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }]; + let sets2 = vec![Set { + w: Some(100.0), + r: Some(8), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }]; let eblock1 = EBlock { eid: "ex1".to_string(), sets: sets1, @@ -179,7 +273,9 @@ fn test_format_workout_multiple_eblocks() { eblocks: vec![eblock1, eblock2], exercises: vec![ex_wrapper1, ex_wrapper2], }; - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let formatted = format_workout("2023-10-01", &jday, true); assert!(formatted.contains("Date: 2023-10-01")); assert!(formatted.contains("#Squat\n135 x 5")); @@ -203,7 +299,9 @@ fn test_format_set_failed() { c: Some(":'(".to_string()), ..Default::default() }; - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let formatted = format_set(&set); // With user_wants_kg = true, 247.208 lbs -> kg ≈ 112 assert_eq!(formatted, "247 x 0 x 1 :'("); @@ -215,13 +313,12 @@ fn test_format_workout_with_failed_sets() { let json = r#"{"data":{"jday":{"log":"deadlift\nEBLOCK:54709\n--\nx#chinup\nBW0 x 5 x 3\nx#barbell-landmine-row-narrow-grip\n135, 160, 180 x 10\nx#barbell-cheat-shrugs\n315, 365, 405, 455 x 10\nx#rack-pulls #dl\n135 x 10\n225,315, 405, 495 x 5","bw":92.5329,"eblocks":[{"eid":"54709","sets":[{"w":61.235,"r":10,"s":1,"lb":1,"rpe":0,"pr":0,"est1rm":76.12996754574426,"eff":0.30241052722331635,"int":0.2432431358840727,"type":0,"t":0,"d":0,"dunit":null,"speed":0,"force":null,"c":""},{"w":102.058,"r":5,"s":1,"lb":1,"rpe":0,"pr":0,"est1rm":111.77812555698762,"eff":0.44401545109567425,"int":0.40540390237701796,"type":0,"t":0,"d":0,"dunit":null,"speed":0,"force":null,"c":""},{"w":142.882,"r":3,"s":1,"lb":1,"rpe":0,"pr":0,"est1rm":149.37622549324806,"eff":0.5933661153723603,"int":0.567568641159273,"type":0,"t":0,"d":0,"dunit":null,"speed":0,"force":null,"c":"R"},{"w":183.705,"r":2,"s":1,"lb":1,"rpe":0,"pr":0,"est1rm":187.78725717488848,"eff":0.745945982624461,"int":0.7297294076522182,"type":0,"t":0,"d":0,"dunit":null,"speed":0,"force":null,"c":"L"},{"w":224.528,"r":1,"s":1,"lb":1,"rpe":0,"pr":0,"est1rm":224.5282440185547,"eff":0.8918919426752364,"int":0.8918901741451634,"type":0,"t":0,"d":0,"dunit":null,"speed":0,"force":null,"c":"R"},{"w":256.28,"r":0,"s":2,"lb":1,"rpe":0,"pr":0,"est1rm":250.82693409970983,"eff":0.9963580417569943,"int":1.0180183043091393,"type":0,"t":0,"d":0,"dunit":null,"speed":0,"force":null,"c":""},{"w":247.208,"r":0,"s":1,"lb":1,"rpe":0,"pr":0,"est1rm":241.94811356096886,"eff":0.9610887662430057,"int":0.9819816956908606,"type":0,"t":0,"d":0,"dunit":null,"speed":0,"force":null,"c":":'("},{"w":233.6,"r":1,"s":2,"lb":1,"rpe":0,"pr":0,"est1rm":233.60008239746094,"eff":0.9279279415793646,"int":0.9279267827634422,"type":0,"t":0,"d":0,"dunit":null,"speed":0,"force":null,"c":""},{"w":210.92,"r":5,"s":1,"lb":1,"rpe":0,"pr":0,"est1rm":231.00813506417816,"eff":0.9176319676697196,"int":0.837835261217745,"type":0,"t":0,"d":0,"dunit":null,"speed":0,"force":null,"c":""},{"w":170.097,"r":10,"s":1,"lb":1,"rpe":0,"pr":0,"est1rm":211.47213576019809,"eff":0.840029256939532,"int":0.6756744947247998,"type":0,"t":0,"d":0,"dunit":null,"speed":0,"force":null,"c":""}]}],"exercises":[{"exercise":{"id":"54709","name":"deadlift #dl","type":"DL"}}]}}} "#; let response: wxrust::models::WorkoutResponse = serde_json::from_str(json).unwrap(); let jday = response.data.unwrap().jday.unwrap(); - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let formatted = format_workout("2023-02-01", &jday, false); // user_wants_kg = false, so show lbs // Check that the failed set is formatted as "247 x 0 x 1 :'(" assert!(formatted.contains("545 x 0 x 1 :'(")); // And not "247 x 1 :'(" assert!(!formatted.contains("545 x 1 :'(")); } - - - diff --git a/tests/test_heatmap.rs b/tests/test_heatmap.rs index 89b9682..b2f5396 100644 --- a/tests/test_heatmap.rs +++ b/tests/test_heatmap.rs @@ -1,5 +1,5 @@ -use wxrust::heatmap::{compute_metric, Metric}; -use wxrust::models::{JDay, Exercise, EBlock, Set}; +use wxrust::heatmap::{Metric, compute_metric}; +use wxrust::models::{EBlock, Exercise, JDay, Set}; #[test] fn test_compute_metric_sets() { @@ -93,25 +93,21 @@ fn create_test_jday() -> JDay { let eblocks = vec![ EBlock { eid: "ex1".to_string(), - sets: vec![ - Set { - w: Some(100.0), - r: Some(5), - s: Some(2), - ..Default::default() - }, - ], + sets: vec![Set { + w: Some(100.0), + r: Some(5), + s: Some(2), + ..Default::default() + }], }, EBlock { eid: "ex2".to_string(), - sets: vec![ - Set { - w: Some(100.0), - r: Some(5), - s: Some(4), - ..Default::default() - }, - ], + sets: vec![Set { + w: Some(100.0), + r: Some(5), + s: Some(4), + ..Default::default() + }], }, ]; diff --git a/tests/test_parsers.rs b/tests/test_parsers.rs index 457007f..908e9ce 100644 --- a/tests/test_parsers.rs +++ b/tests/test_parsers.rs @@ -1,8 +1,8 @@ -use wxrust::parsers::*; use wxrust::formatters::*; -use wxrust::models::{JDay, Set, Exercise, ExerciseWrapper, EBlock}; -use wxrust::workouts::{forget_cached_user_wants_kg, write_cached_user_wants_kg}; +use wxrust::models::{EBlock, Exercise, ExerciseWrapper, JDay, Set}; use wxrust::parsers::LBS_PER_KG; +use wxrust::parsers::*; +use wxrust::workouts::{forget_cached_user_wants_kg, write_cached_user_wants_kg}; fn roughly_equal(a: f32, b: f32, tolerance: f32) -> bool { (a - b).abs() < tolerance @@ -39,10 +39,19 @@ fn test_round_trip() { name: "lat-pulldown".to_string(), ex_type: None, }; - let ex_wrapper1 = ExerciseWrapper { exercise: exercise1 }; - let sets1 = vec![ - Set { w: Some(175.0), r: Some(10), s: Some(3), lb: Some(0.0), rpe: None, c: None, set_type: Some(0), usebw: None }, - ]; + let ex_wrapper1 = ExerciseWrapper { + exercise: exercise1, + }; + let sets1 = vec![Set { + w: Some(175.0), + r: Some(10), + s: Some(3), + lb: Some(0.0), + rpe: None, + c: None, + set_type: Some(0), + usebw: None, + }]; let eblock1 = EBlock { eid: "lat-pulldown".to_string(), sets: sets1, @@ -52,10 +61,19 @@ fn test_round_trip() { name: "cable-low-row".to_string(), ex_type: None, }; - let ex_wrapper2 = ExerciseWrapper { exercise: exercise2 }; - let sets2 = vec![ - Set { w: Some(175.0), r: Some(10), s: Some(3), lb: Some(0.0), rpe: None, c: None, set_type: Some(0), usebw: None }, - ]; + let ex_wrapper2 = ExerciseWrapper { + exercise: exercise2, + }; + let sets2 = vec![Set { + w: Some(175.0), + r: Some(10), + s: Some(3), + lb: Some(0.0), + rpe: None, + c: None, + set_type: Some(0), + usebw: None, + }]; let eblock2 = EBlock { eid: "cable-low-row".to_string(), sets: sets2, @@ -73,7 +91,9 @@ fn test_round_trip() { write_cached_user_wants_kg(false); // Format to full text (simulate render_workout without color) - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let _user = wxrust::models::User { usekg: Some(1) }; let full_formatted_text = format_workout_for_cache("2025-01-21", &original_jday); @@ -111,7 +131,10 @@ TM: 495 // The parsed JDay should have the correct structure assert_eq!(parsed_jday.bw, Some(105.0)); assert_eq!(parsed_jday.exercises.len(), 1); - assert_eq!(parsed_jday.exercises[0].exercise.name, "safety-squat #sq #SQ"); + assert_eq!( + parsed_jday.exercises[0].exercise.name, + "safety-squat #sq #SQ" + ); assert_eq!(parsed_jday.eblocks.len(), 1); assert_eq!(parsed_jday.eblocks[0].eid, "safety-squat #sq #SQ"); assert_eq!(parsed_jday.eblocks[0].sets.len(), 6); // 165x10, 255x5, 305x3, 360x3, 415x3, 455x6 @@ -121,7 +144,9 @@ TM: 495 assert_eq!(parsed_jday.log, expected_log); // Format back and check it matches the input - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let reformatted = format_workout_no_color("2025-12-26", &parsed_jday, true); assert_eq!(reformatted, cache_text); } @@ -152,20 +177,26 @@ TM: 485 // The parsed JDay should have the correct structure assert_eq!(parsed_jday.bw, Some(102.9656)); assert_eq!(parsed_jday.exercises.len(), 1); - assert_eq!(parsed_jday.exercises[0].exercise.name, "safety-box-squat #sq"); + assert_eq!( + parsed_jday.exercises[0].exercise.name, + "safety-box-squat #sq" + ); assert_eq!(parsed_jday.eblocks.len(), 1); assert_eq!(parsed_jday.eblocks[0].eid, "safety-box-squat #sq"); assert_eq!(parsed_jday.eblocks[0].sets.len(), 7); // 195x10, 245x5, 295x3, 375x5, 425x3, 472x7, 375x5 // Check some sets - assert_eq!(parsed_jday.eblocks[0].sets[0].w, Some(195.0/LBS_PER_KG)); + assert_eq!(parsed_jday.eblocks[0].sets[0].w, Some(195.0 / LBS_PER_KG)); assert_eq!(parsed_jday.eblocks[0].sets[0].r, Some(10)); assert_eq!(parsed_jday.eblocks[0].sets[0].s, Some(1)); - assert_eq!(parsed_jday.eblocks[0].sets[5].w, Some(472.0/LBS_PER_KG)); + assert_eq!(parsed_jday.eblocks[0].sets[5].w, Some(472.0 / LBS_PER_KG)); assert_eq!(parsed_jday.eblocks[0].sets[5].r, Some(7)); assert_eq!(parsed_jday.eblocks[0].sets[5].s, Some(1)); - assert_eq!(parsed_jday.eblocks[0].sets[5].c, Some("hard, but rewarding".to_string())); - assert_eq!(parsed_jday.eblocks[0].sets[6].w, Some(375.0/LBS_PER_KG)); + assert_eq!( + parsed_jday.eblocks[0].sets[5].c, + Some("hard, but rewarding".to_string()) + ); + assert_eq!(parsed_jday.eblocks[0].sets[6].w, Some(375.0 / LBS_PER_KG)); assert_eq!(parsed_jday.eblocks[0].sets[6].r, Some(5)); assert_eq!(parsed_jday.eblocks[0].sets[6].s, Some(1)); assert_eq!(parsed_jday.eblocks[0].sets[6].c, Some("AMRAP".to_string())); @@ -206,8 +237,14 @@ shoulders need more work assert_eq!(parsed_jday.bw, Some(102.9656)); assert_eq!(parsed_jday.exercises.len(), 3); assert_eq!(parsed_jday.exercises[0].exercise.name, "cambered-ohp #ohp"); - assert_eq!(parsed_jday.exercises[1].exercise.name, "dumbbell-side-raise"); - assert_eq!(parsed_jday.exercises[2].exercise.name, "weight-plate-front-raise"); + assert_eq!( + parsed_jday.exercises[1].exercise.name, + "dumbbell-side-raise" + ); + assert_eq!( + parsed_jday.exercises[2].exercise.name, + "weight-plate-front-raise" + ); assert_eq!(parsed_jday.eblocks.len(), 3); assert_eq!(parsed_jday.eblocks[0].eid, "cambered-ohp #ohp"); assert_eq!(parsed_jday.eblocks[0].sets.len(), 7); // 70x10, 90x5, 110x3, 119x5, 138x5, 156x7, 120x10 @@ -217,12 +254,12 @@ shoulders need more work assert_eq!(parsed_jday.eblocks[2].sets.len(), 1); // 25x10x3 // Check some sets - assert_eq!(parsed_jday.eblocks[0].sets[0].w, Some(70.0/LBS_PER_KG)); + assert_eq!(parsed_jday.eblocks[0].sets[0].w, Some(70.0 / LBS_PER_KG)); assert_eq!(parsed_jday.eblocks[0].sets[0].r, Some(10)); assert_eq!(parsed_jday.eblocks[0].sets[6].c, Some("AMRAP".to_string())); - assert_eq!(parsed_jday.eblocks[1].sets[0].w, Some(5.0/LBS_PER_KG)); + assert_eq!(parsed_jday.eblocks[1].sets[0].w, Some(5.0 / LBS_PER_KG)); assert_eq!(parsed_jday.eblocks[1].sets[0].r, Some(10)); - assert_eq!(parsed_jday.eblocks[2].sets[0].w, Some(25.0/LBS_PER_KG)); + assert_eq!(parsed_jday.eblocks[2].sets[0].w, Some(25.0 / LBS_PER_KG)); assert_eq!(parsed_jday.eblocks[2].sets[0].r, Some(10)); assert_eq!(parsed_jday.eblocks[2].sets[0].s, Some(3)); @@ -340,16 +377,19 @@ TM: 465 // The parsed JDay should have the correct structure assert_eq!(parsed_jday.bw, Some(100.69763)); assert_eq!(parsed_jday.exercises.len(), 1); - assert_eq!(parsed_jday.exercises[0].exercise.name, "safety-box-squat #sq"); + assert_eq!( + parsed_jday.exercises[0].exercise.name, + "safety-box-squat #sq" + ); assert_eq!(parsed_jday.eblocks.len(), 1); assert_eq!(parsed_jday.eblocks[0].eid, "safety-box-squat #sq"); assert_eq!(parsed_jday.eblocks[0].sets.len(), 8); // 135x10, 235x5, 285x3, 350x5, 405x3, 445x1, 445x3, 350x5 // Check the compressed reps sets - assert_eq!(parsed_jday.eblocks[0].sets[5].w, Some(445.0/LBS_PER_KG)); + assert_eq!(parsed_jday.eblocks[0].sets[5].w, Some(445.0 / LBS_PER_KG)); assert_eq!(parsed_jday.eblocks[0].sets[5].r, Some(1)); assert_eq!(parsed_jday.eblocks[0].sets[5].s, Some(1)); - assert_eq!(parsed_jday.eblocks[0].sets[6].w, Some(445.0/LBS_PER_KG)); + assert_eq!(parsed_jday.eblocks[0].sets[6].w, Some(445.0 / LBS_PER_KG)); assert_eq!(parsed_jday.eblocks[0].sets[6].r, Some(3)); assert_eq!(parsed_jday.eblocks[0].sets[6].s, Some(1)); assert_eq!(parsed_jday.eblocks[0].sets[7].c, Some("AMRAP".to_string())); @@ -370,7 +410,9 @@ TM: 415 135 x 10 185, 225 x 5 285, 335, 375 x 3 -"#.to_string() + "\n"; +"# + .to_string() + + "\n"; // Parse the cache text let parsed_jday = parse_workout(&cache_text).unwrap(); @@ -437,8 +479,8 @@ fn test_parse_units() { // Test units let sets = parse_set_line("405 lb x 5").unwrap(); assert_eq!(sets.len(), 1); - assert_eq!(sets[0].w, Some(405.0/LBS_PER_KG)); // w is always in kg - assert_eq!(sets[0].lb, Some(1.0)); // lb==1 if user entered it in lbs + assert_eq!(sets[0].w, Some(405.0 / LBS_PER_KG)); // w is always in kg + assert_eq!(sets[0].lb, Some(1.0)); // lb==1 if user entered it in lbs let sets = parse_set_line("180 kg x 5").unwrap(); assert_eq!(sets.len(), 1); @@ -458,7 +500,9 @@ fn test_format_rpe() { set_type: Some(0), usebw: None, }; - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let formatted = format_set(&set); assert_eq!(formatted, "405 x 5 @8"); } @@ -497,7 +541,9 @@ fn test_format_bw() { set_type: Some(0), usebw: Some(1), }; - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let formatted = format_set(&set); assert_eq!(formatted, "BW x 10"); @@ -538,7 +584,7 @@ fn test_parser_options_kg() { "; let options = ParserOptions::new(true); // user wants kg let jday = parse_workout_with_options(text, &options).unwrap(); - + assert_eq!(jday.bw, Some(100.0)); // 100 kg assert_eq!(jday.eblocks[0].sets[0].w, Some(200.0)); // 200 kg } @@ -553,7 +599,7 @@ fn test_parser_options_lbs() { "; let options = ParserOptions::new(false); // user wants lbs let jday = parse_workout_with_options(text, &options).unwrap(); - + // Body weight explicitly marked as lbs, so it converts to kg let expected_bw_kg = 220.0 / LBS_PER_KG; assert!( @@ -562,7 +608,7 @@ fn test_parser_options_lbs() { jday.bw.unwrap(), expected_bw_kg ); - + // Weight without explicit unit, should be interpreted as lbs and converted to kg let expected_weight_kg = 440.0 / LBS_PER_KG; assert!( @@ -578,7 +624,7 @@ fn test_parser_options_round_trip_lbs() { // This test simulates the issue: format with lbs preference, then parse with lbs preference forget_cached_user_wants_kg(); write_cached_user_wants_kg(false); // User prefers lbs - + // Create a workout with 100kg weight let exercise = Exercise { id: "squat".to_string(), @@ -586,9 +632,16 @@ fn test_parser_options_round_trip_lbs() { ex_type: None, }; let ex_wrapper = ExerciseWrapper { exercise }; - let sets = vec![ - Set { w: Some(100.0), r: Some(5), s: Some(1), lb: Some(0.0), rpe: None, c: None, set_type: Some(0), usebw: None }, - ]; + let sets = vec![Set { + w: Some(100.0), + r: Some(5), + s: Some(1), + lb: Some(0.0), + rpe: None, + c: None, + set_type: Some(0), + usebw: None, + }]; let eblock = EBlock { eid: "squat".to_string(), sets, @@ -599,23 +652,26 @@ fn test_parser_options_round_trip_lbs() { eblocks: vec![eblock], exercises: vec![ex_wrapper], }; - + // Format for cache (will use lbs since user wants lbs) let cache_text = format_workout_for_cache("2025-01-21", &jday); - + eprintln!("Cache text:\n{}", cache_text); - + // The cache should show weights in lbs assert!(cache_text.contains("220")); // 100kg * 2.20462 ≈ 220 lbs bodyweight assert!(cache_text.contains("lbs bw")); - + // Now parse it back with user wants lbs let options = ParserOptions::new(false); // user wants lbs let parsed = parse_workout_with_options(&cache_text, &options).unwrap(); - + eprintln!("Original weight (kg): {}", 100.0); - eprintln!("Parsed weight (kg): {}", parsed.eblocks[0].sets[0].w.unwrap()); - + eprintln!( + "Parsed weight (kg): {}", + parsed.eblocks[0].sets[0].w.unwrap() + ); + // The parsed weight should be back to ~100kg // Note: There will be rounding error because format_workout_for_cache formats weights as // integer lbs (220 lbs), so 100kg → 220.462 lbs → 220 lbs → 99.79kg @@ -625,7 +681,7 @@ fn test_parser_options_round_trip_lbs() { parsed.eblocks[0].sets[0].w.unwrap(), 100.0 ); - + // Body weight has more precision (4 decimal places), so it should be closer assert!( roughly_equal(parsed.bw.unwrap(), 100.0, 0.01), @@ -633,7 +689,7 @@ fn test_parser_options_round_trip_lbs() { parsed.bw.unwrap(), 100.0 ); - + // Cleanup forget_cached_user_wants_kg(); } @@ -666,9 +722,21 @@ fn test_bw_parsing_without_units_bug() { let expected_60_kg = 60.0 / LBS_PER_KG; let expected_100_kg = 100.0 / LBS_PER_KG; - assert!(roughly_equal(jday.eblocks[0].sets[0].w.unwrap(), expected_50_kg, 0.01)); - assert!(roughly_equal(jday.eblocks[1].sets[0].w.unwrap(), expected_60_kg, 0.01)); - assert!(roughly_equal(jday.eblocks[2].sets[0].w.unwrap(), expected_100_kg, 0.01)); + assert!(roughly_equal( + jday.eblocks[0].sets[0].w.unwrap(), + expected_50_kg, + 0.01 + )); + assert!(roughly_equal( + jday.eblocks[1].sets[0].w.unwrap(), + expected_60_kg, + 0.01 + )); + assert!(roughly_equal( + jday.eblocks[2].sets[0].w.unwrap(), + expected_100_kg, + 0.01 + )); // After fix: both bw and weights are consistently treated as lbs and converted to kg -} \ No newline at end of file +} diff --git a/tests/test_table.rs b/tests/test_table.rs index f32a730..9b67b6f 100644 --- a/tests/test_table.rs +++ b/tests/test_table.rs @@ -1,5 +1,5 @@ +use wxrust::models::{EBlock, Exercise, ExerciseWrapper, JDay, Set}; use wxrust::table::*; -use wxrust::models::{JDay, Set, Exercise, ExerciseWrapper, EBlock}; // ============================================================================ // 1RM Calculation Tests @@ -376,7 +376,9 @@ fn test_format_table_with_records() { let filters: Vec = vec!["dead".to_string()]; // Disable color for testing - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let output = format_table(&state, &filters, true); @@ -393,7 +395,9 @@ fn test_format_table_header_row() { let filters: Vec = vec![]; - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let output = format_table(&state, &filters, true); @@ -412,7 +416,9 @@ fn test_format_table_best_weight_row() { let filters: Vec = vec![]; - unsafe { std::env::set_var("WXRUST_COLOR", "never"); } + unsafe { + std::env::set_var("WXRUST_COLOR", "never"); + } let output = format_table(&state, &filters, true); diff --git a/tests/test_workouts.rs b/tests/test_workouts.rs index ef177fd..7eb4622 100644 --- a/tests/test_workouts.rs +++ b/tests/test_workouts.rs @@ -1,12 +1,20 @@ -use mockall::mock; -use wxrust::workouts::{get_jday, get_dates, get_dates_from_cache, get_jdays, get_jdays_batch, get_jdays_with_callback, read_cached_user_wants_kg, read_cached_user_wants_kg_or, write_cached_user_wants_kg, forget_cached_user_wants_kg, cached_jday_exists, read_cached_jday_text, format_cached_jday_text, write_cached_jday, jday_alias, chunk_dates, build_jday_query, build_batch_jday_query, latest_cached_date, JDAY_BATCH_SIZE}; -use chrono::{Duration, Utc}; -use wxrust::models::{GraphQLResponse, WorkoutData, JDay, EBlock, ExerciseWrapper, Exercise, Set, User}; use base64::{Engine, engine::general_purpose}; -use tempfile::TempDir; +use chrono::{Duration, Utc}; use lazy_static::lazy_static; -use tokio::sync::Mutex; +use mockall::mock; use std::fs; +use tempfile::TempDir; +use tokio::sync::Mutex; +use wxrust::models::{ + EBlock, Exercise, ExerciseWrapper, GraphQLResponse, JDay, Set, User, WorkoutData, +}; +use wxrust::workouts::{ + JDAY_BATCH_SIZE, build_batch_jday_query, build_jday_query, cached_jday_exists, chunk_dates, + forget_cached_user_wants_kg, format_cached_jday_text, get_dates, get_dates_from_cache, + get_jday, get_jdays, get_jdays_batch, get_jdays_with_callback, jday_alias, latest_cached_date, + read_cached_jday_text, read_cached_user_wants_kg, read_cached_user_wants_kg_or, + write_cached_jday, write_cached_user_wants_kg, +}; // all tests in this file run sequentially to avoid clashing with global cached state; // to do this we use an async-aware mutex and hold it in each test, @@ -34,10 +42,14 @@ async fn test_get_jday_graphql_error() { // Set up test cache directory let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } - let header = general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#.as_bytes()); - let payload = general_purpose::URL_SAFE_NO_PAD.encode(r#"{"id":123,"exp":2000000000}"#.as_bytes()); + let header = + general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#.as_bytes()); + let payload = + general_purpose::URL_SAFE_NO_PAD.encode(r#"{"id":123,"exp":2000000000}"#.as_bytes()); let token = format!("{}.{}.{}", header, payload, "signature"); let mut mock_client = MockApiClient::new(); @@ -47,7 +59,9 @@ async fn test_get_jday_graphql_error() { .returning(|_, _, _| { Ok(GraphQLResponse { data: None, - errors: Some(vec![wxrust::models::GraphQLError { message: "GraphQL error".to_string() }]), + errors: Some(vec![wxrust::models::GraphQLError { + message: "GraphQL error".to_string(), + }]), }) }); @@ -67,9 +81,13 @@ async fn test_get_jday_graphql_error() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -79,11 +97,15 @@ async fn test_get_jday_success() { // Set up test cache directory let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } // Create a valid JWT token - let header = general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#.as_bytes()); - let payload = general_purpose::URL_SAFE_NO_PAD.encode(r#"{"id":123,"exp":2000000000}"#.as_bytes()); + let header = + general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#.as_bytes()); + let payload = + general_purpose::URL_SAFE_NO_PAD.encode(r#"{"id":123,"exp":2000000000}"#.as_bytes()); let token = format!("{}.{}.{}", header, payload, "signature"); let mut mock_client = MockApiClient::new(); @@ -141,9 +163,13 @@ async fn test_get_jday_success() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -153,10 +179,14 @@ async fn test_get_jday_no_workout() { // Set up test cache directory let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } - let header = general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#.as_bytes()); - let payload = general_purpose::URL_SAFE_NO_PAD.encode(r#"{"id":123,"exp":2000000000}"#.as_bytes()); + let header = + general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#.as_bytes()); + let payload = + general_purpose::URL_SAFE_NO_PAD.encode(r#"{"id":123,"exp":2000000000}"#.as_bytes()); let token = format!("{}.{}.{}", header, payload, "signature"); let mut mock_client = MockApiClient::new(); @@ -190,9 +220,13 @@ async fn test_get_jday_no_workout() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -202,7 +236,9 @@ async fn test_get_jday_invalid_token() { // Set up test cache directory let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } let token = "invalid"; let mut mock_client = MockApiClient::new(); @@ -210,9 +246,7 @@ async fn test_get_jday_invalid_token() { mock_client .expect_graphql_request::() .times(1) - .returning(|_, _, _| { - Err("Authentication error".into()) - }); + .returning(|_, _, _| Err("Authentication error".into())); let data_access = wxrust::api::DataAccess { client: &mock_client, @@ -228,9 +262,13 @@ async fn test_get_jday_invalid_token() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -240,10 +278,14 @@ async fn test_get_dates_success() { // Set up test cache directory let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } - let header = general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#.as_bytes()); - let payload = general_purpose::URL_SAFE_NO_PAD.encode(r#"{"id":123,"exp":2000000000}"#.as_bytes()); + let header = + general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#.as_bytes()); + let payload = + general_purpose::URL_SAFE_NO_PAD.encode(r#"{"id":123,"exp":2000000000}"#.as_bytes()); let token = format!("{}.{}.{}", header, payload, "signature"); let mut mock_client = MockApiClient::new(); @@ -280,9 +322,13 @@ async fn test_get_dates_success() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -291,7 +337,9 @@ async fn test_get_dates_bounded_range() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } let mut mock_client = MockApiClient::new(); mock_client @@ -326,14 +374,19 @@ async fn test_get_dates_bounded_range() { Some("2023-10-01".to_string()), 1, false, - ).await; + ) + .await; assert!(result.is_ok()); assert_eq!(result.unwrap(), vec!["2023-10-01".to_string()]); if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -343,7 +396,9 @@ async fn test_get_dates_invalid_token() { // Set up test cache directory let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } let token = "invalid"; let mut mock_client = MockApiClient::new(); @@ -351,9 +406,7 @@ async fn test_get_dates_invalid_token() { mock_client .expect_graphql_request::() .times(1) - .returning(|_, _, _| { - Err("Authentication error".into()) - }); + .returning(|_, _, _| Err("Authentication error".into())); let data_access = wxrust::api::DataAccess { client: &mock_client, @@ -369,9 +422,13 @@ async fn test_get_dates_invalid_token() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -380,7 +437,9 @@ async fn test_read_cached_user_wants_kg_none() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); @@ -389,9 +448,13 @@ async fn test_read_cached_user_wants_kg_none() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -400,7 +463,9 @@ async fn test_read_cached_user_wants_kg_true() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); @@ -413,9 +478,13 @@ async fn test_read_cached_user_wants_kg_true() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -424,7 +493,9 @@ async fn test_read_cached_user_wants_kg_false() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); @@ -437,9 +508,13 @@ async fn test_read_cached_user_wants_kg_false() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -448,7 +523,9 @@ async fn test_read_cached_user_wants_kg_invalid() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); @@ -462,9 +539,13 @@ async fn test_read_cached_user_wants_kg_invalid() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -473,7 +554,9 @@ async fn test_read_cached_user_wants_kg_or() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); @@ -490,9 +573,13 @@ async fn test_read_cached_user_wants_kg_or() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -501,7 +588,9 @@ async fn test_write_cached_user_wants_kg() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); @@ -517,9 +606,13 @@ async fn test_write_cached_user_wants_kg() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -528,7 +621,9 @@ async fn test_get_dates_from_cache_empty_dir() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } // No cache directory exists let result = get_dates_from_cache(123, None, None, 10, false); @@ -537,9 +632,13 @@ async fn test_get_dates_from_cache_empty_dir() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -548,7 +647,9 @@ async fn test_get_dates_from_cache_with_files() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } // Create cache directory and files let cache_dir = temp_dir.path().join("wxrust").join("456"); @@ -566,9 +667,13 @@ async fn test_get_dates_from_cache_with_files() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -577,7 +682,9 @@ async fn test_get_dates_from_cache_with_filters() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } // Create cache directory and files let cache_dir = temp_dir.path().join("wxrust").join("789"); @@ -601,16 +708,26 @@ async fn test_get_dates_from_cache_with_filters() { assert_eq!(dates, vec!["2025-01-01", "2025-01-05", "2025-01-10"]); // Test with both filters - let result = get_dates_from_cache(789, Some("2025-01-15".to_string()), Some("2025-01-05".to_string()), 0, false); + let result = get_dates_from_cache( + 789, + Some("2025-01-15".to_string()), + Some("2025-01-05".to_string()), + 0, + false, + ); assert!(result.is_ok()); let dates = result.unwrap(); assert_eq!(dates, vec!["2025-01-05", "2025-01-10", "2025-01-15"]); // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -619,7 +736,9 @@ async fn test_get_dates_from_cache_with_count() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } // Create cache directory and files let cache_dir = temp_dir.path().join("wxrust").join("999"); @@ -638,9 +757,13 @@ async fn test_get_dates_from_cache_with_count() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -649,7 +772,9 @@ async fn test_get_dates_from_cache_with_reverse() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } // Create cache directory and files let cache_dir = temp_dir.path().join("wxrust").join("111"); @@ -666,9 +791,13 @@ async fn test_get_dates_from_cache_with_reverse() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -701,10 +830,12 @@ async fn test_resolve_user_wants_kg_without_token() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); - + // Set cache to false let wxrust_dir = temp_dir.path().join("wxrust"); std::fs::create_dir_all(&wxrust_dir).unwrap(); @@ -728,9 +859,13 @@ async fn test_resolve_user_wants_kg_without_token() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -739,7 +874,9 @@ async fn test_get_dates_from_ranges() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } // Setup cache let cache_dir = temp_dir.path().join("wxrust").join("123"); @@ -761,7 +898,10 @@ async fn test_get_dates_from_ranges() { scan_days: 0, }; - let ranges = vec!["2023-10-01..2023-10-02".to_string(), "2023-10-05".to_string()]; + let ranges = vec![ + "2023-10-01..2023-10-02".to_string(), + "2023-10-05".to_string(), + ]; let result = wxrust::workouts::get_dates_from_ranges(&data_access, &ranges).await; assert!(result.is_ok()); @@ -770,9 +910,13 @@ async fn test_get_dates_from_ranges() { // Restore original XDG_CACHE_HOME if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -847,7 +991,9 @@ async fn test_cached_jday_exists() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); @@ -861,9 +1007,13 @@ async fn test_cached_jday_exists() { assert!(!cached_jday_exists(123, "2023-10-02")); if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -872,7 +1022,9 @@ async fn test_read_and_format_cached_jday_text() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); assert!(read_cached_jday_text(123, "2023-10-01").is_none()); @@ -905,9 +1057,13 @@ async fn test_read_and_format_cached_jday_text() { assert!(written.ends_with('\n')); if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -952,7 +1108,9 @@ async fn test_get_jdays_batch_success() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); let mut mock_client = MockApiClient::new(); @@ -990,9 +1148,13 @@ async fn test_get_jdays_batch_success() { assert_eq!(workouts[1].1.log, "log-b"); if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -1025,7 +1187,11 @@ async fn test_get_jdays_batch_missing_workout() { let dates = vec!["2023-10-01".to_string()]; let result = get_jdays_batch(&data_access, &dates, false).await; assert!(result.is_err()); - assert!(result.unwrap_err().contains("No workout found for 2023-10-01")); + assert!( + result + .unwrap_err() + .contains("No workout found for 2023-10-01") + ); } #[tokio::test] @@ -1038,7 +1204,9 @@ async fn test_get_jdays_batch_graphql_error() { .returning(|_, _, _| { Ok(GraphQLResponse { data: None, - errors: Some(vec![wxrust::models::GraphQLError { message: "boom".to_string() }]), + errors: Some(vec![wxrust::models::GraphQLError { + message: "boom".to_string(), + }]), }) }); @@ -1063,7 +1231,9 @@ async fn test_get_jdays_with_callback_chunks() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); let mut mock_client = MockApiClient::new(); @@ -1103,14 +1273,10 @@ async fn test_get_jdays_with_callback_chunks() { "2023-10-03".to_string(), ]; let mut seen = Vec::new(); - let result = get_jdays_with_callback( - &data_access, - &dates, - 1, - 2, - false, - |date, _jday| seen.push(date.to_string()), - ).await; + let result = get_jdays_with_callback(&data_access, &dates, 1, 2, false, |date, _jday| { + seen.push(date.to_string()) + }) + .await; assert!(result.is_ok()); let workouts = result.unwrap(); @@ -1125,9 +1291,13 @@ async fn test_get_jdays_with_callback_chunks() { assert_eq!(seen.len(), 3); if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -1136,7 +1306,9 @@ async fn test_get_jdays_batch_uses_cache() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } forget_cached_user_wants_kg(); let cache_dir = temp_dir.path().join("wxrust").join("123"); @@ -1144,7 +1316,8 @@ async fn test_get_jdays_batch_uses_cache() { fs::write( cache_dir.join("2023-10-01.txt"), "2023-10-01\n@ 80 kg bw\n#Squat\n135 x 5\n", - ).unwrap(); + ) + .unwrap(); // No network calls expected — served from cache let mock_client = MockApiClient::new(); @@ -1167,17 +1340,25 @@ async fn test_get_jdays_batch_uses_cache() { assert_eq!(workouts[0].1.exercises[0].exercise.name, "Squat"); if let Ok(original) = original_xdg_cache { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } fn restore_xdg_cache(original: Result) { if let Ok(original) = original { - unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", original); + } } else { - unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + unsafe { + std::env::remove_var("XDG_CACHE_HOME"); + } } } @@ -1194,7 +1375,9 @@ async fn test_latest_cached_date() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } assert_eq!(latest_cached_date(123), None); @@ -1217,14 +1400,20 @@ async fn test_get_dates_scan_zero_skips_network_when_current() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } let today = Utc::now().date_naive(); let old = today - Duration::days(40); let cache_dir = temp_dir.path().join("wxrust").join("123"); fs::create_dir_all(&cache_dir).unwrap(); fs::write(cache_dir.join(format!("{}.txt", ymd_string(old))), "old").unwrap(); - fs::write(cache_dir.join(format!("{}.txt", ymd_string(today))), "today").unwrap(); + fs::write( + cache_dir.join(format!("{}.txt", ymd_string(today))), + "today", + ) + .unwrap(); let mock_client = MockApiClient::new(); let data_access = wxrust::api::DataAccess { @@ -1237,7 +1426,9 @@ async fn test_get_dates_scan_zero_skips_network_when_current() { scan_days: 0, }; - let result = get_dates(&data_access, None, None, 10000, false).await.unwrap(); + let result = get_dates(&data_access, None, None, 10000, false) + .await + .unwrap(); assert_eq!(result, vec![ymd_string(old), ymd_string(today)]); restore_xdg_cache(original_xdg_cache); @@ -1248,7 +1439,9 @@ async fn test_get_dates_scan_zero_merges_since_last_cached() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } let today = Utc::now().date_naive(); let yesterday = today - Duration::days(1); @@ -1256,7 +1449,11 @@ async fn test_get_dates_scan_zero_merges_since_last_cached() { let cache_dir = temp_dir.path().join("wxrust").join("123"); fs::create_dir_all(&cache_dir).unwrap(); fs::write(cache_dir.join(format!("{}.txt", ymd_string(old))), "old").unwrap(); - fs::write(cache_dir.join(format!("{}.txt", ymd_string(yesterday))), "y").unwrap(); + fs::write( + cache_dir.join(format!("{}.txt", ymd_string(yesterday))), + "y", + ) + .unwrap(); let today_s = ymd_string(today); let mut mock_client = MockApiClient::new(); @@ -1284,7 +1481,9 @@ async fn test_get_dates_scan_zero_merges_since_last_cached() { scan_days: 0, }; - let result = get_dates(&data_access, None, None, 10000, false).await.unwrap(); + let result = get_dates(&data_access, None, None, 10000, false) + .await + .unwrap(); assert_eq!( result, vec![ymd_string(old), ymd_string(yesterday), ymd_string(today)] @@ -1298,14 +1497,20 @@ async fn test_get_dates_scan_seven_merges_cache() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } let today = Utc::now().date_naive(); let old = chrono::NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); let cache_dir = temp_dir.path().join("wxrust").join("123"); fs::create_dir_all(&cache_dir).unwrap(); fs::write(cache_dir.join(format!("{}.txt", ymd_string(old))), "old").unwrap(); - fs::write(cache_dir.join(format!("{}.txt", ymd_string(today))), "today").unwrap(); + fs::write( + cache_dir.join(format!("{}.txt", ymd_string(today))), + "today", + ) + .unwrap(); let today_s = ymd_string(today); let mut mock_client = MockApiClient::new(); @@ -1333,7 +1538,9 @@ async fn test_get_dates_scan_seven_merges_cache() { scan_days: 7, }; - let result = get_dates(&data_access, None, None, 10000, false).await.unwrap(); + let result = get_dates(&data_access, None, None, 10000, false) + .await + .unwrap(); assert_eq!(result, vec![ymd_string(old), ymd_string(today)]); restore_xdg_cache(original_xdg_cache); @@ -1344,7 +1551,9 @@ async fn test_get_dates_scan_zero_no_cache_is_full() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } let mut mock_client = MockApiClient::new(); mock_client @@ -1382,13 +1591,19 @@ async fn test_get_dates_scan_zero_historical_range_is_cache_only() { let _guard = ENV_MUTEX.lock().await; let temp_dir = TempDir::new().unwrap(); let original_xdg_cache = std::env::var("XDG_CACHE_HOME"); - unsafe { std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); } + unsafe { + std::env::set_var("XDG_CACHE_HOME", temp_dir.path()); + } let today = Utc::now().date_naive(); let cache_dir = temp_dir.path().join("wxrust").join("123"); fs::create_dir_all(&cache_dir).unwrap(); fs::write(cache_dir.join("2020-06-01.txt"), "old").unwrap(); - fs::write(cache_dir.join(format!("{}.txt", ymd_string(today))), "today").unwrap(); + fs::write( + cache_dir.join(format!("{}.txt", ymd_string(today))), + "today", + ) + .unwrap(); let mock_client = MockApiClient::new(); let data_access = wxrust::api::DataAccess { @@ -1407,7 +1622,9 @@ async fn test_get_dates_scan_zero_historical_range_is_cache_only() { Some("2020-01-01".to_string()), 10000, false, - ).await.unwrap(); + ) + .await + .unwrap(); assert_eq!(result, vec!["2020-06-01".to_string()]); restore_xdg_cache(original_xdg_cache);