use anyhow::{anyhow, Result};
use chrono::NaiveDate;
use crate::parsers::common::{
AirlineFilter, FixedFlights, FlightTimes, Location, PlaceType, SortOrder, StopOptions,
StopoverDuration, TotalDuration, TravelClass, Travelers,
};
use crate::requests::api::ApiClient;
use super::{Config, Currency, TripType};
const MAX_AIRPORTS_PER_SIDE: usize = 7;
pub struct ConfigBuilder {
pub(super) departing_date: Option<NaiveDate>,
pub(super) departure: Vec<Location>,
pub(super) destination: Vec<Location>,
pub(super) stop_options: StopOptions,
pub(super) travel_class: TravelClass,
pub(super) return_date: Option<NaiveDate>,
pub(super) travelers: Travelers,
pub(super) departing_times: FlightTimes,
pub(super) return_times: FlightTimes,
pub(super) stopover_max: StopoverDuration,
pub(super) stopover_min: StopoverDuration,
pub(super) duration_max: TotalDuration,
pub(super) currency: Option<Currency>,
pub(super) language: String,
pub(super) country: String,
pub(super) sort_order: SortOrder,
pub(super) airlines_include: Vec<AirlineFilter>,
pub(super) airlines_exclude: Vec<AirlineFilter>,
pub(super) connecting_airports: Vec<String>,
pub(super) lower_emissions: bool,
pub(super) max_price: Option<i32>,
pub(super) baggage: Option<(u8, u8)>,
}
impl Default for ConfigBuilder {
fn default() -> Self {
Self {
departing_date: None,
departure: Vec::new(),
destination: Vec::new(),
stop_options: StopOptions::default(),
travel_class: TravelClass::default(),
return_date: None,
travelers: Travelers::default(),
departing_times: FlightTimes::default(),
return_times: FlightTimes::default(),
stopover_max: StopoverDuration::default(),
stopover_min: StopoverDuration::default(),
duration_max: TotalDuration::default(),
currency: None,
language: "en".to_string(),
country: "GB".to_string(),
sort_order: SortOrder::default(),
airlines_include: Vec::new(),
airlines_exclude: Vec::new(),
connecting_airports: Vec::new(),
lower_emissions: false,
max_price: None,
baggage: None,
}
}
}
impl ConfigBuilder {
pub fn departing_date(mut self, date: NaiveDate) -> Self {
self.departing_date = Some(date);
self
}
pub async fn departure(mut self, location: &str, client: &ApiClient) -> Result<Self> {
let loc = get_location(location, client).await?;
self.departure = vec![loc];
Ok(self)
}
pub fn departure_location(mut self, location: Location) -> Self {
self.departure = vec![location];
self
}
pub async fn add_departure(mut self, location: &str, client: &ApiClient) -> Result<Self> {
ensure_airport_capacity(self.departure.len(), "departure")?;
let loc = get_location(location, client).await?;
self.departure.push(loc);
Ok(self)
}
pub async fn destination(mut self, location: &str, client: &ApiClient) -> Result<Self> {
let loc = get_location(location, client).await?;
self.destination = vec![loc];
Ok(self)
}
pub fn destination_location(mut self, location: Location) -> Self {
self.destination = vec![location];
self
}
pub async fn add_destination(mut self, location: &str, client: &ApiClient) -> Result<Self> {
ensure_airport_capacity(self.destination.len(), "destination")?;
let loc = get_location(location, client).await?;
self.destination.push(loc);
Ok(self)
}
pub fn return_date(mut self, date: NaiveDate) -> Self {
self.return_date = Some(date);
self
}
pub fn travelers(mut self, travelers: Travelers) -> Self {
self.travelers = travelers;
self
}
pub fn stop_options(mut self, stop_options: StopOptions) -> Self {
self.stop_options = stop_options;
self
}
pub fn travel_class(mut self, travel_class: TravelClass) -> Self {
self.travel_class = travel_class;
self
}
pub fn departing_times(mut self, times: FlightTimes) -> Self {
self.departing_times = times;
self
}
pub fn return_times(mut self, times: FlightTimes) -> Self {
self.return_times = times;
self
}
pub fn stopover_max(mut self, duration: StopoverDuration) -> Self {
self.stopover_max = duration;
self
}
pub fn stopover_min(mut self, duration: StopoverDuration) -> Self {
self.stopover_min = duration;
self
}
pub fn duration_max(mut self, duration: TotalDuration) -> Self {
self.duration_max = duration;
self
}
pub fn currency(mut self, currency: Currency) -> Self {
self.currency = Some(currency);
self
}
pub fn language(mut self, language: impl Into<String>) -> Self {
self.language = language.into();
self
}
pub fn country(mut self, country: impl Into<String>) -> Self {
self.country = country.into();
self
}
pub fn sort_order(mut self, sort_order: SortOrder) -> Self {
self.sort_order = sort_order;
self
}
pub fn airlines_include(mut self, filters: Vec<AirlineFilter>) -> Self {
self.airlines_include = filters;
self
}
pub fn add_airline_include(mut self, filter: AirlineFilter) -> Self {
self.airlines_include.push(filter);
self
}
pub fn airlines_exclude(mut self, filters: Vec<AirlineFilter>) -> Self {
self.airlines_exclude = filters;
self
}
pub fn add_airline_exclude(mut self, filter: AirlineFilter) -> Self {
self.airlines_exclude.push(filter);
self
}
pub fn connecting_airports(mut self, airports: Vec<String>) -> Self {
self.connecting_airports = airports;
self
}
pub fn add_connecting_airport(mut self, airport: impl Into<String>) -> Self {
self.connecting_airports.push(airport.into());
self
}
pub fn lower_emissions(mut self, lower: bool) -> Self {
self.lower_emissions = lower;
self
}
pub fn max_price(mut self, price: i32) -> Self {
self.max_price = Some(price);
self
}
pub fn baggage(mut self, carry_on: u8, checked: u8) -> Self {
self.baggage = Some((carry_on, checked));
self
}
pub fn build(self) -> Result<Config> {
let departing_date = self
.departing_date
.ok_or(anyhow!("Departing date is required"))?;
let trip_type = match self.return_date {
Some(_) => TripType::Return,
None => TripType::OneWay,
};
if self.departure.is_empty() {
return Err(anyhow!("At least one departure airport is required"));
}
if self.destination.is_empty() {
return Err(anyhow!("At least one destination airport is required"));
}
Ok(Config {
departing_date,
departure: self.departure,
destination: self.destination,
stop_options: self.stop_options,
travel_class: self.travel_class,
return_date: self.return_date,
travellers: self.travelers,
departing_times: self.departing_times,
return_times: self.return_times,
stopover_max: self.stopover_max,
stopover_min: self.stopover_min,
duration_max: self.duration_max,
currency: self.currency.unwrap_or_default(),
trip_type,
fixed_flights: match trip_type {
TripType::Return => FixedFlights::new(2),
TripType::OneWay => FixedFlights::new(1),
TripType::MultiCity => {
return Err(anyhow!("Multi-city trips are not yet implemented"));
}
},
language: self.language,
country: self.country,
sort_order: self.sort_order,
airlines_include: self.airlines_include,
airlines_exclude: self.airlines_exclude,
connecting_airports: self.connecting_airports,
lower_emissions: self.lower_emissions,
max_price: self.max_price,
baggage: self.baggage,
})
}
}
pub(super) async fn get_location_pub(
location: &str,
client: &ApiClient,
) -> Result<Location, anyhow::Error> {
get_location(location, client).await
}
async fn get_location(location: &str, client: &ApiClient) -> Result<Location, anyhow::Error> {
let departure = if location.len() == 3 && location.chars().all(char::is_uppercase) {
if location.starts_with('X') {
eprintln!(
"Warning: '{}' looks like a rail station code. \
If you meant a city, use the full name (e.g. 'Rome').",
location
);
}
Location {
loc_identifier: location.to_owned(),
loc_type: PlaceType::Airport,
location_name: Some(location.to_string()),
}
} else {
client.request_city(location).await?.to_city_list()
};
Ok(departure)
}
fn ensure_airport_capacity(current: usize, side: &str) -> Result<()> {
if current >= MAX_AIRPORTS_PER_SIDE {
return Err(anyhow!(
"A maximum of {MAX_AIRPORTS_PER_SIDE} {side} airports is supported"
));
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use chrono::{Duration, Utc};
fn future_date(days: i64) -> NaiveDate {
Utc::now().date_naive() + Duration::days(days)
}
#[test]
fn airport_capacity_allows_seven_rejects_eighth() {
for occupied in 0..MAX_AIRPORTS_PER_SIDE {
assert!(
ensure_airport_capacity(occupied, "departure").is_ok(),
"{occupied} occupied airports should still allow another"
);
}
let err = ensure_airport_capacity(MAX_AIRPORTS_PER_SIDE, "destination")
.unwrap_err()
.to_string();
assert!(err.contains("maximum of 7"), "got: {err}");
assert!(err.contains("destination"), "got: {err}");
}
#[test]
fn build_fails_without_departure() {
let result = Config::builder().departing_date(future_date(30)).build();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("departure"), "error should mention departure");
}
#[test]
fn build_fails_without_destination() {
let lhr = Location {
loc_identifier: "LHR".to_owned(),
loc_type: PlaceType::Airport,
location_name: None,
};
let result = Config::builder()
.departing_date(future_date(30))
.departure_location(lhr)
.build();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("destination"),
"error should mention destination, got: {msg}"
);
}
#[test]
fn build_fails_without_both_airports() {
let result = Config::builder().departing_date(future_date(30)).build();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("departure") || msg.contains("destination"),
"error should mention a missing airport, got: {msg}"
);
}
#[test]
fn config_default_locale_is_en_gb() {
let cfg = Config::default();
assert_eq!(cfg.language, "en");
assert_eq!(cfg.country, "GB");
}
#[test]
fn builder_locale_setters_propagate() {
let lhr = Location {
loc_identifier: "LHR".to_owned(),
loc_type: PlaceType::Airport,
location_name: None,
};
let jfk = Location {
loc_identifier: "JFK".to_owned(),
loc_type: PlaceType::Airport,
location_name: None,
};
let cfg = Config::builder()
.departing_date(future_date(30))
.departure_location(lhr)
.destination_location(jfk)
.language("fr")
.country("FR")
.build()
.expect("valid config");
assert_eq!(cfg.language, "fr");
assert_eq!(cfg.country, "FR");
}
#[test]
fn builder_sort_order_setter_propagates() {
let lhr = Location {
loc_identifier: "LHR".to_owned(),
loc_type: PlaceType::Airport,
location_name: None,
};
let jfk = Location {
loc_identifier: "JFK".to_owned(),
loc_type: PlaceType::Airport,
location_name: None,
};
let cfg = Config::builder()
.departing_date(future_date(30))
.departure_location(lhr)
.destination_location(jfk)
.sort_order(SortOrder::Price)
.build()
.expect("valid config");
assert!(matches!(cfg.sort_order, SortOrder::Price));
}
#[test]
fn builder_filter_setters_propagate() {
use crate::parsers::common::{AirlineCode, AirlineFilter, Alliance};
let lhr = Location {
loc_identifier: "LHR".to_owned(),
loc_type: PlaceType::Airport,
location_name: None,
};
let jfk = Location {
loc_identifier: "JFK".to_owned(),
loc_type: PlaceType::Airport,
location_name: None,
};
let cfg = Config::builder()
.departing_date(future_date(30))
.departure_location(lhr)
.destination_location(jfk)
.add_airline_include(AirlineFilter::Airline(AirlineCode::new("LX").unwrap()))
.add_airline_include(AirlineFilter::Alliance(Alliance::OneWorld))
.add_airline_exclude(AirlineFilter::Airline(AirlineCode::new("FR").unwrap()))
.add_connecting_airport("CDG")
.lower_emissions(true)
.stopover_min(StopoverDuration::Minutes(60))
.stopover_max(StopoverDuration::Minutes(180))
.build()
.expect("valid config");
assert_eq!(cfg.airlines_include.len(), 2);
assert_eq!(cfg.airlines_exclude.len(), 1);
assert_eq!(cfg.connecting_airports, vec!["CDG"]);
assert!(cfg.lower_emissions);
assert!(matches!(cfg.stopover_min, StopoverDuration::Minutes(60)));
assert!(matches!(cfg.stopover_max, StopoverDuration::Minutes(180)));
}
#[test]
fn x_prefixed_location_builds_without_error() {
let xrj = Location {
loc_identifier: "XRJ".to_owned(),
loc_type: PlaceType::Airport,
location_name: Some("XRJ".to_owned()),
};
let xvq = Location {
loc_identifier: "XVQ".to_owned(),
loc_type: PlaceType::Airport,
location_name: Some("XVQ".to_owned()),
};
let cfg = Config::builder()
.departing_date(future_date(30))
.departure_location(xrj)
.destination_location(xvq)
.build()
.expect("X-prefixed location codes must not be rejected by Config::build");
assert_eq!(cfg.departure[0].loc_identifier, "XRJ");
assert_eq!(cfg.destination[0].loc_identifier, "XVQ");
}
}