Skip to content

Commit eda3157

Browse files
committed
[vibe] improve parser/formatter to support RPE/BW/etc
1 parent a3f1b73 commit eda3157

5 files changed

Lines changed: 259 additions & 18 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ You can look in `weightxreps-client/src/data/generated---db-types-and-hooks.tsx`
7373
- Bulk fetch command to download workouts from server into cache, with options for diff, force, and file import
7474
- Progress bars for long-running operations using indicatif
7575
- Side-by-side diff display using similar crate for comparing local and server workouts
76+
- Parsing and formatting support for RPE (@ syntax), BW exercises (BW, BW+, BW-), lb/kg units
7677

7778
## Dependencies Added
7879

@@ -155,5 +156,8 @@ Commands support `{{VARIABLE}}` placeholders. Predefined variables include `PROJ
155156
- Add export options (JSON, CSV).
156157
- Support for user profile and goals queries.
157158
- Enhance error handling and retry logic.
159+
- Support for other set types (WxD, WxT, etc.).
160+
- Support for tags, time/distance sets.
161+
- DELETE keyword handling in cache management.
158162

159163

src/formatters.rs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,22 @@ pub fn format_weight(w: f32, lb: bool) -> String {
9595
}
9696
}
9797

98+
pub fn format_weight_with_bw(w: f32, lb: bool, usebw: i32) -> String {
99+
if usebw != 0 {
100+
if w > 0.0 {
101+
if usebw > 0 {
102+
format!("BW+{}", format_weight(w, lb))
103+
} else {
104+
format!("BW-{}", format_weight(w, lb))
105+
}
106+
} else {
107+
"BW".to_string()
108+
}
109+
} else {
110+
format_weight(w, lb)
111+
}
112+
}
113+
98114
#[allow(dead_code)]
99115
pub fn format_set(set: &Set) -> String {
100116
format_set_internal(set, *COLOR_ENABLED)
@@ -107,7 +123,21 @@ fn format_set_internal(set: &Set, color_enabled: bool) -> String {
107123
let s = set.s.unwrap_or(1);
108124
let rpe = set.rpe.unwrap_or(0.0);
109125
let lb = set.lb.unwrap_or(0.0) == 1.0;
110-
let w_str = color_weight_internal(&format_weight(w, lb), color_enabled);
126+
let usebw = set.usebw.unwrap_or(0);
127+
let line = if usebw != 0 {
128+
if w > 0.0 {
129+
if usebw > 0 {
130+
format!("BW+{}", format_weight(w, lb))
131+
} else {
132+
format!("BW-{}", format_weight(w, lb))
133+
}
134+
} else {
135+
"BW".to_string()
136+
}
137+
} else {
138+
format_weight(w, lb)
139+
};
140+
let w_str = color_weight_internal(&line, color_enabled);
111141
let mut line = w_str;
112142
if r > 0 {
113143
line += " x ";
@@ -148,19 +178,21 @@ fn compress_sets_internal(sets: &[Set], color_enabled: bool) -> Vec<String> {
148178
let _s = set.s.unwrap_or(1);
149179
let rpe = set.rpe.unwrap_or(0.0);
150180
let lb = set.lb.unwrap_or(0.0) == 1.0;
181+
let usebw = set.usebw.unwrap_or(0);
151182
// check for same weight consecutive
152183
let mut same_weight = vec![r];
153184
let mut j = i + 1;
154185
while j < sets.len() {
155186
let next = &sets[j];
156-
if next.set_type.unwrap_or(0) != 0 || next.w != set.w || next.rpe != set.rpe || next.lb != set.lb || next.s != set.s {
187+
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 {
157188
break;
158189
}
159190
same_weight.push(next.r.unwrap_or(0));
160191
j += 1;
161192
}
162193
if same_weight.len() > 1 {
163-
let w_str = color_weight_internal(&format_weight(w, lb), color_enabled);
194+
let line = format_weight_with_bw(w, lb, usebw);
195+
let w_str = color_weight_internal(&line, color_enabled);
164196
let r_str = same_weight.iter().map(|&r| color_reps_internal(&r.to_string(), color_enabled)).collect::<Vec<_>>().join(", ");
165197
let mut line = format!("{} x {}", w_str, r_str);
166198
if rpe > 0.0 {
@@ -174,14 +206,17 @@ fn compress_sets_internal(sets: &[Set], color_enabled: bool) -> Vec<String> {
174206
let mut j = i + 1;
175207
while j < sets.len() {
176208
let next = &sets[j];
177-
if next.set_type.unwrap_or(0) != 0 || next.r != set.r || next.rpe != set.rpe || next.lb != set.lb || next.s != set.s {
209+
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 {
178210
break;
179211
}
180212
same_rep.push(next.w.unwrap_or(0.0));
181213
j += 1;
182214
}
183215
if same_rep.len() > 1 {
184-
let w_str = same_rep.iter().map(|&w| color_weight_internal(&format_weight(w, lb), color_enabled)).collect::<Vec<_>>().join(", ");
216+
let w_str = same_rep.iter().map(|&w| {
217+
let line = format_weight_with_bw(w, lb, usebw);
218+
color_weight_internal(&line, color_enabled)
219+
}).collect::<Vec<_>>().join(", ");
185220
let r_str = color_reps_internal(&r.to_string(), color_enabled);
186221
let mut line = format!("{} x {}", w_str, r_str);
187222
if rpe > 0.0 {

src/models.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ pub struct Set {
107107
pub rpe: Option<f32>,
108108
pub c: Option<String>,
109109
pub set_type: Option<u32>,
110+
pub usebw: Option<i32>,
110111
}
111112

112113
#[derive(Deserialize, Debug, Clone)]

src/parsers.rs

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,52 @@ pub fn parse_workout(text: &str) -> Result<JDay, String> {
111111
// 405, 406 x 2 cccc - ( Set { w=405, r=2, s=1, c="" }, Set { w=406, r=2, s=1, c="cccc" } )
112112

113113
pub fn is_weight_part(s: &str) -> bool {
114-
s.chars().all(|c| c.is_digit(10) || c == ',' || c == '.')
114+
s.chars().all(|c| c.is_digit(10) || c == ',' || c == '.' || c == '+' || c == '-') || s.to_lowercase().starts_with("bw") || s.to_lowercase() == "kg" || s.to_lowercase() == "lb" || s.to_lowercase() == "lbs"
115115
}
116116

117-
pub fn parse_weights(s: &str) -> Result<Vec<f32>, String> {
117+
pub fn parse_weight(s: &str) -> Result<(f32, bool, i32), String> {
118+
let s = s.trim();
119+
let lower = s.to_lowercase();
120+
if lower.starts_with("bw") {
121+
let mut usebw = 1;
122+
let mut v = 0.0;
123+
let rest = &s[2..].trim();
124+
if rest.starts_with('+') {
125+
usebw = 1;
126+
let num = rest[1..].trim();
127+
if !num.is_empty() {
128+
v = num.parse().map_err(|_| "Invalid BW+ weight")?;
129+
}
130+
} else if rest.starts_with('-') {
131+
usebw = -1;
132+
let num = rest[1..].trim();
133+
if !num.is_empty() {
134+
v = num.parse().map_err(|_| "Invalid BW- weight")?;
135+
}
136+
} else if !rest.is_empty() {
137+
return Err("Invalid BW syntax".to_string());
138+
}
139+
Ok((v, false, usebw))
140+
} else {
141+
let mut lb = false;
142+
let num_end = s.find(|c: char| !c.is_digit(10) && c != '.');
143+
let num_str = if let Some(end) = num_end { &s[..end] } else { s };
144+
let unit = if let Some(end) = num_end { &s[end..].trim().to_lowercase() } else { "" };
145+
if unit == "lb" || unit == "lbs" {
146+
lb = true;
147+
} else if unit == "kg" {
148+
lb = false;
149+
} else if !unit.is_empty() {
150+
return Err(format!("Invalid unit: {}", unit));
151+
}
152+
let v: f32 = num_str.parse().map_err(|_| format!("Invalid weight: {}", num_str))?;
153+
Ok((v, lb, 0))
154+
}
155+
}
156+
157+
pub fn parse_weights(s: &str) -> Result<Vec<(f32, bool, i32)>, String> {
118158
s.split(',')
119-
.map(|p| p.trim().parse::<f32>().map_err(|_| format!("Invalid weight: {}", p)))
159+
.map(|p| parse_weight(p.trim()))
120160
.collect()
121161
}
122162

@@ -171,6 +211,23 @@ pub fn parse_reps_and_comment(s: &str) -> Result<(Vec<u32>, Option<String>), Str
171211
Ok((reps, comment))
172212
}
173213

214+
#[allow(dead_code)]
215+
pub fn parse_rpe(s: &str) -> Option<f32> {
216+
let s = s.trim();
217+
if !s.starts_with('@') {
218+
return None;
219+
}
220+
let s = &s[1..].trim();
221+
// Optional "rpe "
222+
let s = if s.to_lowercase().starts_with("rpe ") {
223+
&s[4..].trim()
224+
} else {
225+
s
226+
};
227+
// Parse number, can be float
228+
s.parse().ok()
229+
}
230+
174231
pub fn parse_set_line(line: &str) -> Result<Vec<Set>, String> {
175232
let line = line.trim();
176233
let parts: Vec<&str> = line.split_whitespace().collect();
@@ -223,25 +280,56 @@ pub fn parse_set_line(line: &str) -> Result<Vec<Set>, String> {
223280
comment = Some(parts[weights_end..].join(" "));
224281
}
225282
}
283+
// Parse RPE from comment
284+
let mut rpe = None;
285+
let mut final_comment = comment.clone();
286+
if let Some(c) = &comment {
287+
if c.trim().starts_with('@') {
288+
let trimmed = c.trim();
289+
let after_at = &trimmed[1..];
290+
let rpe_part_end = after_at.find(' ').unwrap_or(after_at.len());
291+
let rpe_part = &after_at[..rpe_part_end];
292+
if let Ok(r) = rpe_part.parse::<f32>() {
293+
rpe = Some(r);
294+
let rpe_full = &trimmed[..1 + rpe_part_end];
295+
let rest = trimmed.strip_prefix(rpe_full).unwrap_or(trimmed).trim();
296+
final_comment = if rest.is_empty() { None } else { Some(rest.to_string()) };
297+
}
298+
}
299+
}
300+
// Determine lb and usebw
301+
let mut lb = 0.0;
302+
let mut usebw = 0;
303+
let mut w_vals = Vec::new();
304+
for (v, is_lb, ubw) in weights {
305+
if is_lb {
306+
lb = 1.0;
307+
}
308+
if ubw != 0 {
309+
usebw = ubw;
310+
}
311+
w_vals.push(v);
312+
}
226313
// Now create the sets
227314
let mut result = Vec::new();
228-
for &w in &weights {
315+
for &w in &w_vals {
229316
for &r in &reps {
230317
result.push(Set {
231318
w: Some(w),
232319
r: Some(r),
233320
s: Some(sets),
234-
lb: Some(0.0),
235-
rpe: None,
236-
c: None,
321+
lb: Some(lb),
322+
rpe,
323+
c: final_comment.clone(),
237324
set_type: Some(0),
325+
usebw: if usebw != 0 { Some(usebw) } else { None },
238326
});
239327
}
240328
}
241-
// Set comment on the last set
242-
if let Some(c) = comment {
243-
if let Some(last) = result.last_mut() {
244-
last.c = Some(c);
329+
// If multiple sets, only last has comment
330+
if result.len() > 1 {
331+
for i in 0..result.len() - 1 {
332+
result[i].c = None;
245333
}
246334
}
247335
Ok(result)

tests/test_parsers.rs

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ fn test_round_trip() {
3535
};
3636
let ex_wrapper1 = ExerciseWrapper { exercise: exercise1 };
3737
let sets1 = vec![
38-
Set { w: Some(175.0), r: Some(10), s: Some(3), lb: Some(0.0), rpe: None, c: None, set_type: Some(0) },
38+
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 },
3939
];
4040
let eblock1 = EBlock {
4141
eid: "lat-pulldown".to_string(),
@@ -48,7 +48,7 @@ fn test_round_trip() {
4848
};
4949
let ex_wrapper2 = ExerciseWrapper { exercise: exercise2 };
5050
let sets2 = vec![
51-
Set { w: Some(175.0), r: Some(10), s: Some(3), lb: Some(0.0), rpe: None, c: None, set_type: Some(0) },
51+
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 },
5252
];
5353
let eblock2 = EBlock {
5454
eid: "cable-low-row".to_string(),
@@ -333,3 +333,116 @@ TM: 465
333333
let reformatted = format_workout_for_cache("2025-10-31", &parsed_jday);
334334
assert_eq!(reformatted, cache_text);
335335
}
336+
337+
#[test]
338+
fn test_parse_rpe() {
339+
// Test RPE parsing
340+
let sets = parse_set_line("405 x 5 @8").unwrap();
341+
assert_eq!(sets.len(), 1);
342+
assert_eq!(sets[0].w, Some(405.0));
343+
assert_eq!(sets[0].r, Some(5));
344+
assert_eq!(sets[0].s, Some(1));
345+
assert_eq!(sets[0].rpe, Some(8.0));
346+
assert_eq!(sets[0].c, None);
347+
348+
let sets = parse_set_line("405 x 5 @7.5 hard").unwrap();
349+
assert_eq!(sets.len(), 1);
350+
assert_eq!(sets[0].rpe, Some(7.5));
351+
assert_eq!(sets[0].c, Some("hard".to_string()));
352+
}
353+
354+
#[test]
355+
fn test_parse_bw_exercises() {
356+
// Test BW exercises
357+
let sets = parse_set_line("BW x 10").unwrap();
358+
assert_eq!(sets.len(), 1);
359+
assert_eq!(sets[0].w, Some(0.0));
360+
assert_eq!(sets[0].r, Some(10));
361+
assert_eq!(sets[0].usebw, Some(1));
362+
363+
let sets = parse_set_line("BW+ 20 x 5").unwrap();
364+
assert_eq!(sets.len(), 1);
365+
assert_eq!(sets[0].w, Some(20.0));
366+
assert_eq!(sets[0].r, Some(5));
367+
assert_eq!(sets[0].usebw, Some(1));
368+
369+
let sets = parse_set_line("BW- 10 x 5").unwrap();
370+
assert_eq!(sets.len(), 1);
371+
assert_eq!(sets[0].w, Some(10.0));
372+
assert_eq!(sets[0].r, Some(5));
373+
assert_eq!(sets[0].usebw, Some(-1));
374+
}
375+
376+
#[test]
377+
fn test_parse_units() {
378+
// Test units
379+
let sets = parse_set_line("405 lb x 5").unwrap();
380+
assert_eq!(sets.len(), 1);
381+
assert_eq!(sets[0].w, Some(405.0));
382+
assert_eq!(sets[0].lb, Some(1.0));
383+
384+
let sets = parse_set_line("180 kg x 5").unwrap();
385+
assert_eq!(sets.len(), 1);
386+
assert_eq!(sets[0].w, Some(180.0));
387+
assert_eq!(sets[0].lb, Some(0.0));
388+
}
389+
390+
#[test]
391+
fn test_format_rpe() {
392+
let set = Set {
393+
w: Some(405.0),
394+
r: Some(5),
395+
s: Some(1),
396+
lb: Some(0.0),
397+
rpe: Some(8.0),
398+
c: None,
399+
set_type: Some(0),
400+
usebw: None,
401+
};
402+
unsafe { std::env::set_var("WXRUST_COLOR", "never"); }
403+
let formatted = format_set(&set);
404+
assert_eq!(formatted, "405 x 5 @8");
405+
}
406+
407+
#[test]
408+
fn test_format_bw() {
409+
let set = Set {
410+
w: Some(0.0),
411+
r: Some(10),
412+
s: Some(1),
413+
lb: Some(0.0),
414+
rpe: None,
415+
c: None,
416+
set_type: Some(0),
417+
usebw: Some(1),
418+
};
419+
unsafe { std::env::set_var("WXRUST_COLOR", "never"); }
420+
let formatted = format_set(&set);
421+
assert_eq!(formatted, "BW x 10");
422+
423+
let set = Set {
424+
w: Some(20.0),
425+
r: Some(5),
426+
s: Some(1),
427+
lb: Some(0.0),
428+
rpe: None,
429+
c: None,
430+
set_type: Some(0),
431+
usebw: Some(1),
432+
};
433+
let formatted = format_set(&set);
434+
assert_eq!(formatted, "BW+20 x 5");
435+
436+
let set = Set {
437+
w: Some(10.0),
438+
r: Some(5),
439+
s: Some(1),
440+
lb: Some(0.0),
441+
rpe: None,
442+
c: None,
443+
set_type: Some(0),
444+
usebw: Some(-1),
445+
};
446+
let formatted = format_set(&set);
447+
assert_eq!(formatted, "BW-10 x 5");
448+
}

0 commit comments

Comments
 (0)