diff --git a/CHANGELOG.md b/CHANGELOG.md index 85b2d42..7ce1032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v6.1.0 (2026-09-12) + +* Add GTFS data caching [#209](https://github.com/gerardcl/renfe-cli/issues/209) +* Add transfer infos [#208](https://github.com/gerardcl/renfe-cli/issues/208) + ## v6.0.0 (2026-09-08) * General maintenance and improvements on GTFS data handling and algorithms [#206](https://github.com/gerardcl/renfe-cli/issues/206) diff --git a/Cargo.lock b/Cargo.lock index a0dede8..1d0e8db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,6 +58,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -306,6 +315,27 @@ dependencies = [ "digest", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -878,6 +908,15 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "libc", +] + [[package]] name = "litemap" version = "0.8.3" @@ -934,6 +973,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1140,16 +1185,30 @@ dependencies = [ "rand_core", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "renfe-cli" version = "6.0.0" dependencies = [ + "bincode", "chrono", + "directories", "getopts", "gtfs-structures", "jiff", "pyo3", "reqwest", + "serde", ] [[package]] @@ -1368,6 +1427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", + "serde_derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2cc9907..01862e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,5 +23,8 @@ pyo3 = { version = "0.29", features = ["abi3-py310"] } getopts = "0.2.24" chrono = "0.4.45" gtfs-structures = { version = "0.50", default-features = false } -jiff = "0.2.35" +jiff = { version = "0.2.35", features = ["serde"] } reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] } +serde = { version = "1.0", features = ["derive"] } +bincode = "1.3" +directories = "6.0" diff --git a/README.md b/README.md index d86c066..f6ffa76 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,22 @@ Destination station: Estación de tren Tres Cantos (apt) =========================================================== ``` +Journeys with connections include the details of every transfer directly +below the timetable row. The arrival and departure are the times at which the +transfer starts and ends; the duration is the total time available to change +trains. Transfers between different stops show both station names: + +```text + Transfer 1 at Madrid-Chamartín: arrive 10:05, depart 10:20 (00:15) + Transfer 2 at Madrid-Atocha → Madrid-Puerta de Atocha: arrive 10:45, depart 11:10 (00:25) +``` + +Downloaded feeds are converted to routing data and cached in the operating +system's user cache directory. On later runs, `renfe-cli` sends Renfe the +stored HTTP `ETag`; when the feed has not changed, Renfe returns `304 Not +Modified` and the processed data is loaded directly from the local cache. +Set `RENFE_CLI_CACHE_DIR` to use a custom cache location. + ## Usage (Library) `renfe-cli` can be imported as a python package into your project, offering utilities when willing to deal with the Renfe search web site. diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..64968f6 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,398 @@ +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use directories::BaseDirs; +use gtfs_structures::{Exception, Gtfs, TransferType}; +use jiff::civil::Date; +use reqwest::StatusCode; +use reqwest::blocking::{Client, Response}; +use reqwest::header::{ETAG, IF_NONE_MATCH}; +use serde::{Deserialize, Serialize}; + +use crate::router::{StopTime, Transfer, Trip}; + +const CACHE_FORMAT_VERSION: u8 = 2; + +#[derive(Clone, Copy)] +pub(crate) enum Feed { + Default, + Cercanias, +} + +impl Feed { + fn url(self) -> &'static str { + match self { + Self::Default => "https://ssl.renfe.com/gtransit/Fichero_AV_LD/google_transit.zip", + Self::Cercanias => "https://ssl.renfe.com/ftransit/Fichero_CER_FOMENTO/fomento_transit.zip", + } + } + + fn cache_name(self) -> &'static str { + match self { + Self::Default => "default", + Self::Cercanias => "cercanias", + } + } + + fn loading_message(self) -> &'static str { + match self { + Self::Default => { + "Loading default GTFS data from Renfe web - Alta velocidad, Larga distancia y Media distancia" + }, + Self::Cercanias => "Loading Cercanías GTFS data from Renfe web - long load time", + } + } +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct TransitData { + pub(crate) stations: HashMap, + pub(crate) trips: Vec, + pub(crate) transfers: Vec, + pub(crate) calendar: HashMap, + pub(crate) calendar_dates: HashMap>, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct TransitStation { + pub(crate) name: String, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct ServiceTrip { + pub(crate) service_id: String, + pub(crate) trip: Trip, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct ServiceCalendar { + pub(crate) monday: bool, + pub(crate) tuesday: bool, + pub(crate) wednesday: bool, + pub(crate) thursday: bool, + pub(crate) friday: bool, + pub(crate) saturday: bool, + pub(crate) sunday: bool, + pub(crate) start_date: Date, + pub(crate) end_date: Date, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct ServiceCalendarDate { + pub(crate) date: Date, + pub(crate) added: bool, +} + +struct CachePaths { + data: PathBuf, + etag: PathBuf, +} + +pub(crate) fn load(feed: Feed) -> Result { + let paths = cache_paths(feed); + let cached_etag = paths + .as_ref() + .and_then(|paths| fs::read_to_string(&paths.etag).ok()) + .filter(|etag| !etag.trim().is_empty()); + + // Renfe's download server currently ignores conditional headers over HTTP/2. + let client = Client::builder() + .http1_only() + .build() + .map_err(|error| format!("Error creating HTTP client: {error}"))?; + let mut request = client.get(feed.url()); + if let Some(etag) = cached_etag.as_deref() { + request = request.header(IF_NONE_MATCH, etag.trim()); + } + + let response = request + .send() + .map_err(|error| format!("Error checking Renfe GTFS data: {error}"))?; + + if response.status() == StatusCode::NOT_MODIFIED + && let Some(paths) = &paths + { + match read_cache(&paths.data) { + Ok(data) => { + println!("Loading processed GTFS data from cache"); + data.print_stats(); + return Ok(data); + }, + Err(error) => { + eprintln!("Cached GTFS data could not be read ({error}); downloading it again"); + let response = client + .get(feed.url()) + .send() + .map_err(|error| format!("Error downloading Renfe GTFS data: {error}"))?; + return download_and_cache(feed, response, Some(paths)); + }, + } + } + + download_and_cache(feed, response, paths.as_ref()) +} + +fn download_and_cache( + feed: Feed, + response: Response, + paths: Option<&CachePaths>, +) -> Result { + let response = response + .error_for_status() + .map_err(|error| format!("Error downloading Renfe GTFS data: {error}"))?; + let etag = response + .headers() + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let body = response + .bytes() + .map_err(|error| format!("Error reading Renfe GTFS data: {error}"))?; + + println!("{}", feed.loading_message()); + let cursor = std::io::Cursor::new(body); + let gtfs = + Gtfs::from_reader(cursor).map_err(|error| format!("Error parsing GTFS zip: {error}"))?; + gtfs.print_stats(); + let data = TransitData::from(gtfs); + + if let Some(paths) = paths + && let Err(error) = write_cache(paths, &data, etag.as_deref()) + { + eprintln!("Processed GTFS data could not be cached: {error}"); + } + + Ok(data) +} + +fn cache_paths(feed: Feed) -> Option { + let root = std::env::var_os("RENFE_CLI_CACHE_DIR") + .map(PathBuf::from) + .or_else(|| BaseDirs::new().map(|directories| directories.cache_dir().join("renfe-cli")))?; + let directory = root.join(format!("v{CACHE_FORMAT_VERSION}")); + Some(CachePaths { + data: directory.join(format!("{}.bin", feed.cache_name())), + etag: directory.join(format!("{}.etag", feed.cache_name())), + }) +} + +fn read_cache(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|error| error.to_string())?; + bincode::deserialize(&bytes).map_err(|error| error.to_string()) +} + +fn write_cache(paths: &CachePaths, data: &TransitData, etag: Option<&str>) -> Result<(), String> { + let directory = paths + .data + .parent() + .ok_or_else(|| "cache path has no parent directory".to_owned())?; + fs::create_dir_all(directory).map_err(|error| error.to_string())?; + let bytes = bincode::serialize(data).map_err(|error| error.to_string())?; + atomic_write(&paths.data, &bytes)?; + + if let Some(etag) = etag { + atomic_write(&paths.etag, etag.as_bytes())?; + } else if paths.etag.exists() { + fs::remove_file(&paths.etag).map_err(|error| error.to_string())?; + } + Ok(()) +} + +fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), String> { + let temporary = path.with_extension(format!("tmp-{}", std::process::id())); + let mut file = fs::File::create(&temporary).map_err(|error| error.to_string())?; + file + .write_all(contents) + .and_then(|()| file.sync_all()) + .map_err(|error| error.to_string())?; + if let Err(error) = fs::rename(&temporary, path) { + if path.exists() { + fs::remove_file(path).map_err(|remove_error| remove_error.to_string())?; + fs::rename(&temporary, path).map_err(|rename_error| rename_error.to_string()) + } else { + Err(error.to_string()) + } + } else { + Ok(()) + } +} + +impl TransitData { + fn print_stats(&self) { + println!("GTFS data:"); + println!(" Stops: {}", self.stations.len()); + println!(" Trips: {}", self.trips.len()); + } +} + +impl From for TransitData { + fn from(gtfs: Gtfs) -> Self { + let transfers = gtfs + .stops + .values() + .flat_map(|stop| { + stop.transfers.iter().map(|transfer| Transfer { + from_stop_id: stop.id.clone(), + to_stop_id: transfer.to_stop_id.clone(), + duration: (transfer.transfer_type != TransferType::Impossible) + .then(|| transfer.min_transfer_time.unwrap_or(5 * 60)), + }) + }) + .collect(); + let stations = gtfs + .stops + .iter() + .map(|(id, stop)| { + ( + id.clone(), + TransitStation { + name: stop.name.clone().unwrap_or_else(|| id.clone()), + }, + ) + }) + .collect(); + let trips = gtfs + .trips + .values() + .filter_map(|trip| { + let stop_times: Vec<_> = trip + .stop_times + .iter() + .filter_map(|stop_time| { + Some(StopTime { + stop_id: stop_time.stop.id.clone(), + arrival: stop_time.arrival_time?, + departure: stop_time.departure_time?, + }) + }) + .collect(); + (stop_times.len() >= 2).then(|| ServiceTrip { + service_id: trip.service_id.clone(), + trip: Trip { + id: trip.id.clone(), + name: gtfs + .get_route(&trip.route_id) + .ok() + .and_then(|route| route.short_name.clone().or_else(|| route.long_name.clone())) + .or_else(|| trip.trip_short_name.clone()) + .unwrap_or_else(|| trip.route_id.clone()), + stop_times, + }, + }) + }) + .collect(); + + Self { + stations, + trips, + transfers, + calendar: gtfs + .calendar + .into_iter() + .map(|(service_id, calendar)| { + ( + service_id, + ServiceCalendar { + monday: calendar.monday, + tuesday: calendar.tuesday, + wednesday: calendar.wednesday, + thursday: calendar.thursday, + friday: calendar.friday, + saturday: calendar.saturday, + sunday: calendar.sunday, + start_date: calendar.start_date, + end_date: calendar.end_date, + }, + ) + }) + .collect(), + calendar_dates: gtfs + .calendar_dates + .into_iter() + .map(|(service_id, dates)| { + ( + service_id, + dates + .into_iter() + .map(|calendar_date| ServiceCalendarDate { + date: calendar_date.date, + added: calendar_date.exception_type == Exception::Added, + }) + .collect(), + ) + }) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn processed_cache_round_trips() { + let data = TransitData { + stations: HashMap::from([( + "station".into(), + TransitStation { + name: "Station".into(), + }, + )]), + trips: vec![ServiceTrip { + service_id: "service".into(), + trip: Trip { + id: "trip".into(), + name: "Train".into(), + stop_times: vec![ + StopTime { + stop_id: "station".into(), + arrival: 100, + departure: 100, + }, + StopTime { + stop_id: "destination".into(), + arrival: 200, + departure: 200, + }, + ], + }, + }], + transfers: Vec::new(), + calendar: HashMap::from([( + "service".into(), + ServiceCalendar { + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: false, + sunday: false, + start_date: Date::new(2026, 1, 1).unwrap(), + end_date: Date::new(2026, 12, 31).unwrap(), + }, + )]), + calendar_dates: HashMap::from([( + "service".into(), + vec![ServiceCalendarDate { + date: Date::new(2026, 6, 1).unwrap(), + added: false, + }], + )]), + }; + + let encoded = bincode::serialize(&data).unwrap(); + let decoded: TransitData = bincode::deserialize(&encoded).unwrap(); + + assert_eq!(decoded.stations["station"].name, "Station"); + assert_eq!(decoded.trips[0].trip.stop_times[1].arrival, 200); + assert_eq!( + decoded.calendar["service"].end_date, + Date::new(2026, 12, 31).unwrap() + ); + assert!(!decoded.calendar_dates["service"][0].added); + } +} diff --git a/src/lib.rs b/src/lib.rs index 5425d9c..9679a66 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +mod cache; pub mod cli; pub mod renfe; mod router; diff --git a/src/renfe.rs b/src/renfe.rs index 595dde1..393b7df 100644 --- a/src/renfe.rs +++ b/src/renfe.rs @@ -1,14 +1,14 @@ use chrono::{NaiveTime, TimeDelta, Timelike}; -use gtfs_structures::{Gtfs, TransferType}; use jiff::civil::{Date, Weekday}; -use pyo3::{PyResult, exceptions::PyValueError, pyclass, pymethods}; -use std::io::Read; +use pyo3::{PyResult, exceptions::PyRuntimeError, exceptions::PyValueError, pyclass, pymethods}; +use std::collections::HashMap; -use crate::router::{StopTime, Transfer, Trip, find_journeys}; +use crate::cache::{Feed, ServiceCalendar, ServiceCalendarDate, TransitData}; +use crate::router::{Journey, Trip, find_journeys}; #[pyclass] pub struct Renfe { - gtfs: Gtfs, + data: TransitData, schedules: Vec, } @@ -20,7 +20,15 @@ pub struct Schedule { departure_time: NaiveTime, arrival_time: NaiveTime, duration: TimeDelta, - transfers: usize, + transfers: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +struct ScheduleTransfer { + location: String, + arrival_time: NaiveTime, + departure_time: NaiveTime, + duration: TimeDelta, } // Struct to hold the station name and ID @@ -35,41 +43,27 @@ pub struct Station { impl Renfe { #[new] pub fn new(cercanias: bool) -> PyResult { - let mut res = reqwest::blocking::get( - match cercanias { - false => { - println!("Loading default GTFS data from Renfe web - Alta velocidad, Larga distancia y Media distancia"); - "https://ssl.renfe.com/gtransit/Fichero_AV_LD/google_transit.zip" - }, - true => { - println!("Loading Cercanías GTFS data from Renfe web - long load time"); - "https://ssl.renfe.com/ftransit/Fichero_CER_FOMENTO/fomento_transit.zip" - }, - }, - ) - .expect("Error downloading GTFS zip file"); - let mut body = Vec::new(); - res.read_to_end(&mut body)?; - let cursor = std::io::Cursor::new(body); - - let gtfs = Gtfs::from_reader(cursor).expect("Error parsing GTFS zip"); - - gtfs.print_stats(); + let feed = if cercanias { + Feed::Cercanias + } else { + Feed::Default + }; + let data = crate::cache::load(feed).map_err(PyRuntimeError::new_err)?; Ok(Renfe { - gtfs, + data, schedules: Vec::new(), }) } pub fn all_stations(&self) -> PyResult> { let stations: Vec = self - .gtfs - .stops + .data + .stations .iter() - .map(|s| Station { - name: s.1.name.clone().unwrap(), - id: s.1.id.clone(), + .map(|(id, station)| Station { + name: station.name.clone(), + id: id.clone(), }) .collect(); Ok(stations) @@ -77,20 +71,18 @@ impl Renfe { pub fn stations_match(&self, station: String) -> PyResult> { let found: Vec = self - .gtfs - .stops + .data + .stations .iter() - .filter(|s| { - s.1 + .filter(|(_, candidate)| { + candidate .name - .clone() - .unwrap() .to_lowercase() .contains(&station.to_lowercase()) }) - .map(|s| Station { - name: s.1.name.clone().unwrap(), - id: s.1.id.clone(), + .map(|(id, station)| Station { + name: station.name.clone(), + id: id.clone(), }) .collect(); Ok(found) @@ -122,7 +114,7 @@ impl Renfe { year: i32, sorted: bool, ) -> PyResult<()> { - let gtfs = &self.gtfs; + let data = &self.data; // the date for which schedules are needed let date = i16::try_from(year) .ok() @@ -138,50 +130,18 @@ impl Renfe { }, }; - let trips: Vec<_> = gtfs + let trips: Vec = data .trips - .values() - .filter(|trip| is_service_active(gtfs, &trip.service_id, date)) - .filter_map(|trip| { - let stop_times: Vec<_> = trip - .stop_times - .iter() - .filter_map(|stop_time| { - Some(StopTime { - stop_id: stop_time.stop.id.clone(), - arrival: stop_time.arrival_time?, - departure: stop_time.departure_time?, - }) - }) - .collect(); - (stop_times.len() >= 2).then(|| Trip { - id: trip.id.clone(), - name: gtfs - .get_route(&trip.route_id) - .ok() - .and_then(|route| route.short_name.clone().or_else(|| route.long_name.clone())) - .or_else(|| trip.trip_short_name.clone()) - .unwrap_or_else(|| trip.route_id.clone()), - stop_times, - }) - }) - .collect(); - let transfers: Vec<_> = gtfs - .stops - .values() - .flat_map(|stop| { - stop.transfers.iter().map(|transfer| Transfer { - from_stop_id: stop.id.clone(), - to_stop_id: transfer.to_stop_id.clone(), - duration: (transfer.transfer_type != TransferType::Impossible) - .then(|| transfer.min_transfer_time.unwrap_or(5 * 60)), - }) + .iter() + .filter(|trip| { + is_service_active(&data.calendar, &data.calendar_dates, &trip.service_id, date) }) + .map(|trip| trip.trip.clone()) .collect(); let mut schedules: Vec<_> = find_journeys( &trips, - &transfers, + &data.transfers, origin_station_id, destination_station_id, ) @@ -200,7 +160,13 @@ impl Renfe { departure_time: seconds_to_time(departure)?, arrival_time: seconds_to_time(arrival)?, duration: TimeDelta::seconds(i64::from(arrival.saturating_sub(departure))), - transfers: journey.transfers(), + transfers: schedule_transfers(&journey, |stop_id| { + data + .stations + .get(stop_id) + .map(|stop| stop.name.clone()) + .unwrap_or_else(|| stop_id.to_owned()) + }), }) }) .collect(); @@ -247,22 +213,79 @@ impl Renfe { track.duration.num_hours(), track.duration.num_minutes() % 60 ), - track.transfers + track.transfers.len() ); + for (index, transfer) in track.transfers.iter().enumerate() { + println!( + " Transfer {} at {}: arrive {}, depart {} ({})", + index + 1, + transfer.location, + format_time(transfer.arrival_time), + format_time(transfer.departure_time), + format_duration(transfer.duration) + ); + } } println!("========================================================================="); } } } +fn schedule_transfers( + journey: &Journey, + stop_name: impl Fn(&str) -> String, +) -> Vec { + journey + .legs + .windows(2) + .filter_map(|legs| { + let arriving_leg = &legs[0]; + let departing_leg = &legs[1]; + let arrival_location = stop_name(&arriving_leg.to_stop_id); + let departure_location = stop_name(&departing_leg.from_stop_id); + let location = if arriving_leg.to_stop_id == departing_leg.from_stop_id { + arrival_location + } else { + format!("{arrival_location} → {departure_location}") + }; + + Some(ScheduleTransfer { + location, + arrival_time: seconds_to_time(arriving_leg.arrival)?, + departure_time: seconds_to_time(departing_leg.departure)?, + duration: TimeDelta::seconds(i64::from( + departing_leg.departure.saturating_sub(arriving_leg.arrival), + )), + }) + }) + .collect() +} + +fn format_time(time: NaiveTime) -> String { + format!("{:02}:{:02}", time.hour(), time.minute()) +} + +fn format_duration(duration: TimeDelta) -> String { + format!( + "{:02}:{:02}", + duration.num_hours(), + duration.num_minutes() % 60 + ) +} + fn seconds_to_time(seconds: u32) -> Option { NaiveTime::from_hms_opt((seconds / 3600) % 24, seconds % 3600 / 60, seconds % 60) } // Helper function to check if a service is active on a given date -fn is_service_active(gtfs: &Gtfs, service_id: &str, date: Date) -> bool { +fn is_service_active( + calendar: &HashMap, + calendar_dates: &HashMap>, + service_id: &str, + date: Date, +) -> bool { // First check the `calendar.txt` - if let Some(calendar) = gtfs.calendar.get(service_id) { + if let Some(calendar) = calendar.get(service_id) { let weekday = match date.weekday() { Weekday::Monday => calendar.monday, Weekday::Tuesday => calendar.tuesday, @@ -275,10 +298,10 @@ fn is_service_active(gtfs: &Gtfs, service_id: &str, date: Date) -> bool { if weekday && date >= calendar.start_date && date <= calendar.end_date { // this should never happen - but a check is for free - if let Some(calendar_dates) = gtfs.calendar_dates.get(service_id) { + if let Some(calendar_dates) = calendar_dates.get(service_id) { for date_override in calendar_dates { if date_override.date == date { - return !(date_override.exception_type == gtfs_structures::Exception::Deleted); + return date_override.added; } } } @@ -287,13 +310,72 @@ fn is_service_active(gtfs: &Gtfs, service_id: &str, date: Date) -> bool { } // Then check the `calendar_dates.txt` for exceptions - if let Some(calendar_dates) = gtfs.calendar_dates.get(service_id) { + if let Some(calendar_dates) = calendar_dates.get(service_id) { for date_override in calendar_dates { if date_override.date == date { - return date_override.exception_type == gtfs_structures::Exception::Added; + return date_override.added; } } } false } + +#[cfg(test)] +mod tests { + use super::*; + use crate::router::Leg; + + fn leg(name: &str, from: &str, to: &str, departure: u32, arrival: u32) -> Leg { + Leg { + trip_id: name.into(), + name: name.into(), + from_stop_id: from.into(), + to_stop_id: to.into(), + departure, + arrival, + } + } + + #[test] + fn describes_transfer_times_durations_and_locations() { + let journey = Journey { + legs: vec![ + leg("A", "origin", "central", 8 * 3_600, 9 * 3_600), + leg("B", "central", "north", 9 * 3_600 + 15 * 60, 10 * 3_600), + leg( + "C", + "south", + "destination", + 10 * 3_600 + 30 * 60, + 11 * 3_600, + ), + ], + }; + + let transfers = schedule_transfers(&journey, |id| match id { + "central" => "Central Station".into(), + "north" => "North Station".into(), + "south" => "South Station".into(), + _ => id.into(), + }); + + assert_eq!( + transfers, + vec![ + ScheduleTransfer { + location: "Central Station".into(), + arrival_time: NaiveTime::from_hms_opt(9, 0, 0).unwrap(), + departure_time: NaiveTime::from_hms_opt(9, 15, 0).unwrap(), + duration: TimeDelta::minutes(15), + }, + ScheduleTransfer { + location: "North Station → South Station".into(), + arrival_time: NaiveTime::from_hms_opt(10, 0, 0).unwrap(), + departure_time: NaiveTime::from_hms_opt(10, 30, 0).unwrap(), + duration: TimeDelta::minutes(30), + }, + ] + ); + } +} diff --git a/src/router.rs b/src/router.rs index a51e758..d03f85d 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,26 +1,28 @@ use std::collections::{HashMap, HashSet}; +use serde::{Deserialize, Serialize}; + pub const MAX_TRANSFERS: usize = 3; const MAX_LEGS: usize = MAX_TRANSFERS + 1; const DEFAULT_TRANSFER_TIME: u32 = 5 * 60; const TRANSFER_PENALTY: u32 = 15 * 60; const MAX_JOURNEY_TIME: u32 = 24 * 60 * 60; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct StopTime { pub stop_id: String, pub arrival: u32, pub departure: u32, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct Trip { pub id: String, pub name: String, pub stop_times: Vec, } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct Transfer { pub from_stop_id: String, pub to_stop_id: String,