feat: list_workouts and get_workout commands (phase 2)

pull/4/head
lambda 6 days ago
parent a08b3ed76c
commit 46e6510f05
No known key found for this signature in database
GPG Key ID: CF45A8EE158BBCB6

@ -217,6 +217,18 @@ fn trainer_device(state: &State) -> Result<Device, String> {
.ok_or_else(|| "станок не найден".to_string()) .ok_or_else(|| "станок не найден".to_string())
} }
/// Список доступных Ride-воркаутов (сводки).
#[tauri::command]
fn list_workouts() -> Result<Vec<workout::WorkoutSummary>, String> {
workout::list_workouts(&workout::workouts_dir()).map_err(|e| e.to_string())
}
/// Полный воркаут по имени файла (из каталога воркаутов).
#[tauri::command]
fn get_workout(path: String) -> Result<workout::Workout, String> {
workout::read_workout(&path).map_err(|e| e.to_string())
}
/// Why the watchdog loop stopped consuming a stream. /// Why the watchdog loop stopped consuming a stream.
enum StreamOutcome { enum StreamOutcome {
/// The stream ended (peripheral disconnected / notifications closed). /// The stream ended (peripheral disconnected / notifications closed).
@ -384,7 +396,9 @@ pub fn run() {
set_target_power, set_target_power,
set_target_cadence, set_target_cadence,
trainer_start, trainer_start,
trainer_stop trainer_stop,
list_workouts,
get_workout
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

@ -1,8 +1,7 @@
//! Модель воркаутов intervals.icu: парсинг event-JSON и разворачивание //! Модель воркаутов intervals.icu: парсинг event-JSON и разворачивание
//! вложенных повторов в линейный список сегментов для исполнения на станке. //! вложенных повторов в линейный список сегментов для исполнения на станке.
// TODO(phase 2): убрать, когда модуль будет подключён к tauri-командам. use std::path::{Path, PathBuf};
#![allow(dead_code)]
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -52,6 +51,16 @@ pub struct Workout {
pub steps: Vec<WorkoutStep>, 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) ===== // ===== Сырые serde-структуры (повторяют формат /events intervals.icu) =====
#[derive(Deserialize)] #[derive(Deserialize)]
@ -110,6 +119,10 @@ struct RawCadence {
pub enum WorkoutError { pub enum WorkoutError {
#[error("invalid workout JSON: {0}")] #[error("invalid workout JSON: {0}")]
Json(#[from] serde_json::Error), 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() 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -279,5 +350,45 @@ mod tests {
"workout {file}: flat total mismatch" "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());
} }
} }

Loading…
Cancel
Save