use anyhow::Result;
use chrono::NaiveDate;
use clap::Parser;
use gflights::parsers::common::{SortOrder, TravelClass, Travelers};
use gflights::requests::api::ApiClient;
use gflights::requests::config::MultiCityConfig;
use super::OutputFormat;
#[derive(Debug, Clone)]
pub struct LegArg {
pub from: String,
pub to: String,
pub date: NaiveDate,
}
impl std::str::FromStr for LegArg {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
let parts: Vec<&str> = s.splitn(3, ',').collect();
if parts.len() != 3 {
anyhow::bail!("leg must be FROM,TO,DATE (e.g. LUX,FCO,2026-09-10), got: {s}");
}
Ok(LegArg {
from: normalize_location(parts[0].trim()),
to: normalize_location(parts[1].trim()),
date: NaiveDate::parse_from_str(parts[2].trim(), "%Y-%m-%d")
.map_err(|e| anyhow::anyhow!("invalid date '{}': {e}", parts[2]))?,
})
}
}
fn normalize_location(s: &str) -> String {
if s.len() == 3 && s.chars().all(|c| c.is_ascii_alphabetic()) {
s.to_uppercase()
} else {
s.to_string()
}
}
#[derive(Parser, Debug)]
pub struct MultiCityArgs {
#[arg(long = "leg", required = true, num_args = 1)]
pub legs: Vec<LegArg>,
#[arg(long, default_value = "1")]
pub adults: u32,
#[arg(long, default_value = "0")]
pub children: u32,
#[arg(long = "infants-seat", default_value = "0")]
pub infants_seat: u32,
#[arg(long = "infants-lap", default_value = "0")]
pub infants_lap: u32,
#[arg(long, default_value = "economy")]
pub class: TravelClass,
#[arg(long, default_value = "best")]
pub sort: SortOrder,
#[arg(long, default_value = "table")]
pub format: OutputFormat,
}
pub async fn cmd_multi_city(args: MultiCityArgs, client: &ApiClient) -> Result<()> {
if args.legs.len() < 2 {
anyhow::bail!("multi-city requires at least 2 legs");
}
let travelers = Travelers::new(vec![
args.adults as i32,
args.children as i32,
args.infants_lap as i32,
args.infants_seat as i32,
])?;
let mut builder = MultiCityConfig::builder()
.travellers(travelers)
.travel_class(args.class)
.sort_order(args.sort);
for leg in &args.legs {
builder = builder
.add_leg(&leg.from, &leg.to, leg.date, client)
.await?;
}
let config = builder.build()?;
let results = client.request_multi_city_flights(&config).await?;
let flights = results.get_all_flights();
if flights.is_empty() {
eprintln!("No flights found.");
return Ok(());
}
let route_banner: String = config
.legs
.iter()
.flat_map(|leg| {
leg.from
.iter()
.map(|l| l.loc_identifier.as_str())
.chain(std::iter::once(
leg.to
.first()
.map(|l| l.loc_identifier.as_str())
.unwrap_or("?"),
))
})
.collect::<Vec<_>>()
.windows(2)
.filter_map(|w| if w[0] != w[1] { Some(w[0]) } else { None })
.chain(
config
.legs
.last()
.and_then(|l| l.to.first())
.map(|l| l.loc_identifier.as_str()),
)
.collect::<Vec<_>>()
.join(" → ");
match args.format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&flights)?);
}
OutputFormat::Table => {
println!("Route: {route_banner}");
println!();
println!(
"{:<8} {:>6} {:>5} {:>5} ROUTE",
"AIRLINE", "PRICE", "STOPS", "MINS"
);
println!("{}", "-".repeat(60));
for f in &flights {
let price = f
.itinerary_cost
.trip_cost
.as_ref()
.map(|c| c.price.to_string())
.unwrap_or_else(|| "—".into());
let route = {
let details = &f.itinerary.flight_details;
let mut parts: Vec<&str> = details
.iter()
.map(|d| d.departure_airport_code.as_str())
.collect();
if let Some(last) = details.last() {
parts.push(last.destination_airport_code.as_str());
}
if parts.is_empty() {
"?".to_string()
} else {
parts.join("→")
}
};
println!(
"{:<8} {:>6} {:>5} {:>5} {route}",
f.itinerary.flight_by,
price,
f.itinerary.stop_count(),
f.itinerary.total_time_minutes,
);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn leg_arg_parses_valid_input() {
let leg: LegArg = "LUX,FCO,2026-09-10".parse().unwrap();
assert_eq!(leg.from, "LUX");
assert_eq!(leg.to, "FCO");
assert_eq!(leg.date.to_string(), "2026-09-10");
}
#[test]
fn leg_arg_uppercases_iata_codes() {
let leg: LegArg = "lux,fco,2026-09-10".parse().unwrap();
assert_eq!(leg.from, "LUX");
assert_eq!(leg.to, "FCO");
}
#[test]
fn leg_arg_preserves_city_names() {
let leg: LegArg = "MXP,London,2026-09-12".parse().unwrap();
assert_eq!(leg.from, "MXP");
assert_eq!(leg.to, "London");
}
#[test]
fn leg_arg_preserves_mixed_case_city() {
let leg: LegArg = "lux,New York,2026-12-01".parse().unwrap();
assert_eq!(leg.from, "LUX");
assert_eq!(leg.to, "New York");
}
#[test]
fn leg_arg_rejects_missing_parts() {
assert!("LUX,FCO".parse::<LegArg>().is_err());
}
#[test]
fn leg_arg_rejects_invalid_date() {
assert!("LUX,FCO,not-a-date".parse::<LegArg>().is_err());
}
}