1use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
2use base64::Engine;
3
4use crate::error::FlightError;
5use crate::proto;
6
7#[derive(Debug, Clone)]
8pub struct FlightLeg {
9 pub date: String,
10 pub from_airport: String,
11 pub to_airport: String,
12 pub max_stops: Option<u32>,
13 pub airlines: Option<Vec<String>>,
14}
15
16#[derive(Debug, Clone)]
17pub struct Passengers {
18 pub adults: u32,
19 pub children: u32,
20 pub infants_in_seat: u32,
21 pub infants_on_lap: u32,
22}
23
24impl Default for Passengers {
25 fn default() -> Self {
26 Self {
27 adults: 1,
28 children: 0,
29 infants_in_seat: 0,
30 infants_on_lap: 0,
31 }
32 }
33}
34
35#[derive(Debug, Clone)]
36pub enum Seat {
37 Economy,
38 PremiumEconomy,
39 Business,
40 First,
41}
42
43impl Seat {
44 pub fn from_str_loose(s: &str) -> Result<Self, FlightError> {
45 match s {
46 "economy" => Ok(Self::Economy),
47 "premium-economy" => Ok(Self::PremiumEconomy),
48 "business" => Ok(Self::Business),
49 "first" => Ok(Self::First),
50 _ => Err(FlightError::Validation(format!("invalid seat class: {s}"))),
51 }
52 }
53}
54
55#[derive(Debug, Clone)]
56pub enum TripType {
57 RoundTrip,
58 OneWay,
59 MultiCity,
60}
61
62impl TripType {
63 pub fn from_str_loose(s: &str) -> Result<Self, FlightError> {
64 match s {
65 "round-trip" => Ok(Self::RoundTrip),
66 "one-way" => Ok(Self::OneWay),
67 "multi-city" => Ok(Self::MultiCity),
68 _ => Err(FlightError::Validation(format!("invalid trip type: {s}"))),
69 }
70 }
71}
72
73#[derive(Debug, Clone)]
74pub struct QueryParams {
75 pub legs: Vec<FlightLeg>,
76 pub passengers: Passengers,
77 pub seat: Seat,
78 pub trip: TripType,
79 pub language: String,
80 pub currency: String,
81}
82
83fn validate_airport(code: &str) -> Result<(), FlightError> {
84 if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) {
85 return Err(FlightError::InvalidAirport(code.to_string()));
86 }
87 Ok(())
88}
89
90fn days_in_month(year: u32, month: u32) -> u32 {
91 match month {
92 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
93 4 | 6 | 9 | 11 => 30,
94 2 => {
95 if (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400) {
96 29
97 } else {
98 28
99 }
100 }
101 _ => 0,
102 }
103}
104
105fn validate_date(date: &str) -> Result<(), FlightError> {
106 let parts: Vec<&str> = date.split('-').collect();
107 if parts.len() != 3 {
108 return Err(FlightError::InvalidDate(date.to_string()));
109 }
110 let year: u32 = parts[0]
111 .parse()
112 .map_err(|_| FlightError::InvalidDate(date.to_string()))?;
113 let month: u32 = parts[1]
114 .parse()
115 .map_err(|_| FlightError::InvalidDate(date.to_string()))?;
116 let day: u32 = parts[2]
117 .parse()
118 .map_err(|_| FlightError::InvalidDate(date.to_string()))?;
119
120 if year < 2000 || !(1..=12).contains(&month) {
121 return Err(FlightError::InvalidDate(date.to_string()));
122 }
123
124 if day < 1 || day > days_in_month(year, month) {
125 return Err(FlightError::InvalidDate(date.to_string()));
126 }
127
128 Ok(())
129}
130
131impl QueryParams {
132 pub fn validate(&self) -> Result<(), FlightError> {
133 if self.legs.is_empty() {
134 return Err(FlightError::Validation(
135 "at least one flight leg required".into(),
136 ));
137 }
138
139 for leg in &self.legs {
140 validate_airport(&leg.from_airport)?;
141 validate_airport(&leg.to_airport)?;
142 validate_date(&leg.date)?;
143 }
144
145 let total = self.passengers.adults
146 + self.passengers.children
147 + self.passengers.infants_in_seat
148 + self.passengers.infants_on_lap;
149
150 if total > 9 {
151 return Err(FlightError::Validation(format!(
152 "total passengers ({total}) exceeds maximum of 9"
153 )));
154 }
155
156 if total == 0 {
157 return Err(FlightError::Validation(
158 "at least one passenger required".into(),
159 ));
160 }
161
162 if self.passengers.infants_on_lap > self.passengers.adults {
163 return Err(FlightError::Validation(
164 "infants on lap cannot exceed number of adults".into(),
165 ));
166 }
167
168 Ok(())
169 }
170
171 pub fn to_url_params(&self) -> Vec<(String, String)> {
172 let encoded = proto::encode(&self.legs, &self.passengers, &self.seat, &self.trip);
173 let b64 = STANDARD.encode(&encoded);
174
175 let mut params = vec![("tfs".to_string(), b64)];
176
177 if !self.language.is_empty() {
178 params.push(("hl".to_string(), self.language.clone()));
179 }
180 if !self.currency.is_empty() {
181 params.push(("curr".to_string(), self.currency.clone()));
182 }
183
184 params
185 }
186}
187
188pub enum SearchQuery {
189 Structured(QueryParams),
190 NaturalLanguage(String),
191}
192
193impl SearchQuery {
194 pub fn to_url_params(&self) -> Vec<(String, String)> {
195 match self {
196 Self::Structured(q) => q.to_url_params(),
197 Self::NaturalLanguage(text) => vec![("q".to_string(), text.clone())],
198 }
199 }
200}
201
202pub fn to_google_flights_url(params: &QueryParams) -> String {
203 let encoded = proto::encode(¶ms.legs, ¶ms.passengers, ¶ms.seat, ¶ms.trip);
204 let tfs = URL_SAFE_NO_PAD.encode(&encoded);
205
206 let mut url = format!(
207 "https://www.google.com/travel/flights/search?tfs={tfs}&tfu=EgYIABAAGAA"
208 );
209
210 if !params.currency.is_empty() {
211 url.push_str(&format!("&curr={}", params.currency));
212 }
213 if !params.language.is_empty() {
214 url.push_str(&format!("&hl={}", params.language));
215 }
216
217 url
218}