use anyhow::Result;
use clap::Parser;
use std::io::{self, Write};
use gflights::parsers::flight_response::ItineraryContainer;
use gflights::requests::api::ApiClient;
use super::{build_config, CommonArgs};
#[derive(Parser, Debug)]
pub struct SelectArgs {
#[command(flatten)]
pub common: CommonArgs,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Selection {
Index(usize),
Cancel,
Invalid,
}
pub fn parse_selection(input: &str, max: usize) -> Selection {
let t = input.trim();
if t.is_empty() || t.eq_ignore_ascii_case("q") || t.eq_ignore_ascii_case("quit") {
return Selection::Cancel;
}
match t.parse::<usize>() {
Ok(n) if n >= 1 && n <= max => Selection::Index(n - 1),
_ => Selection::Invalid,
}
}
fn read_selection(prompt: &str, max: usize) -> Result<Option<usize>> {
loop {
print!("{prompt}");
io::stdout().flush()?;
let mut line = String::new();
if io::stdin().read_line(&mut line)? == 0 {
return Ok(None); }
match parse_selection(&line, max) {
Selection::Index(i) => return Ok(Some(i)),
Selection::Cancel => return Ok(None),
Selection::Invalid => {
println!("Enter a number between 1 and {max}, or 'q' to cancel.");
}
}
}
}
fn print_flight_options(flights: &[ItineraryContainer]) {
println!(
"{:>3} {:<8} {:>6} {:>5} {:>5} ROUTE",
"#", "AIRLINE", "PRICE", "STOPS", "MINS"
);
println!("{}", "-".repeat(50));
for (i, f) in flights.iter().enumerate() {
let price = f
.itinerary_cost
.trip_cost
.as_ref()
.map(|c| c.price.to_string())
.unwrap_or_else(|| "—".into());
let from = f
.itinerary
.flight_details
.first()
.map(|d| d.departure_airport_code.as_str())
.unwrap_or("?");
let to = f
.itinerary
.flight_details
.last()
.map(|d| d.destination_airport_code.as_str())
.unwrap_or("?");
println!(
"{:>3} {:<8} {:>6} {:>5} {:>5} {}→{}",
i + 1,
f.itinerary.flight_by,
price,
f.itinerary.stop_count(),
f.itinerary.total_time_minutes,
from,
to,
);
}
}
async fn pick_leg(
label: &str,
config: &gflights::requests::config::Config,
client: &ApiClient,
) -> Result<Option<ItineraryContainer>> {
let result = client.request_flights(config).await?;
let flights = result.get_all_flights();
if flights.is_empty() {
eprintln!("No {label} flights found.");
return Ok(None);
}
println!("\n{} flights:", capitalize(label));
print_flight_options(&flights);
let prompt = format!("Select {label} flight [1-{}, q to cancel]: ", flights.len());
match read_selection(&prompt, flights.len())? {
Some(idx) => Ok(flights.into_iter().nth(idx)),
None => Ok(None),
}
}
fn capitalize(s: &str) -> String {
let mut c = s.chars();
match c.next() {
Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
}
pub async fn cmd_select(args: SelectArgs, client: &ApiClient) -> Result<()> {
let config = build_config(&args.common, client).await?;
let outbound = match pick_leg("outbound", &config, client).await? {
Some(f) => f,
None => {
println!("Cancelled.");
return Ok(());
}
};
config.fixed_flights.add_element(outbound)?;
if config.return_date.is_some() {
let ret = match pick_leg("return", &config, client).await? {
Some(f) => f,
None => {
println!("Cancelled.");
return Ok(());
}
};
config.fixed_flights.add_element(ret)?;
}
let offers = client.request_offer(&config).await?;
let mut groups: Vec<_> = offers
.response
.iter()
.flat_map(|r| &r.offers)
.filter(|o| o.price.is_some())
.collect();
groups.sort_by_key(|o| o.price.unwrap_or(i32::MAX));
if groups.is_empty() {
eprintln!("No booking offers found for this itinerary.");
return Ok(());
}
println!("\nBooking offers:");
println!("{:>3} {:<30} {:>8}", "#", "AIRLINE(S)", "PRICE");
println!("{}", "-".repeat(45));
for (i, o) in groups.iter().enumerate() {
println!(
"{:>3} {:<30} {:>8}",
i + 1,
o.airline_names.join(", "),
o.price.unwrap_or(0),
);
}
let offer = match read_selection(
&format!("Select offer to book [1-{}, q to cancel]: ", groups.len()),
groups.len(),
)? {
Some(idx) => groups[idx],
None => {
println!("Cancelled.");
return Ok(());
}
};
let token = offer.click_token.as_deref().or_else(|| {
offer
.sub_options
.iter()
.find_map(|s| s.click_token.as_deref())
});
match token {
Some(t) => {
let url = client.resolve_booking_url(t).await?;
println!("\nBooking URL:\n{url}");
}
None => println!("No booking link available for this offer."),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_selection_valid_indices_are_zero_based() {
assert_eq!(parse_selection("1", 5), Selection::Index(0));
assert_eq!(parse_selection("5", 5), Selection::Index(4));
assert_eq!(parse_selection(" 3 ", 5), Selection::Index(2));
}
#[test]
fn parse_selection_cancel_inputs() {
assert_eq!(parse_selection("", 5), Selection::Cancel);
assert_eq!(parse_selection(" ", 5), Selection::Cancel);
assert_eq!(parse_selection("q", 5), Selection::Cancel);
assert_eq!(parse_selection("Q", 5), Selection::Cancel);
assert_eq!(parse_selection("quit", 5), Selection::Cancel);
}
#[test]
fn parse_selection_out_of_range_is_invalid() {
assert_eq!(parse_selection("0", 5), Selection::Invalid);
assert_eq!(parse_selection("6", 5), Selection::Invalid);
assert_eq!(parse_selection("99", 5), Selection::Invalid);
}
#[test]
fn parse_selection_non_numeric_is_invalid() {
assert_eq!(parse_selection("abc", 5), Selection::Invalid);
assert_eq!(parse_selection("1x", 5), Selection::Invalid);
assert_eq!(parse_selection("-1", 5), Selection::Invalid);
}
#[test]
fn capitalize_works() {
assert_eq!(capitalize("outbound"), "Outbound");
assert_eq!(capitalize("return"), "Return");
assert_eq!(capitalize(""), "");
}
}