diff --git a/src/db.rs b/src/db.rs index 7214d0b..b003f3e 100644 --- a/src/db.rs +++ b/src/db.rs @@ -14,11 +14,7 @@ pub fn hash_token(token: &str) -> Vec { hasher.finalize().to_vec() } -pub async fn create_session( - db: &PgPool, - athlete_id: i64, - token: &str, -) -> Result<()> { +pub async fn create_session(db: &PgPool, athlete_id: i64, token: &str) -> Result<()> { let hash = hash_token(token); sqlx::query( @@ -38,10 +34,7 @@ pub async fn create_session( Ok(()) } -pub async fn athlete_for_session( - db: &PgPool, - token: &str, -) -> Result> { +pub async fn athlete_for_session(db: &PgPool, token: &str) -> Result> { let hash = hash_token(token); let row = sqlx::query( @@ -62,11 +55,9 @@ pub async fn athlete_for_session( use sqlx::Row; if let Some(row) = row { - let id: i64 = - row.try_get("id")?; + let id: i64 = row.try_get("id")?; - let name: String = - row.try_get("display_name")?; + let name: String = row.try_get("display_name")?; sqlx::query( r#" @@ -93,10 +84,7 @@ pub struct SessionAthlete { pub access_token: String, } -pub async fn session_athlete( - db: &PgPool, - token: &str, -) -> Result> { +pub async fn session_athlete(db: &PgPool, token: &str) -> Result> { let hash = hash_token(token); let row = sqlx::query( @@ -166,9 +154,7 @@ pub async fn last_synced_activity_time( Ok(row.try_get("last_synced")?) } -pub async fn delete_old_oauth_states( - db: &PgPool, -) -> Result<()> { +pub async fn delete_old_oauth_states(db: &PgPool) -> Result<()> { sqlx::query( r#" DELETE FROM oauth_states diff --git a/src/main.rs b/src/main.rs index 5dc451d..fdac108 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,32 +5,24 @@ mod geo; mod intervals; mod leaderboard; mod model; -mod webhook; mod web; +mod webhook; -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result, anyhow}; use axum::{ + Router, extract::{Path, Query, State}, response::Html, routing::{get, post}, - Router, }; use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::PgPool; use tower_http::trace::TraceLayer; -use tracing_subscriber::{ - layer::SubscriberExt, - util::SubscriberInitExt, -}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use crate::{ - config::Config, - db::SessionAthlete, - intervals::IntervalsClient, - model::TrackPoint, -}; +use crate::{config::Config, db::SessionAthlete, intervals::IntervalsClient, model::TrackPoint}; #[derive(Clone)] pub struct AppState { @@ -57,14 +49,11 @@ pub struct SyncActivity { #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::from_default_env(), - ) + .with(tracing_subscriber::EnvFilter::from_default_env()) .with(tracing_subscriber::fmt::layer()) .init(); - let config = - Config::from_env()?; + let config = Config::from_env()?; tracing::info!( database_url = %redact_database_url( @@ -73,21 +62,14 @@ async fn main() -> Result<()> { "connecting to PostgreSQL" ); - let db = - PgPool::connect( - &config.database_url - ) + let db = PgPool::connect(&config.database_url) .await - .context( - "cannot connect to PostgreSQL" - )?; + .context("cannot connect to PostgreSQL")?; sqlx::migrate!() .run(&db) .await - .context( - "database migration failed" - )?; + .context("database migration failed")?; let state = AppState { db, @@ -95,42 +77,15 @@ async fn main() -> Result<()> { }; let app = Router::new() - .route( - "/", - get(web::index), - ) - .route( - "/health", - get(health), - ) - .route( - "/oauth/start", - get(auth::start), - ) - .route( - "/oauth/callback", - get(auth::callback), - ) - .route( - "/logout", - get(auth::logout), - ) - .route( - "/webhooks/intervals", - post(webhook::receive), - ) - .route( - "/api/leaderboard", - get(leaderboard::api), - ) - .route( - "/sync", - get(sync_index), - ) - .route( - "/sync/{athlete_id}", - get(sync_athlete), - ) + .route("/", get(web::index)) + .route("/health", get(health)) + .route("/oauth/start", get(auth::start)) + .route("/oauth/callback", get(auth::callback)) + .route("/logout", get(auth::logout)) + .route("/webhooks/intervals", post(webhook::receive)) + .route("/api/leaderboard", get(leaderboard::api)) + .route("/sync", get(sync_index)) + .route("/sync/{athlete_id}", get(sync_athlete)) .route( "/sync/{athlete_id}/activities/{activity_id}/import", post(sync_import_activity), @@ -139,27 +94,17 @@ async fn main() -> Result<()> { "/sync/{athlete_id}/import-visible", post(sync_import_visible), ) - .layer( - TraceLayer::new_for_http() - ) + .layer(TraceLayer::new_for_http()) .with_state(state); - let listener = - tokio::net::TcpListener::bind( - &config.bind_address - ) - .await?; + let listener = tokio::net::TcpListener::bind(&config.bind_address).await?; tracing::info!( address = %config.bind_address, "server listening" ); - axum::serve( - listener, - app, - ) - .await?; + axum::serve(listener, app).await?; Ok(()) } @@ -168,16 +113,10 @@ async fn health() -> &'static str { "ok" } -fn redact_database_url( - url: &str, -) -> String { - if let Some((prefix, _)) = - url.split_once('@') - { +fn redact_database_url(url: &str) -> String { + if let Some((prefix, _)) = url.split_once('@') { if prefix.contains("://") { - return format!( - "{prefix}@***" - ); + return format!("{prefix}@***"); } } @@ -188,20 +127,11 @@ async fn current_session_athlete( state: &AppState, jar: &axum_extra::extract::cookie::CookieJar, ) -> Result { - let cookie = - jar.get("county_session") - .context( - "not authenticated" - )?; + let cookie = jar.get("county_session").context("not authenticated")?; - db::session_athlete( - &state.db, - cookie.value(), - ) - .await? - .context( - "session is invalid or expired" - ) + db::session_athlete(&state.db, cookie.value()) + .await? + .context("session is invalid or expired") } async fn require_athlete_access( @@ -209,16 +139,9 @@ async fn require_athlete_access( jar: &axum_extra::extract::cookie::CookieJar, athlete_id: &str, ) -> Result { - let session = - current_session_athlete( - state, - jar, - ) - .await?; + let session = current_session_athlete(state, jar).await?; - if session.intervals_athlete_id - != athlete_id - { + if session.intervals_athlete_id != athlete_id { return Err(anyhow!( "current authentication token has no access to athlete {}", athlete_id @@ -232,11 +155,7 @@ async fn sync_index( State(state): State, jar: axum_extra::extract::cookie::CookieJar, ) -> Result, (axum::http::StatusCode, String)> { - let session = - current_session_athlete( - &state, - &jar, - ) + let session = current_session_athlete(&state, &jar) .await .map_err(http_error)?; @@ -303,15 +222,9 @@ a {{

"#, - web_escape( - &session.display_name - ), - web_escape( - &session.intervals_athlete_id - ), - url_segment( - &session.intervals_athlete_id - ), + web_escape(&session.display_name), + web_escape(&session.intervals_athlete_id), + url_segment(&session.intervals_athlete_id), ); Ok(Html(html)) @@ -323,12 +236,7 @@ async fn sync_athlete( Query(query): Query, jar: axum_extra::extract::cookie::CookieJar, ) -> Result, (axum::http::StatusCode, String)> { - let session = - require_athlete_access( - &state, - &jar, - &athlete_id, - ) + let session = require_athlete_access(&state, &jar, &athlete_id) .await .map_err(http_error)?; @@ -340,81 +248,38 @@ async fn sync_athlete( * * An explicit query value overrides the default. */ - let default_oldest = - db::last_synced_activity_time( - &state.db, - session.id, - ) + let default_oldest = db::last_synced_activity_time(&state.db, session.id) .await .map_err(http_error)? - .unwrap_or_else(|| { - Utc::now() - - Duration::days(30) - }); + .unwrap_or_else(|| Utc::now() - Duration::days(30)); - let oldest = - parse_datetime_local( - query.oldest.as_deref() + let oldest = parse_datetime_local(query.oldest.as_deref()).unwrap_or(default_oldest); + + let newest = parse_datetime_local(query.newest.as_deref()).unwrap_or_else(|| Utc::now()); + + let client = IntervalsClient::with_api_key(state.config.clone(), session.access_token.clone()); + + let activities = client + .activities( + &athlete_id, + &oldest.format("%Y-%m-%d").to_string(), + &newest.format("%Y-%m-%d").to_string(), ) - .unwrap_or( - default_oldest - ); + .await + .map_err(http_error)?; - let newest = - parse_datetime_local( - query.newest.as_deref() - ) - .unwrap_or_else( - || Utc::now() - ); + let activities = activities + .as_array() + .ok_or_else(|| http_error(anyhow!("activities response is not an array")))?; - let client = - IntervalsClient::with_api_key( - state.config.clone(), - session.access_token.clone(), - ); - - let activities = - client - .activities( - &athlete_id, - &oldest - .format("%Y-%m-%d") - .to_string(), - &newest - .format("%Y-%m-%d") - .to_string(), - ) - .await - .map_err(http_error)?; - - let activities = - activities - .as_array() - .ok_or_else(|| { - http_error(anyhow!( - "activities response is not an array" - )) - })?; - - let mut visible = - Vec::::new(); + let mut visible = Vec::::new(); for activity in activities { - let Some(activity_id) = - activity - .get("id") - .and_then(Value::as_str) - else { + let Some(activity_id) = activity.get("id").and_then(Value::as_str) else { continue; }; - let imported = - activity_exists( - &state.db, - session.id, - activity_id, - ) + let imported = activity_exists(&state.db, session.id, activity_id) .await .map_err(http_error)?; @@ -422,53 +287,32 @@ async fn sync_athlete( continue; } - let name = - activity - .get("name") - .and_then(Value::as_str) - .unwrap_or( - "Aktivität" - ); + let name = activity + .get("name") + .and_then(Value::as_str) + .unwrap_or("Aktivität"); - let activity_type = - activity - .get("type") - .and_then(Value::as_str) - .unwrap_or(""); + let activity_type = activity.get("type").and_then(Value::as_str).unwrap_or(""); - let start_time = - activity - .get("start_date_local") - .or_else(|| { - activity.get( - "start_date" - ) - }) - .and_then(Value::as_str); + let start_time = activity + .get("start_date_local") + .or_else(|| activity.get("start_date")) + .and_then(Value::as_str); - let distance = - activity - .get("distance") - .and_then(Value::as_f64); + let distance = activity.get("distance").and_then(Value::as_f64); - visible.push( - SyncActivity { - id: activity_id.to_string(), - name: name.to_string(), - activity_type: - activity_type.to_string(), - start_time: - start_time - .map(str::to_string), - distance, - imported: false, - } - ); + visible.push(SyncActivity { + id: activity_id.to_string(), + name: name.to_string(), + activity_type: activity_type.to_string(), + start_time: start_time.map(str::to_string), + distance, + imported: false, + }); } - let mut html = - String::from( - r#" + let mut html = String::from( + r#" @@ -538,13 +382,11 @@ input[type="datetime-local"] { "#, - ); + ); html.push_str(&format!( "

Sync: {}

", - web_escape( - &session.display_name - ) + web_escape(&session.display_name) )); html.push_str( @@ -553,15 +395,9 @@ input[type="datetime-local"] {

"#, ); - let oldest_input = - format_datetime_local( - oldest - ); + let oldest_input = format_datetime_local(oldest); - let newest_input = - format_datetime_local( - newest - ); + let newest_input = format_datetime_local(newest); html.push_str(&format!( r#"
@@ -626,15 +462,9 @@ input[type="datetime-local"] {
"#, - url_segment( - &athlete_id - ), - web_escape( - &oldest_input - ), - web_escape( - &newest_input - ), + url_segment(&athlete_id), + web_escape(&oldest_input), + web_escape(&newest_input), )); } @@ -648,21 +478,12 @@ input[type="datetime-local"] { } for activity in &visible { - let distance = - activity.distance - .map(|m| { - format!( - "{:.1} km", - m / 1000.0 - ) - }) - .unwrap_or_else( - || "—".into() - ); + let distance = activity + .distance + .map(|m| format!("{:.1} km", m / 1000.0)) + .unwrap_or_else(|| "—".into()); - html.push_str( - r#"
"#, - ); + html.push_str(r#"
"#); html.push_str(&format!( r#"
@@ -671,18 +492,9 @@ input[type="datetime-local"] { {} · {} · {}
"#, - web_escape( - &activity.name - ), - web_escape( - &activity.activity_type - ), - web_escape( - activity - .start_time - .as_deref() - .unwrap_or("") - ), + web_escape(&activity.name), + web_escape(&activity.activity_type), + web_escape(activity.start_time.as_deref().unwrap_or("")), distance, )); @@ -694,17 +506,11 @@ input[type="datetime-local"] { Analysieren "#, - url_segment( - &athlete_id - ), - url_segment( - &activity.id - ), + url_segment(&athlete_id), + url_segment(&activity.id), )); - html.push_str( - "
", - ); + html.push_str(""); } html.push_str( @@ -720,27 +526,16 @@ input[type="datetime-local"] { async fn sync_import_activity( State(state): State, - Path((athlete_id, activity_id)): Path<( - String, - String, - )>, + Path((athlete_id, activity_id)): Path<(String, String)>, jar: axum_extra::extract::cookie::CookieJar, ) -> Result, (axum::http::StatusCode, String)> { - require_athlete_access( - &state, - &jar, - &athlete_id, - ) - .await - .map_err(http_error)?; + require_athlete_access(&state, &jar, &athlete_id) + .await + .map_err(http_error)?; - process_activity( - state.clone(), - athlete_id.clone(), - activity_id.clone(), - ) - .await - .map_err(http_error)?; + process_activity(state.clone(), athlete_id.clone(), activity_id.clone()) + .await + .map_err(http_error)?; Ok(Html(format!( r#" @@ -760,15 +555,9 @@ Aktivität {} wurde importiert.

"#, - url_segment( - &athlete_id - ), - web_escape( - &activity_id - ), - url_segment( - &athlete_id - ), + url_segment(&athlete_id), + web_escape(&activity_id), + url_segment(&athlete_id), ))) } @@ -785,77 +574,36 @@ async fn sync_import_visible( jar: axum_extra::extract::cookie::CookieJar, axum::Form(form): axum::Form, ) -> Result, (axum::http::StatusCode, String)> { - let session = - require_athlete_access( - &state, - &jar, - &athlete_id, - ) + let session = require_athlete_access(&state, &jar, &athlete_id) .await .map_err(http_error)?; let oldest = - parse_datetime_local( - Some(&form.oldest) - ) - .unwrap_or_else( - || { - Utc::now() - - Duration::days(30) - } - ); + parse_datetime_local(Some(&form.oldest)).unwrap_or_else(|| Utc::now() - Duration::days(30)); - let newest = - parse_datetime_local( - Some(&form.newest) - ) - .unwrap_or_else( - || Utc::now() - ); + let newest = parse_datetime_local(Some(&form.newest)).unwrap_or_else(|| Utc::now()); - let client = - IntervalsClient::with_api_key( - state.config.clone(), - session.access_token.clone(), - ); + let client = IntervalsClient::with_api_key(state.config.clone(), session.access_token.clone()); - let activities = - client.activities( + let activities = client + .activities( &athlete_id, - &oldest - .format("%Y-%m-%d") - .to_string(), - &newest - .format("%Y-%m-%d") - .to_string(), + &oldest.format("%Y-%m-%d").to_string(), + &newest.format("%Y-%m-%d").to_string(), ) .await .map_err(http_error)?; - let activities = - activities - .as_array() - .ok_or_else(|| { - http_error(anyhow!( - "activities response is not an array" - )) - })?; + let activities = activities + .as_array() + .ok_or_else(|| http_error(anyhow!("activities response is not an array")))?; for activity in activities { - let Some(activity_id) = - activity - .get("id") - .and_then(Value::as_str) - else { + let Some(activity_id) = activity.get("id").and_then(Value::as_str) else { continue; }; - let imported = - activity_exists( - &state.db, - session.id, - activity_id, - ) + let imported = activity_exists(&state.db, session.id, activity_id) .await .map_err(http_error)?; @@ -868,25 +616,17 @@ async fn sync_import_visible( * so the exact time range is checked again here before * importing. */ - let Some(activity_time) = - activity_start_time(activity) - else { + let Some(activity_time) = activity_start_time(activity) else { continue; }; - if activity_time < oldest - || activity_time > newest - { + if activity_time < oldest || activity_time > newest { continue; } - process_activity( - state.clone(), - athlete_id.clone(), - activity_id.to_string(), - ) - .await - .map_err(http_error)?; + process_activity(state.clone(), athlete_id.clone(), activity_id.to_string()) + .await + .map_err(http_error)?; } Ok(Html(format!( @@ -907,29 +647,16 @@ Aktivitäten werden importiert …

"#, - url_segment( - &athlete_id - ), - url_query( - &form.oldest - ), - url_query( - &form.newest - ), - url_segment( - &athlete_id - ), + url_segment(&athlete_id), + url_query(&form.oldest), + url_query(&form.newest), + url_segment(&athlete_id), ))) } -async fn activity_exists( - db: &PgPool, - athlete_id: i64, - activity_id: &str, -) -> Result { - let row = - sqlx::query( - r#" +async fn activity_exists(db: &PgPool, athlete_id: i64, activity_id: &str) -> Result { + let row = sqlx::query( + r#" SELECT EXISTS ( SELECT 1 FROM activities @@ -937,62 +664,36 @@ async fn activity_exists( AND intervals_activity_id = $2 ) AS exists "#, - ) - .bind(athlete_id) - .bind(activity_id) - .fetch_one(db) - .await?; + ) + .bind(athlete_id) + .bind(activity_id) + .fetch_one(db) + .await?; use sqlx::Row; Ok(row.try_get("exists")?) } -fn activity_start_time( - activity: &Value, -) -> Option> { - let value = - activity - .get("start_date") - .or_else(|| { - activity.get( - "start_date_local" - ) - })?; +fn activity_start_time(activity: &Value) -> Option> { + let value = activity + .get("start_date") + .or_else(|| activity.get("start_date_local"))?; value .as_str() - .and_then(|value| { - parse_datetime_local( - Some(value) - ) - }) + .and_then(|value| parse_datetime_local(Some(value))) } -fn parse_datetime_local( - value: Option<&str>, -) -> Option> { +fn parse_datetime_local(value: Option<&str>) -> Option> { let value = value?; - if let Ok(parsed) = - DateTime::parse_from_rfc3339( - value - ) - { - return Some( - parsed.with_timezone(&Utc) - ); + if let Ok(parsed) = DateTime::parse_from_rfc3339(value) { + return Some(parsed.with_timezone(&Utc)); } - if let Ok(parsed) = - DateTime::parse_from_str( - value, - "%Y-%m-%dT%H:%M", - ) - { - return Some( - parsed.with_timezone(&Utc) - ); + if let Ok(parsed) = DateTime::parse_from_str(value, "%Y-%m-%dT%H:%M") { + return Some(parsed.with_timezone(&Utc)); } /* @@ -1005,33 +706,16 @@ fn parse_datetime_local( * Since the application is intended to run in one local * environment for now, this is sufficient. */ - chrono::NaiveDateTime::parse_from_str( - value, - "%Y-%m-%dT%H:%M", - ) - .ok() - .map(|naive| { - DateTime::::from_naive_utc_and_offset( - naive, - Utc, - ) - }) + chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M") + .ok() + .map(|naive| DateTime::::from_naive_utc_and_offset(naive, Utc)) } -fn format_datetime_local( - value: DateTime, -) -> String { - value - .format("%Y-%m-%dT%H:%M") - .to_string() +fn format_datetime_local(value: DateTime) -> String { + value.format("%Y-%m-%dT%H:%M").to_string() } -fn http_error( - error: anyhow::Error, -) -> ( - axum::http::StatusCode, - String, -) { +fn http_error(error: anyhow::Error) -> (axum::http::StatusCode, String) { tracing::error!( error = ?error, "request failed" @@ -1043,9 +727,7 @@ fn http_error( ) } -fn web_escape( - value: &str, -) -> String { +fn web_escape(value: &str) -> String { value .replace('&', "&") .replace('<', "<") @@ -1054,22 +736,12 @@ fn web_escape( .replace('\'', "'") } -fn url_segment( - value: &str, -) -> String { - urlencoding::encode( - value - ) - .into_owned() +fn url_segment(value: &str) -> String { + urlencoding::encode(value).into_owned() } -fn url_query( - value: &str, -) -> String { - urlencoding::encode( - value - ) - .into_owned() +fn url_query(value: &str) -> String { + urlencoding::encode(value).into_owned() } pub async fn process_activity( @@ -1077,21 +749,18 @@ pub async fn process_activity( intervals_athlete_id: String, activity_id: String, ) -> Result<()> { - let athlete = - sqlx::query( - r#" + let athlete = sqlx::query( + r#" SELECT id, access_token FROM athletes WHERE intervals_athlete_id = $1 "#, - ) - .bind( - &intervals_athlete_id - ) - .fetch_optional(&state.db) - .await?; + ) + .bind(&intervals_athlete_id) + .fetch_optional(&state.db) + .await?; use sqlx::Row; @@ -1102,44 +771,19 @@ pub async fn process_activity( )); }; - let athlete_db_id: i64 = - athlete.try_get("id")?; + let athlete_db_id: i64 = athlete.try_get("id")?; - let access_token: String = - athlete.try_get( - "access_token" - )?; + let access_token: String = athlete.try_get("access_token")?; - let client = - IntervalsClient::with_api_key( - state.config.clone(), - access_token, - ); + let client = IntervalsClient::with_api_key(state.config.clone(), access_token); - let activity = - client - .activity( - &activity_id - ) - .await?; + let activity = client.activity(&activity_id).await?; - let start_time = - IntervalsClient::activity_start( - &activity - )?; + let start_time = IntervalsClient::activity_start(&activity)?; - let streams = - client - .streams( - &activity_id - ) - .await?; + let streams = client.streams(&activity_id).await?; - let points = - parse_track( - &streams, - start_time - )?; + let points = parse_track(&streams, start_time)?; if points.len() < 2 { tracing::info!( @@ -1151,50 +795,22 @@ pub async fn process_activity( return Ok(()); } - let mut tx = - state.db.begin().await?; + let mut tx = state.db.begin().await?; let activity_db_id = - db::ensure_activity( - &mut tx, - athlete_db_id, - &activity_id, - start_time, - &activity, - ) - .await?; + db::ensure_activity(&mut tx, athlete_db_id, &activity_id, start_time, &activity).await?; - db::delete_activity_crossings( - &mut tx, - activity_db_id, - ) - .await?; + db::delete_activity_crossings(&mut tx, activity_db_id).await?; let mut crossing_count = 0usize; for pair in points.windows(2) { - let crossings = - geo::crossings_between( - &state.db, - &pair[0], - &pair[1], - ) - .await?; + let crossings = geo::crossings_between(&state.db, &pair[0], &pair[1]).await?; for crossing in crossings { - let interval = - IntervalsClient::matching_interval( - &activity, - crossing.track_index, - ); + let interval = IntervalsClient::matching_interval(&activity, crossing.track_index); - db::save_crossing( - &mut tx, - activity_db_id, - &crossing, - interval.as_ref(), - ) - .await?; + db::save_crossing(&mut tx, activity_db_id, &crossing, interval.as_ref()).await?; crossing_count += 1; @@ -1209,11 +825,7 @@ pub async fn process_activity( } } - db::mark_activity_processed( - &mut tx, - activity_db_id, - ) - .await?; + db::mark_activity_processed(&mut tx, activity_db_id).await?; tx.commit().await?; @@ -1226,94 +838,50 @@ pub async fn process_activity( Ok(()) } -fn parse_track( - streams: &Value, - start_time: DateTime, -) -> Result> { - let times = - IntervalsClient::stream_values( - streams, - "time", - ) - .ok_or_else(|| { - anyhow!( - "Intervals.icu response has no time stream" - ) - })?; +fn parse_track(streams: &Value, start_time: DateTime) -> Result> { + let times = IntervalsClient::stream_values(streams, "time") + .ok_or_else(|| anyhow!("Intervals.icu response has no time stream"))?; - let (latitudes, longitudes) = - IntervalsClient::latlng_values( - streams - ) - .ok_or_else(|| { - anyhow!( - "Intervals.icu response has no usable latlng stream" - ) - })?; + let (latitudes, longitudes) = IntervalsClient::latlng_values(streams) + .ok_or_else(|| anyhow!("Intervals.icu response has no usable latlng stream"))?; - let length = - times - .len() - .min(latitudes.len()) - .min(longitudes.len()); + let length = times.len().min(latitudes.len()).min(longitudes.len()); - let mut result = - Vec::with_capacity(length); + let mut result = Vec::with_capacity(length); for index in 0..length { - let Some(seconds) = - times[index].as_f64() - else { + let Some(seconds) = times[index].as_f64() else { continue; }; - let Some(lat) = - latitudes[index].as_f64() - else { + let Some(lat) = latitudes[index].as_f64() else { continue; }; - let Some(lon) = - longitudes[index].as_f64() - else { + let Some(lon) = longitudes[index].as_f64() else { continue; }; - if !seconds.is_finite() - || !lat.is_finite() - || !lon.is_finite() - { + if !seconds.is_finite() || !lat.is_finite() || !lon.is_finite() { continue; } - if !(-90.0..=90.0) - .contains(&lat) - { + if !(-90.0..=90.0).contains(&lat) { continue; } - if !(-180.0..=180.0) - .contains(&lon) - { + if !(-180.0..=180.0).contains(&lon) { continue; } - let millis = - (seconds * 1000.0) - .round() as i64; + let millis = (seconds * 1000.0).round() as i64; - result.push( - TrackPoint { - index, - time: - start_time - + Duration::milliseconds( - millis - ), - lat, - lon, - } - ); + result.push(TrackPoint { + index, + time: start_time + Duration::milliseconds(millis), + lat, + lon, + }); } Ok(result)