|
|
|
|
@ -1,8 +1,7 @@
|
|
|
|
|
//! Модель воркаутов intervals.icu: парсинг event-JSON и разворачивание
|
|
|
|
|
//! вложенных повторов в линейный список сегментов для исполнения на станке.
|
|
|
|
|
|
|
|
|
|
// TODO(phase 2): убрать, когда модуль будет подключён к tauri-командам.
|
|
|
|
|
#![allow(dead_code)]
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
|
|
|
|
@ -52,6 +51,16 @@ pub struct Workout {
|
|
|
|
|
pub steps: Vec<WorkoutStep>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Сводка воркаута для списка.
|
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
|
|
|
pub struct WorkoutSummary {
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub date: String,
|
|
|
|
|
pub duration_min: u64,
|
|
|
|
|
pub load: Option<f64>,
|
|
|
|
|
pub path: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Сырые serde-структуры (повторяют формат /events intervals.icu) =====
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
@ -110,6 +119,10 @@ struct RawCadence {
|
|
|
|
|
pub enum WorkoutError {
|
|
|
|
|
#[error("invalid workout JSON: {0}")]
|
|
|
|
|
Json(#[from] serde_json::Error),
|
|
|
|
|
#[error("io error: {0}")]
|
|
|
|
|
Io(#[from] std::io::Error),
|
|
|
|
|
#[error("unsafe workout path: {0}")]
|
|
|
|
|
InvalidPath(String),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Парсинг =====
|
|
|
|
|
@ -185,6 +198,64 @@ pub fn total_duration_secs(segments: &[Segment]) -> u64 {
|
|
|
|
|
segments.iter().map(|s| s.duration_secs as u64).sum()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Каталог воркаутов: env `RIDEIN_WORKOUTS_DIR`, иначе `workouts/` рядом с cwd.
|
|
|
|
|
pub fn workouts_dir() -> PathBuf {
|
|
|
|
|
std::env::var("RIDEIN_WORKOUTS_DIR")
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.unwrap_or_else(|_| PathBuf::from("workouts"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Сканирует каталог и возвращает сводки Ride-воркаутов (с непустыми шагами),
|
|
|
|
|
/// отсортированные по дате. `events.json` и неразбираемые файлы пропускаются.
|
|
|
|
|
pub fn list_workouts(dir: &Path) -> Result<Vec<WorkoutSummary>, WorkoutError> {
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
for entry in std::fs::read_dir(dir)? {
|
|
|
|
|
let entry = entry?;
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if path.file_name().and_then(|f| f.to_str()) == Some("events.json") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let workout = match read_workout_file(&path) {
|
|
|
|
|
Ok(w) => w,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("skip unparseable workout {}: {e}", path.display());
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if workout.steps.is_empty() {
|
|
|
|
|
continue; // не Ride (нет workout_doc.steps)
|
|
|
|
|
}
|
|
|
|
|
let duration_min = total_duration_secs(&flatten(&workout.steps)) / 60;
|
|
|
|
|
out.push(WorkoutSummary {
|
|
|
|
|
name: workout.name.clone(),
|
|
|
|
|
date: workout.start_date_local.clone().unwrap_or_default(),
|
|
|
|
|
duration_min,
|
|
|
|
|
load: workout.icu_training_load,
|
|
|
|
|
path: path.file_name().unwrap().to_string_lossy().into_owned(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
out.sort_by(|a, b| a.date.cmp(&b.date));
|
|
|
|
|
Ok(out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Читает воркаут по имени файла (без пути) из каталога воркаутов.
|
|
|
|
|
pub fn read_workout(filename: &str) -> Result<Workout, WorkoutError> {
|
|
|
|
|
let fname = Path::new(filename);
|
|
|
|
|
if filename.contains("..") || fname.components().count() != 1 {
|
|
|
|
|
return Err(WorkoutError::InvalidPath(filename.to_string()));
|
|
|
|
|
}
|
|
|
|
|
let path = workouts_dir().join(fname);
|
|
|
|
|
read_workout_file(&path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_workout_file(path: &Path) -> Result<Workout, WorkoutError> {
|
|
|
|
|
let json = std::fs::read_to_string(path)?;
|
|
|
|
|
parse_workout(&json)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
@ -279,5 +350,45 @@ mod tests {
|
|
|
|
|
"workout {file}: flat total mismatch"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// list_workouts на реальном каталоге: 3 Ride-воркаута, по дате.
|
|
|
|
|
let dir = std::path::Path::new("/home/dude/code/ridein/workouts");
|
|
|
|
|
let list = list_workouts(dir).unwrap();
|
|
|
|
|
assert_eq!(list.len(), 3);
|
|
|
|
|
assert_eq!(list[0].name, "Threshold Over-Under 2x20");
|
|
|
|
|
assert_eq!(list[1].name, "VO2max 5x4min");
|
|
|
|
|
assert_eq!(list[2].name, "Long Z2 (Endurance)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn lists_ride_workouts_skipping_non_ride() {
|
|
|
|
|
let dir = std::env::temp_dir().join("ridein-workout-list-test");
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
|
// Ride-воркаут (с шагами)
|
|
|
|
|
std::fs::write(dir.join("2026-10-28_test-a.json"), EVENT).unwrap();
|
|
|
|
|
// events.json — пропускается по имени
|
|
|
|
|
std::fs::write(dir.join("events.json"), "[{}]").unwrap();
|
|
|
|
|
// не-Ride (пустой workout_doc) — пропускается
|
|
|
|
|
std::fs::write(
|
|
|
|
|
dir.join("2026-10-30_yoga.json"),
|
|
|
|
|
r#"{"name":"Yoga","workout_doc":{"steps":[]}}"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let list = list_workouts(&dir).unwrap();
|
|
|
|
|
assert_eq!(list.len(), 1);
|
|
|
|
|
assert_eq!(list[0].name, "Test");
|
|
|
|
|
assert_eq!(list[0].duration_min, 36); // 2160s / 60
|
|
|
|
|
assert_eq!(list[0].load, Some(91.0));
|
|
|
|
|
assert_eq!(list[0].path, "2026-10-28_test-a.json");
|
|
|
|
|
|
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn read_workout_rejects_traversal() {
|
|
|
|
|
assert!(read_workout("../etc/passwd").is_err());
|
|
|
|
|
assert!(read_workout("a/b.json").is_err());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|