From 7fab39c298dd70906ea3a16b66d007a69b1fffd2 Mon Sep 17 00:00:00 2001 From: Bart Trojanowski Date: Fri, 4 Sep 2026 16:19:17 +0000 Subject: [PATCH 1/7] Batch and concurrent jday fetches for faster fetch Sequential per-date GraphQL requests made `wxrust fetch` take ~19s for 152 workouts. Pack up to 10 jday selections into one aliased query and run 8 requests concurrently (~1.2s for the downloads, ~3.4s end-to-end). Tune the HTTP client (idle pool, TCP_NODELAY, gzip) and skip cached dates by file existence instead of parsing. --- Cargo.toml | 3 +- src/api.rs | 6 +- src/fetch.rs | 91 +++++++++++++++------ src/models.rs | 3 + src/workouts.rs | 207 ++++++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 269 insertions(+), 41 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2e47d72..6247d40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,9 @@ name = "wxrust" path = "src/lib.rs" [dependencies] -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.12", features = ["json", "gzip"] } tokio = { version = "1", features = ["full"] } +futures = "0.3" serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } base64 = "0.21" diff --git a/src/api.rs b/src/api.rs index 5fbb646..5db4949 100644 --- a/src/api.rs +++ b/src/api.rs @@ -67,7 +67,11 @@ pub struct ReqwestClient { impl ReqwestClient { pub fn new_with_verbose(verbose: bool) -> Self { ReqwestClient { - client: reqwest::Client::new(), + client: reqwest::Client::builder() + .pool_max_idle_per_host(32) + .tcp_nodelay(true) + .build() + .expect("failed to build HTTP client"), verbose, user_info: OnceCell::new(), } diff --git a/src/fetch.rs b/src/fetch.rs index 9611781..a151d2e 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -1,4 +1,4 @@ -use crate::api::ReqwestClient; +use crate::api::ApiClient; use crate::formatters; use crate::models; use crate::parsers; @@ -6,9 +6,10 @@ use crate::utils; use crate::workouts; use regex::Regex; use std::fs; +use std::time::Instant; -pub async fn fetch_command( - data_access: &crate::api::DataAccess<'_, ReqwestClient>, +pub async fn fetch_command( + data_access: &crate::api::DataAccess<'_, C>, dates: &[String], diff: bool, force: bool, @@ -28,31 +29,46 @@ pub async fn fetch_command( return Ok(()); } + if diff { + return fetch_diff(data_access, uid, &dates_to_fetch, verbose).await; + } + let pb = utils::create_progress_bar(dates_to_fetch.len() as u64); + let mut need = Vec::new(); for date in &dates_to_fetch { - pb.set_message(format!("Fetching {}", date)); - - if diff { - let server_jday = workouts::get_jday(data_access, date, verbose).await?; - let local_jday = workouts::lookup_cached_jday(uid, date, verbose); - - if let Some(local) = local_jday { - show_diff(date, &local, &server_jday); - } else { - println!("{}: No local cache, server version:", date); - let user_wants_kg = workouts::resolve_user_wants_kg(data_access).await; - let workout = formatters::format_workout(date, &server_jday, user_wants_kg); - print!("{}", workout); - } - } else if !force && workouts::lookup_cached_jday(uid, date, verbose).is_some() { + if !force && workouts::cached_jday_exists(uid, date) { pb.println(format!("{} already cached, skipping", date)); + pb.inc(1); } else { - let jday = workouts::get_jday(data_access, date, verbose).await?; - workouts::write_cached_jday(uid, date, &jday); + need.push(date.clone()); } + } - pb.inc(1); + if !need.is_empty() { + let start = Instant::now(); + workouts::get_jdays_with_callback( + data_access, + &need, + workouts::JDAY_BATCH_SIZE, + workouts::FETCH_CONCURRENCY, + verbose, + |date, jday| { + workouts::write_cached_jday(uid, date, jday); + pb.set_message(format!("Fetching {}", date)); + pb.inc(1); + }, + ) + .await?; + if verbose { + eprintln!( + "Fetched {} workouts in {:.2}s ({} per request, concurrency {})", + need.len(), + start.elapsed().as_secs_f64(), + workouts::JDAY_BATCH_SIZE, + workouts::FETCH_CONCURRENCY + ); + } } pb.finish_with_message("Done"); @@ -60,6 +76,35 @@ pub async fn fetch_command( Ok(()) } +async fn fetch_diff( + data_access: &crate::api::DataAccess<'_, C>, + uid: u32, + dates: &[String], + verbose: bool, +) -> Result<(), String> { + let pb = utils::create_progress_bar(dates.len() as u64); + pb.set_message("Fetching"); + let user_wants_kg = workouts::resolve_user_wants_kg(data_access).await; + + let fetched = workouts::get_jdays(data_access, dates, verbose).await?; + pb.inc(dates.len() as u64); + + pb.finish_and_clear(); + + for (date, server_jday) in fetched { + let local_jday = workouts::lookup_cached_jday(uid, &date, verbose); + if let Some(local) = local_jday { + show_diff(&date, &local, &server_jday); + } else { + println!("{}: No local cache, server version:", date); + let workout = formatters::format_workout(&date, &server_jday, user_wants_kg); + print!("{}", workout); + } + } + + Ok(()) +} + 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))?; @@ -112,8 +157,8 @@ fn parse_file_export(content: &str) -> Result, Strin Ok(workouts_list) } -async fn get_dates_to_fetch( - data_access: &crate::api::DataAccess<'_, ReqwestClient>, +async fn get_dates_to_fetch( + data_access: &crate::api::DataAccess<'_, C>, dates: &[String], ) -> Result, String> { if dates.is_empty() { diff --git a/src/models.rs b/src/models.rs index 62d8ebf..1fbd295 100644 --- a/src/models.rs +++ b/src/models.rs @@ -67,6 +67,9 @@ pub struct WorkoutData { pub jday: Option, } +/// GraphQL alias map used by batched `jday` queries (`d0`, `d1`, ...). +pub type BatchJDayData = std::collections::HashMap>; + #[derive(Deserialize, Serialize, Debug, Clone)] #[allow(dead_code)] pub struct JDay { diff --git a/src/workouts.rs b/src/workouts.rs index 99b8e5e..5ca69f3 100644 --- a/src/workouts.rs +++ b/src/workouts.rs @@ -3,11 +3,28 @@ use crate::formatters; use crate::models; use crate::parsers; use chrono::{Datelike, Utc}; +use futures::StreamExt; use lazy_static::lazy_static; +use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::sync::Mutex; +/// Number of `jday` selections packed into one GraphQL request via aliases. +pub const JDAY_BATCH_SIZE: usize = 10; +/// Maximum number of batched GraphQL requests in flight during a bulk fetch. +pub const FETCH_CONCURRENCY: usize = 8; + +const JDAY_FIELDS: &str = r#" log + bw + eblocks { + eid + sets { w r s lb rpe pr est1rm eff int type t d dunit speed force c } + } + exercises { + exercise { id name type } + }"#; + lazy_static! { static ref USER_WANTS_KG: Mutex> = Mutex::new(None); } @@ -106,6 +123,42 @@ fn get_cache_file_path(uid: u32, date: &str) -> Result { Ok(cache_dir.join(format!("{}.txt", date))) } +/// 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) +} + +pub fn jday_alias(index: usize) -> String { + format!("d{}", index) +} + +pub fn chunk_dates(dates: &[String], batch_size: usize) -> Vec> { + let batch_size = batch_size.max(1); + dates.chunks(batch_size).map(|c| c.to_vec()).collect() +} + +pub fn build_jday_query(uid: u32, date: &str) -> String { + format!( + "query {{\n jday(uid: {}, ymd: \"{}\") {{\n{}\n }}\n}}\n", + uid, date, JDAY_FIELDS + ) +} + +pub fn build_batch_jday_query(uid: u32, dates: &[String]) -> String { + let mut q = String::from("query {\n"); + for (i, date) in dates.iter().enumerate() { + q.push_str(&format!( + " {}: jday(uid: {}, ymd: \"{}\") {{\n{}\n }}\n", + jday_alias(i), + uid, + date, + JDAY_FIELDS + )); + } + q.push_str("}\n"); + q +} + 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() { @@ -192,22 +245,7 @@ pub async fn get_jday(data_access: &crate::api::DataAc let token = data_access.token.ok_or("No token available for network request")?; - let query = format!(r#" -query {{ - jday(uid: {}, ymd: "{}") {{ - log - bw - eblocks {{ - eid - sets {{ w r s lb rpe pr est1rm eff int type t d dunit speed force c }} - }} - exercises {{ - exercise {{ id name type }} - }} - }} -}} -"#, - uid, date); + 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())?; @@ -231,6 +269,143 @@ query {{ } } +async fn fetch_jdays_from_network( + data_access: &crate::api::DataAccess<'_, C>, + uid: u32, + dates: &[String], + _verbose: bool, +) -> Result, String> { + 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) + .await + .map_err(|e| e.to_string())?; + + if let Some(errors) = response.errors { + 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 results = Vec::with_capacity(dates.len()); + for (i, date) in dates.iter().enumerate() { + let key = jday_alias(i); + match data.remove(&key) { + Some(Some(jday)) => results.push((date.clone(), jday)), + Some(None) => return Err(format!("No workout found for {}", date)), + None => return Err(format!("Unexpected response: missing {} for {}", key, date)), + } + } + Ok(results) +} + +/// Fetch a group of dates in a single GraphQL request (via aliases). +/// +/// Cached dates are served locally when `use_cache` is set. Newly fetched +/// workouts are written to cache when `write_cache` is set. +pub async fn get_jdays_batch( + data_access: &crate::api::DataAccess<'_, C>, + dates: &[String], + verbose: bool, +) -> Result, String> { + if dates.is_empty() { + return Ok(vec![]); + } + + let uid = data_access.uid.ok_or("No user ID available")?; + let mut found: HashMap = HashMap::new(); + let mut missing: Vec = Vec::new(); + + for date in dates { + if data_access.use_cache { + if let Some(jday) = lookup_cached_jday(uid, date, verbose) { + found.insert(date.clone(), jday); + continue; + } + } + missing.push(date.clone()); + } + + if !missing.is_empty() { + if !data_access.use_network { + 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 { + if data_access.write_cache { + write_cached_jday(uid, &date, &jday); + } + found.insert(date, jday); + } + } + + let mut results = Vec::with_capacity(dates.len()); + for date in dates { + match found.remove(date) { + Some(jday) => results.push((date.clone(), jday)), + None => return Err(format!("No workout found for {}", date)), + } + } + Ok(results) +} + +/// Fetch many dates concurrently, packing up to `JDAY_BATCH_SIZE` into each request. +pub async fn get_jdays( + data_access: &crate::api::DataAccess<'_, C>, + dates: &[String], + verbose: bool, +) -> Result, String> { + get_jdays_with_callback( + data_access, + dates, + JDAY_BATCH_SIZE, + FETCH_CONCURRENCY, + verbose, + |_, _| {}, + ) + .await +} + +pub async fn get_jdays_with_callback( + data_access: &crate::api::DataAccess<'_, C>, + dates: &[String], + batch_size: usize, + concurrency: usize, + verbose: bool, + mut on_workout: F, +) -> Result, String> +where + C: crate::api::ApiClient, + F: FnMut(&str, &models::JDay), +{ + if dates.is_empty() { + return Ok(vec![]); + } + + let chunks = chunk_dates(dates, batch_size); + let concurrency = concurrency.max(1); + let mut stream = futures::stream::iter(chunks) + .map(|chunk| async move { get_jdays_batch(data_access, &chunk, verbose).await }) + .buffer_unordered(concurrency); + + let mut collected: HashMap = HashMap::new(); + while let Some(result) = stream.next().await { + for (date, jday) in result? { + on_workout(&date, &jday); + collected.insert(date, jday); + } + } + + let mut ordered = Vec::with_capacity(dates.len()); + for date in dates { + match collected.remove(date) { + Some(jday) => ordered.push((date.clone(), jday)), + None => return Err(format!("No workout found for {}", date)), + } + } + Ok(ordered) +} + 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")?; let client = data_access.client; From 5b50365c0999ab338fc3070d98ec09026f91b4d6 Mon Sep 17 00:00:00 2001 From: Bart Trojanowski Date: Fri, 4 Sep 2026 16:19:24 +0000 Subject: [PATCH 2/7] Document batched fetch performance and jrange weeks Record that jrange range is in weeks (max 32), GraphQL aliases work for bulk jday, and fetch uses 10-wide batches with concurrency 8. Note downloadLogs exists but is unused because JEditorData can diverge from jday cache format. --- AGENTS.md | 15 ++++++++++----- README.md | 4 +++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ac0225f..51d4ed0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,8 +21,10 @@ decodes the JWT token for user ID, queries the GraphQL API for workout data, and - **API Structure**: WeightXReps uses GraphQL at `/api/graphql` for all data operations. Authentication via JWT tokens in Authorization headers. - **Authentication**: Login mutation `login(u: $u, p: $p)` returns a JWT token containing user ID in the `id` field. - **Data Retrieval**: - - `JDay` query fetches workouts by user ID and date (YMD format: YYYY-MM-DD). Includes structured data like eblocks (exercise blocks), sets, exercises, and a pre-formatted log. - - `jrange` query fetches a range of workout days around a given date, with configurable count (max 32). Returns days with workouts in the range. + - `JDay` query fetches workouts by user ID and date (YMD format: YYYY-MM-DD). Includes structured data like eblocks (exercise blocks), sets, exercises, and a pre-formatted log (comments live here). + - `jrange` query fetches range data between `ymd - range*7` and `ymd`. **`range` is in weeks, max 32** (32 weeks ≈ 224 days), not a workout-day count. Returns days with workouts in the window. `jrange` includes eblocks/sets/exercises but **not** the `log` field, so it cannot replace `jday` for cache-accurate fetches. + - GraphQL aliases work: one request can fetch many `jday` selections (`d0: jday(...) { ... } d1: jday(...) { ... }`). + - `downloadLogs` returns all of the current user's logs as `JEditorData` in one request (used by the website export). Not used by fetch because converting JEditorData can diverge from `jday` cache format. - **Data Formatting**: Workout logs are formatted text with #exercise prefixes and compressed set notations (e.g., 135x5x3 or 445x1,3). Formatting logic found in client code. - **Color Scheme**: Website editor uses specific RGB colors for syntax highlighting: - Date: #9D4EDD (157,78,221) @@ -31,7 +33,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. Implemented concurrent API fetching for multiple workouts and session-level caching of user preferences to reduce latency and redundant calls. +- **Performance**: Reuse HTTP client across requests to maintain connection pooling and avoid TCP overhead. Session-level caching of user preferences. Fetch packs up to 10 `jday` queries per GraphQL request (aliases) and runs 8 requests concurrently (`JDAY_BATCH_SIZE=10`, `FETCH_CONCURRENCY=8`). Sequential `jday` fetching of 152 workouts took ~19s; batched+concurrent takes ~1.2s for the workout downloads (~3.4s including date listing/auth). 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 @@ -72,7 +74,7 @@ You can look in `weightxreps-client/src/data/generated---db-types-and-hooks.tsx` - Ensures ordered output in list commands by buffering concurrent async requests to maintain sequence. - Local caching of structured workout data in XDG_CACHE_HOME/wxrust/{uid}/yyyy-mm-dd.txt for offline access and performance. - Cache format is text, same as what is formatted for output using `format_workout_for_cache` -- Bulk fetch command to download workouts from server into cache, with options for diff, force, and file import +- Bulk fetch command to download workouts from server into cache, with options for diff, force, and file import. Fetch skips already-cached dates by checking file existence (no parse). Network fetches use batched concurrent `jday` queries. - Progress bars for long-running operations using indicatif - Side-by-side diff display using similar crate for comparing local and server workouts - Parsing and formatting support for RPE (@ syntax), BW exercises (BW, BW+, BW-), lb/kg units @@ -85,7 +87,8 @@ You can look in `weightxreps-client/src/data/generated---db-types-and-hooks.tsx` ## Dependencies Added -- `reqwest` (0.12) with JSON features for HTTP requests and client reuse. +- `reqwest` (0.12) with JSON and gzip features for HTTP requests and client reuse. +- `futures` (0.3) for concurrent batched GraphQL streams (`buffer_unordered`). - `serde` (1.0) with derive for JSON serialization/deserialization. - `base64` (0.21) for JWT payload decoding. - `tokio` (1) for async runtime. @@ -196,6 +199,8 @@ Unlike the C version which shows separate tables per filter, the Rust implementa ## Future Improvements +- Parallelize `get_dates` jrange pagination (currently sequential week-windows). +- Optionally use `downloadLogs` for full-history `fetch` with no date filter. - Add support for year/month range queries. - Add export options (JSON, CSV). - Support for user profile and goals queries. diff --git a/README.md b/README.md index 04de2e8..81c8a79 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ A Rust CLI tool to extract and display workouts from the WeightXReps.net website - Supports detailed views, summaries, and date filtering - Handles JWT tokens and GraphQL queries efficiently - Caches workout data locally in XDG cache directory for improved performance -- Fetches and caches workouts in bulk with progress indication +- Fetches and caches workouts in bulk with progress indication, using batched concurrent GraphQL requests - Compares local cache with server versions - Imports workouts from text export files @@ -92,6 +92,8 @@ Create a `credentials.txt` file with your WeightXReps account email on the first - Force re-download: `wxrust fetch --force 2025` - Import from text export file: `wxrust fetch --file export.txt` +`fetch` downloads workouts concurrently (10 dates per GraphQL request, 8 requests in flight) and skips dates that are already cached unless `--force` is used. + #### Table Command (PR Progression) Display a progression table showing personal records (PRs) over time for specific exercises. From 231273a2a52e480610777388d04c5de0b3a69a12 Mon Sep 17 00:00:00 2001 From: Bart Trojanowski Date: Fri, 4 Sep 2026 16:19:24 +0000 Subject: [PATCH 3/7] Add unit tests for batched concurrent fetch Cover query builders, cache existence checks, get_jdays_batch success/error/cache paths, concurrent chunk reordering, and fetch_command skip/force/network behavior. --- tests/test_fetch.rs | 226 ++++++++++++++++++++++++++ tests/test_workouts.rs | 348 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 tests/test_fetch.rs diff --git a/tests/test_fetch.rs b/tests/test_fetch.rs new file mode 100644 index 0000000..ea8168e --- /dev/null +++ b/tests/test_fetch.rs @@ -0,0 +1,226 @@ +use mockall::mock; +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; +use std::fs; + +lazy_static! { + static ref ENV_MUTEX: Mutex<()> = Mutex::new(()); +} + +mock! { + #[derive(Clone)] + ApiClient {} + + #[async_trait::async_trait] + impl wxrust::api::ApiClient for ApiClient { + async fn login_request(&self, request: &wxrust::models::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; + } +} + +fn sample_jday() -> JDay { + JDay { + log: "EBLOCK:ex1".to_string(), + bw: Some(80.0), + eblocks: vec![EBlock { + eid: "ex1".to_string(), + sets: vec![Set { + w: Some(135.0), + r: Some(5), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }], + }], + exercises: vec![ExerciseWrapper { + exercise: Exercise { + id: "ex1".to_string(), + name: "Squat".to_string(), + ex_type: Some("strength".to_string()), + }, + }], + } +} + +fn restore_xdg(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"); } + } +} + +#[tokio::test] +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()); } + forget_cached_user_wants_kg(); + + let cache_dir = temp_dir.path().join("wxrust").join("123"); + fs::create_dir_all(&cache_dir).unwrap(); + fs::write( + cache_dir.join("2023-10-01.txt"), + "2023-10-01\n@ 80 kg bw\n#Squat\n135 x 5\n", + ).unwrap(); + + let mock_client = MockApiClient::new(); + let data_access = wxrust::api::DataAccess { + client: &mock_client, + token: None, + uid: Some(123), + use_network: false, + use_cache: true, + write_cache: false, + }; + + let dates = vec!["2023-10-01".to_string()]; + let result = wxrust::fetch::fetch_command(&data_access, &dates, false, false, None, false).await; + assert!(result.is_ok()); + + restore_xdg(original_xdg_cache); +} + +#[tokio::test] +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()); } + forget_cached_user_wants_kg(); + + 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![wxrust::models::JRangeDayData { + on: Some("2023-10-01".to_string()), + }]), + }), + }), + errors: None, + }) + }); + mock_client + .expect_graphql_request::() + .times(1) + .returning(|_, _, _| { + let mut data = std::collections::HashMap::new(); + data.insert("d0".to_string(), Some(sample_jday())); + Ok(GraphQLResponse { + data: Some(data), + 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, + }; + + let dates = vec!["2023-10-01".to_string()]; + let result = wxrust::fetch::fetch_command(&data_access, &dates, false, false, None, 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"); + + restore_xdg(original_xdg_cache); +} + +#[tokio::test] +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()); } + forget_cached_user_wants_kg(); + + let mock_client = MockApiClient::new(); + let data_access = wxrust::api::DataAccess { + client: &mock_client, + token: None, + uid: Some(123), + use_network: false, + use_cache: true, + write_cache: false, + }; + + let result = wxrust::fetch::fetch_command(&data_access, &["2023-10-01".to_string()], false, false, None, false).await; + assert!(result.is_ok()); + + restore_xdg(original_xdg_cache); +} + +#[tokio::test] +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()); } + forget_cached_user_wants_kg(); + + let cache_dir = temp_dir.path().join("wxrust").join("123"); + fs::create_dir_all(&cache_dir).unwrap(); + fs::write( + cache_dir.join("2023-10-01.txt"), + "2023-10-01\n@ 80 kg bw\n#Squat\n100 x 5\n", + ).unwrap(); + + 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![wxrust::models::JRangeDayData { + on: Some("2023-10-01".to_string()), + }]), + }), + }), + errors: None, + }) + }); + mock_client + .expect_graphql_request::() + .times(1) + .returning(|_, _, _| { + let mut data = std::collections::HashMap::new(); + data.insert("d0".to_string(), Some(sample_jday())); + Ok(GraphQLResponse { + data: Some(data), + 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, + }; + + let dates = vec!["2023-10-01".to_string()]; + let result = wxrust::fetch::fetch_command(&data_access, &dates, false, true, None, false).await; + assert!(result.is_ok()); + + restore_xdg(original_xdg_cache); +} diff --git a/tests/test_workouts.rs b/tests/test_workouts.rs index 35d6b26..9a086ed 100644 --- a/tests/test_workouts.rs +++ b/tests/test_workouts.rs @@ -1,5 +1,5 @@ use mockall::mock; -use wxrust::workouts::{get_jday, get_dates, get_dates_from_cache, read_cached_user_wants_kg, read_cached_user_wants_kg_or, write_cached_user_wants_kg, forget_cached_user_wants_kg}; +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, jday_alias, chunk_dates, build_jday_query, build_batch_jday_query, JDAY_BATCH_SIZE}; use wxrust::models::{GraphQLResponse, WorkoutData, JDay, EBlock, ExerciseWrapper, Exercise, Set, User}; use base64::{Engine, engine::general_purpose}; use tempfile::TempDir; @@ -714,3 +714,349 @@ async fn test_get_dates_from_ranges() { unsafe { std::env::remove_var("XDG_CACHE_HOME"); } } } + +fn sample_jday(log: &str) -> JDay { + JDay { + log: log.to_string(), + bw: Some(80.0), + eblocks: vec![EBlock { + eid: "ex1".to_string(), + sets: vec![Set { + w: Some(135.0), + r: Some(5), + s: Some(1), + lb: Some(0.0), + ..Default::default() + }], + }], + exercises: vec![ExerciseWrapper { + exercise: Exercise { + id: "ex1".to_string(), + name: "Squat".to_string(), + ex_type: Some("strength".to_string()), + }, + }], + } +} + +#[test] +fn test_jday_alias() { + assert_eq!(jday_alias(0), "d0"); + assert_eq!(jday_alias(9), "d9"); + assert_eq!(jday_alias(10), "d10"); +} + +#[test] +fn test_chunk_dates() { + let dates: Vec = (0..10).map(|i| format!("2023-10-{:02}", i + 1)).collect(); + let chunks = chunk_dates(&dates, 8); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].len(), 8); + assert_eq!(chunks[1].len(), 2); + assert_eq!(chunks[0][0], "2023-10-01"); + assert_eq!(chunks[1][0], "2023-10-09"); + + assert!(chunk_dates(&[], 8).is_empty()); + assert_eq!(chunk_dates(&dates, 0).len(), dates.len()); // batch_size 0 treated as 1 + assert_eq!(JDAY_BATCH_SIZE, 10); +} + +#[test] +fn test_build_jday_query() { + let query = build_jday_query(2751, "2026-01-05"); + assert!(query.contains("jday(uid: 2751, ymd: \"2026-01-05\")")); + assert!(query.contains("eblocks")); + assert!(query.contains("exercises")); +} + +#[test] +fn test_build_batch_jday_query() { + let dates = vec!["2026-01-05".to_string(), "2026-01-06".to_string()]; + let query = build_batch_jday_query(2751, &dates); + assert!(query.contains("d0: jday(uid: 2751, ymd: \"2026-01-05\")")); + assert!(query.contains("d1: jday(uid: 2751, ymd: \"2026-01-06\")")); + assert!(query.contains("log")); + assert!(query.contains("eblocks")); + // one query, two aliased selections + assert_eq!(query.matches("jday(").count(), 2); +} + +#[tokio::test] +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()); } + + forget_cached_user_wants_kg(); + + assert!(!cached_jday_exists(123, "2023-10-01")); + + let cache_dir = temp_dir.path().join("wxrust").join("123"); + fs::create_dir_all(&cache_dir).unwrap(); + fs::write(cache_dir.join("2023-10-01.txt"), "2023-10-01\n").unwrap(); + + assert!(cached_jday_exists(123, "2023-10-01")); + assert!(!cached_jday_exists(123, "2023-10-02")); + + if let Ok(original) = original_xdg_cache { + unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + } else { + unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + } +} + +#[tokio::test] +async fn test_get_jdays_empty() { + let _guard = ENV_MUTEX.lock().await; + 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, + }; + let result = get_jdays(&data_access, &[], false).await; + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); +} + +#[tokio::test] +async fn test_get_jdays_batch_empty() { + let _guard = ENV_MUTEX.lock().await; + 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, + }; + let result = get_jdays_batch(&data_access, &[], false).await; + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); +} + +#[tokio::test] +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()); } + forget_cached_user_wants_kg(); + + let mut mock_client = MockApiClient::new(); + mock_client + .expect_graphql_request::() + .times(1) + .returning(|_, _, _| { + let mut data = std::collections::HashMap::new(); + data.insert("d0".to_string(), Some(sample_jday("log-a"))); + data.insert("d1".to_string(), Some(sample_jday("log-b"))); + Ok(GraphQLResponse { + data: Some(data), + 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, + }; + + let dates = vec!["2023-10-01".to_string(), "2023-10-02".to_string()]; + let result = get_jdays_batch(&data_access, &dates, false).await; + assert!(result.is_ok()); + let workouts = result.unwrap(); + assert_eq!(workouts.len(), 2); + assert_eq!(workouts[0].0, "2023-10-01"); + assert_eq!(workouts[0].1.log, "log-a"); + assert_eq!(workouts[1].0, "2023-10-02"); + assert_eq!(workouts[1].1.log, "log-b"); + + if let Ok(original) = original_xdg_cache { + unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + } else { + unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + } +} + +#[tokio::test] +async fn test_get_jdays_batch_missing_workout() { + let _guard = ENV_MUTEX.lock().await; + let mut mock_client = MockApiClient::new(); + mock_client + .expect_graphql_request::() + .times(1) + .returning(|_, _, _| { + let mut data = std::collections::HashMap::new(); + data.insert("d0".to_string(), None); + Ok(GraphQLResponse { + data: Some(data), + 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, + }; + + 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")); +} + +#[tokio::test] +async fn test_get_jdays_batch_graphql_error() { + let _guard = ENV_MUTEX.lock().await; + let mut mock_client = MockApiClient::new(); + mock_client + .expect_graphql_request::() + .times(1) + .returning(|_, _, _| { + Ok(GraphQLResponse { + data: None, + errors: Some(vec![wxrust::models::GraphQLError { message: "boom".to_string() }]), + }) + }); + + let data_access = wxrust::api::DataAccess { + client: &mock_client, + token: Some("token"), + uid: Some(123), + use_network: true, + use_cache: false, + write_cache: false, + }; + + 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("boom")); +} + +#[tokio::test] +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()); } + forget_cached_user_wants_kg(); + + let mut mock_client = MockApiClient::new(); + mock_client + .expect_graphql_request::() + .times(3) + .returning(|_, query, _| { + // Each batch has a single alias d0 because batch_size=1 + let log = if query.contains("2023-10-01") { + "one" + } else if query.contains("2023-10-02") { + "two" + } else { + "three" + }; + let mut data = std::collections::HashMap::new(); + data.insert("d0".to_string(), Some(sample_jday(log))); + Ok(GraphQLResponse { + data: Some(data), + 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, + }; + + let dates = vec![ + "2023-10-01".to_string(), + "2023-10-02".to_string(), + "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; + + assert!(result.is_ok()); + let workouts = result.unwrap(); + assert_eq!(workouts.len(), 3); + // results are reordered to match input dates even if batches complete out of order + assert_eq!(workouts[0].0, "2023-10-01"); + assert_eq!(workouts[0].1.log, "one"); + assert_eq!(workouts[1].0, "2023-10-02"); + assert_eq!(workouts[1].1.log, "two"); + assert_eq!(workouts[2].0, "2023-10-03"); + assert_eq!(workouts[2].1.log, "three"); + assert_eq!(seen.len(), 3); + + if let Ok(original) = original_xdg_cache { + unsafe { std::env::set_var("XDG_CACHE_HOME", original); } + } else { + unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + } +} + +#[tokio::test] +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()); } + forget_cached_user_wants_kg(); + + let cache_dir = temp_dir.path().join("wxrust").join("123"); + fs::create_dir_all(&cache_dir).unwrap(); + fs::write( + cache_dir.join("2023-10-01.txt"), + "2023-10-01\n@ 80 kg bw\n#Squat\n135 x 5\n", + ).unwrap(); + + // No network calls expected — served from cache + 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, + }; + + let dates = vec!["2023-10-01".to_string()]; + let result = get_jdays_batch(&data_access, &dates, false).await; + assert!(result.is_ok()); + let workouts = result.unwrap(); + assert_eq!(workouts.len(), 1); + assert_eq!(workouts[0].0, "2023-10-01"); + 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); } + } else { + unsafe { std::env::remove_var("XDG_CACHE_HOME"); } + } +} From 64d33e688877bf839233555a0a27bb4395633713 Mon Sep 17 00:00:00 2001 From: Bart Trojanowski Date: Fri, 4 Sep 2026 16:50:22 +0000 Subject: [PATCH 4/7] Count GraphQL response bytes for transfer stats Record request count and response body size on each HTTP GraphQL call so fetch --stats can report MB/s. --- src/api.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/api.rs b/src/api.rs index 5db4949..6b34a61 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2,6 +2,28 @@ use async_trait::async_trait; use serde::de::DeserializeOwned; use ansi_term::Colour; use tokio::sync::OnceCell; +use std::sync::atomic::{AtomicU64, Ordering}; + +static TRANSFER_BYTES: AtomicU64 = AtomicU64::new(0); +static TRANSFER_REQUESTS: AtomicU64 = AtomicU64::new(0); + +pub fn reset_transfer_stats() { + TRANSFER_BYTES.store(0, Ordering::Relaxed); + TRANSFER_REQUESTS.store(0, Ordering::Relaxed); +} + +/// Returns `(requests, bytes)` recorded since the last reset. +pub fn transfer_stats() -> (u64, u64) { + ( + TRANSFER_REQUESTS.load(Ordering::Relaxed), + TRANSFER_BYTES.load(Ordering::Relaxed), + ) +} + +fn record_transfer(bytes: usize) { + TRANSFER_BYTES.fetch_add(bytes as u64, Ordering::Relaxed); + TRANSFER_REQUESTS.fetch_add(1, Ordering::Relaxed); +} use crate::models::{GraphQLRequest, GraphQLResponse, WorkoutRequest, WorkoutResponse, UserBasicInfoData, User}; use crate::formatters::STDERR_COLOR_ENABLED; @@ -90,6 +112,7 @@ impl ApiClient for ReqwestClient { .await?; let status = response.status(); let text = response.text().await?; + record_transfer(text.len()); log_verbose_response(&text, status, self.verbose); let body: GraphQLResponse = serde_json::from_str(&text)?; Ok(body) @@ -110,6 +133,7 @@ impl ApiClient for ReqwestClient { .await?; let status = response.status(); let text = response.text().await?; + record_transfer(text.len()); log_verbose_response(&text, status, self.verbose); let body: GraphQLResponse = serde_json::from_str(&text)?; Ok(body) From c433dfa3bfb87efab4d83ad6b59353b9096daa75 Mon Sep 17 00:00:00 2001 From: Bart Trojanowski Date: Fri, 4 Sep 2026 16:50:22 +0000 Subject: [PATCH 5/7] Add fetch --stats and honor --force over cache Print `X workouts, Y seconds, x.y T/s, x.y MB/s` after the progress line. --force (and --diff) skip cache reads so already-cached dates are downloaded from the server again. --- src/fetch.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++++++---- src/main.rs | 4 ++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/fetch.rs b/src/fetch.rs index a151d2e..50bfa91 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -8,6 +8,24 @@ use regex::Regex; use std::fs; use std::time::Instant; +pub fn format_transfer_stats(transfers: u64, bytes: u64, elapsed_secs: f64) -> String { + let (tps, mbps) = if elapsed_secs > 0.0 { + ( + transfers as f64 / elapsed_secs, + (bytes as f64 / 1_000_000.0) / elapsed_secs, + ) + } else { + (0.0, 0.0) + }; + format!( + "{} workouts, {:.2} seconds, {:.1} T/s, {:.2} MB/s", + transfers, + elapsed_secs.max(0.0), + tps, + mbps + ) +} + pub async fn fetch_command( data_access: &crate::api::DataAccess<'_, C>, dates: &[String], @@ -15,6 +33,7 @@ pub async fn fetch_command( force: bool, file: Option<&str>, verbose: bool, + stats: bool, ) -> Result<(), String> { let uid = data_access.uid.ok_or("No user ID available")?; @@ -30,7 +49,7 @@ pub async fn fetch_command( } if diff { - return fetch_diff(data_access, uid, &dates_to_fetch, verbose).await; + return fetch_diff(data_access, uid, &dates_to_fetch, verbose, stats).await; } let pb = utils::create_progress_bar(dates_to_fetch.len() as u64); @@ -45,10 +64,15 @@ pub async fn fetch_command( } } + let mut elapsed_secs = 0.0; + let mut bytes = 0u64; if !need.is_empty() { + crate::api::reset_transfer_stats(); let start = Instant::now(); + // --force must hit the network even when cache files already exist. + let fetch_access = without_cache(data_access, force); workouts::get_jdays_with_callback( - data_access, + &fetch_access, &need, workouts::JDAY_BATCH_SIZE, workouts::FETCH_CONCURRENCY, @@ -60,11 +84,14 @@ pub async fn fetch_command( }, ) .await?; + elapsed_secs = start.elapsed().as_secs_f64(); + let (_requests, received) = crate::api::transfer_stats(); + bytes = received; if verbose { eprintln!( "Fetched {} workouts in {:.2}s ({} per request, concurrency {})", need.len(), - start.elapsed().as_secs_f64(), + elapsed_secs, workouts::JDAY_BATCH_SIZE, workouts::FETCH_CONCURRENCY ); @@ -73,6 +100,10 @@ pub async fn fetch_command( pb.finish_with_message("Done"); + if stats { + println!("{}", format_transfer_stats(need.len() as u64, bytes, elapsed_secs)); + } + Ok(()) } @@ -81,16 +112,27 @@ async fn fetch_diff( uid: u32, dates: &[String], verbose: bool, + stats: bool, ) -> Result<(), String> { let pb = utils::create_progress_bar(dates.len() as u64); pb.set_message("Fetching"); let user_wants_kg = workouts::resolve_user_wants_kg(data_access).await; - let fetched = workouts::get_jdays(data_access, dates, verbose).await?; + crate::api::reset_transfer_stats(); + let start = Instant::now(); + // Diff compares against the server, so skip local cache for the download. + let fetch_access = without_cache(data_access, true); + let fetched = workouts::get_jdays(&fetch_access, dates, verbose).await?; + let elapsed_secs = start.elapsed().as_secs_f64(); + let (_requests, bytes) = crate::api::transfer_stats(); pb.inc(dates.len() as u64); pb.finish_and_clear(); + if stats { + println!("{}", format_transfer_stats(fetched.len() as u64, bytes, elapsed_secs)); + } + for (date, server_jday) in fetched { let local_jday = workouts::lookup_cached_jday(uid, &date, verbose); if let Some(local) = local_jday { @@ -157,6 +199,20 @@ fn parse_file_export(content: &str) -> Result, Strin Ok(workouts_list) } +fn without_cache<'a, C: ApiClient>( + data_access: &crate::api::DataAccess<'a, C>, + skip_cache: bool, +) -> crate::api::DataAccess<'a, C> { + crate::api::DataAccess { + client: data_access.client, + token: data_access.token, + uid: data_access.uid, + use_network: data_access.use_network, + use_cache: data_access.use_cache && !skip_cache, + write_cache: data_access.write_cache, + } +} + async fn get_dates_to_fetch( data_access: &crate::api::DataAccess<'_, C>, dates: &[String], diff --git a/src/main.rs b/src/main.rs index 0978926..59f53fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -134,6 +134,9 @@ struct FetchArgs { #[arg(long, value_name = "FILE")] file: Option, + #[arg(long, help = "Print transfer rate (T/s, MB/s) after fetch")] + stats: bool, + dates: Vec, } @@ -225,6 +228,7 @@ async fn handle_fetch( fetch_args.force, fetch_args.file.as_deref(), verbose, + fetch_args.stats, ).await { utils::exit_with_error(e); } From d8cec38d97e3a1a219b7f8a9fc98464dbfb65517 Mon Sep 17 00:00:00 2001 From: Bart Trojanowski Date: Fri, 4 Sep 2026 16:50:22 +0000 Subject: [PATCH 6/7] Test fetch --stats formatting and --force cache bypass Cover transfer-stats output and assert --force still issues a network jday request when a cache file already exists. --- tests/test_fetch.rs | 81 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/tests/test_fetch.rs b/tests/test_fetch.rs index ea8168e..3f73eaa 100644 --- a/tests/test_fetch.rs +++ b/tests/test_fetch.rs @@ -81,7 +81,7 @@ 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).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); @@ -133,7 +133,7 @@ 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).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"); @@ -160,7 +160,7 @@ async fn test_fetch_command_no_dates() { write_cache: false, }; - let result = wxrust::fetch::fetch_command(&data_access, &["2023-10-01".to_string()], false, false, None, 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); @@ -214,13 +214,84 @@ async fn test_fetch_command_force_refetches() { token: Some("token"), uid: Some(123), use_network: true, - use_cache: false, + use_cache: true, + write_cache: false, + }; + + let dates = vec!["2023-10-01".to_string()]; + let result = wxrust::fetch::fetch_command(&data_access, &dates, false, true, None, false, false).await; + assert!(result.is_ok()); + + restore_xdg(original_xdg_cache); +} + +#[tokio::test] +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()); } + forget_cached_user_wants_kg(); + + let cache_dir = temp_dir.path().join("wxrust").join("123"); + fs::create_dir_all(&cache_dir).unwrap(); + fs::write( + cache_dir.join("2023-10-01.txt"), + "2023-10-01\n@ 80 kg bw\n#Squat\n100 x 5\n", + ).unwrap(); + + 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![wxrust::models::JRangeDayData { + on: Some("2023-10-01".to_string()), + }]), + }), + }), + errors: None, + }) + }); + mock_client + .expect_graphql_request::() + .times(0); + + let data_access = wxrust::api::DataAccess { + client: &mock_client, + token: Some("token"), + uid: Some(123), + use_network: true, + use_cache: true, write_cache: false, }; let dates = vec!["2023-10-01".to_string()]; - let result = wxrust::fetch::fetch_command(&data_access, &dates, false, true, None, 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); } + +#[test] +fn test_format_transfer_stats() { + assert_eq!( + wxrust::fetch::format_transfer_stats(152, 850_000, 1.15), + "152 workouts, 1.15 seconds, 132.2 T/s, 0.74 MB/s" + ); + assert_eq!( + wxrust::fetch::format_transfer_stats(115, 1_150_000, 1.15), + "115 workouts, 1.15 seconds, 100.0 T/s, 1.00 MB/s" + ); + assert_eq!( + wxrust::fetch::format_transfer_stats(10, 500_000, 0.0), + "10 workouts, 0.00 seconds, 0.0 T/s, 0.00 MB/s" + ); + assert_eq!( + wxrust::fetch::format_transfer_stats(0, 0, 1.0), + "0 workouts, 1.00 seconds, 0.0 T/s, 0.00 MB/s" + ); +} From 637822e6ef5ec58ceec219f32d7fd467be4f45bc Mon Sep 17 00:00:00 2001 From: Bart Trojanowski Date: Fri, 4 Sep 2026 16:50:22 +0000 Subject: [PATCH 7/7] Document fetch --stats output format Describe the T/s and MB/s summary line printed after fetch. --- AGENTS.md | 2 +- README.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 51d4ed0..a7bb1ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ You can look in `weightxreps-client/src/data/generated---db-types-and-hooks.tsx` - Ensures ordered output in list commands by buffering concurrent async requests to maintain sequence. - Local caching of structured workout data in XDG_CACHE_HOME/wxrust/{uid}/yyyy-mm-dd.txt for offline access and performance. - Cache format is text, same as what is formatted for output using `format_workout_for_cache` -- Bulk fetch command to download workouts from server into cache, with options for diff, force, and file import. Fetch skips already-cached dates by checking file existence (no parse). Network fetches use batched concurrent `jday` queries. +- Bulk fetch command to download workouts from server into cache, with options for diff, force, file import, and `--stats` (prints `X workouts, Y seconds, x.y T/s, x.y MB/s` after the progress line). Fetch skips already-cached dates by checking file existence (no parse). Network fetches use batched concurrent `jday` queries. HTTP response body sizes are counted in `api::transfer_stats`. - Progress bars for long-running operations using indicatif - Side-by-side diff display using similar crate for comparing local and server workouts - Parsing and formatting support for RPE (@ syntax), BW exercises (BW, BW+, BW-), lb/kg units diff --git a/README.md b/README.md index 81c8a79..35a0074 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,9 @@ Create a `credentials.txt` file with your WeightXReps account email on the first - Show diff between local and server: `wxrust fetch --diff 2025-10` - Force re-download: `wxrust fetch --force 2025` - Import from text export file: `wxrust fetch --file export.txt` +- Print transfer rate after fetch: `wxrust fetch --stats 2026` -`fetch` downloads workouts concurrently (10 dates per GraphQL request, 8 requests in flight) and skips dates that are already cached unless `--force` is used. +`fetch` downloads workouts concurrently (10 dates per GraphQL request, 8 requests in flight) and skips dates that are already cached unless `--force` is used. `--stats` prints `X workouts, Y seconds, x.y T/s, x.y MB/s` after the progress line. #### Table Command (PR Progression)