use anyhow::{bail, Result};
use clap::{Parser, ValueEnum};
use gflights::parsers::common::{Location, PlaceType, TravelClass};
use gflights::requests::api::ApiClient;
use gflights::requests::config::explore::{
known_interest_names, known_region_names, mid_from_name, region_from_name, ExploreConfig,
ExploreDate, ExploreDuration,
};
use gflights::requests::config::Currency;
use super::OutputFormat;
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum DurationArg {
Weekend,
Week,
#[value(name = "2weeks")]
TwoWeeks,
}
impl From<DurationArg> for ExploreDuration {
fn from(d: DurationArg) -> Self {
match d {
DurationArg::Weekend => ExploreDuration::Weekend,
DurationArg::Week => ExploreDuration::OneWeek,
DurationArg::TwoWeeks => ExploreDuration::TwoWeeks,
}
}
}
fn resolve_destination(raw: &str) -> Result<gflights::parsers::common::Location> {
use gflights::parsers::common::{Location, PlaceType};
if raw.starts_with("/m/") || raw.starts_with("/g/") {
return Ok(Location {
loc_identifier: raw.to_string(),
loc_type: PlaceType::Region,
location_name: None,
});
}
if let Some(mid) = region_from_name(raw) {
return Ok(Location {
loc_identifier: mid.to_string(),
loc_type: PlaceType::Region,
location_name: Some(raw.to_string()),
});
}
if raw.len() <= 4 && raw.chars().all(|c| c.is_ascii_alphanumeric()) {
return Ok(Location {
loc_identifier: raw.to_uppercase(),
loc_type: PlaceType::Airport,
location_name: None,
});
}
let regions = known_region_names().join(", ");
bail!(
"unknown destination {:?}\n\
Use an IATA airport code (e.g. --to BCN), a region name ({regions}),\n\
or a raw Knowledge-Graph MID (e.g. --to /m/01531v)",
raw
)
}
fn resolve_interest(raw: &str) -> Result<String> {
if raw.starts_with("/m/") || raw.starts_with("/g/") {
return Ok(raw.to_string());
}
if let Some(mid) = mid_from_name(raw) {
return Ok(mid.to_string());
}
let names = known_interest_names().join(", ");
bail!(
"unknown interest {:?}\n\
Known names: {names}\n\
Or pass a raw Knowledge-Graph MID, e.g. --interest /m/01rwk",
raw
)
}
#[derive(Parser, Debug)]
pub struct ExploreArgs {
#[arg(long)]
pub from: String,
#[arg(long)]
pub to: Option<String>,
#[arg(long)]
pub month: Option<u8>,
#[arg(long, default_value = "week")]
pub duration: DurationArg,
#[arg(long)]
pub budget: Option<i32>,
#[arg(long)]
pub interest: Option<String>,
#[arg(long = "max-flight-hours")]
pub max_flight_hours: Option<u32>,
#[arg(long = "carry-on")]
pub carry_on: Option<u8>,
#[arg(long = "checked")]
pub checked: Option<u8>,
#[arg(long, default_value = "1")]
pub adults: u32,
#[arg(long, default_value = "economy")]
pub class: TravelClass,
#[arg(long, default_value = "euro")]
pub currency: Currency,
#[arg(long, default_value = "en")]
pub lang: String,
#[arg(long, default_value = "GB")]
pub country: String,
#[arg(long, default_value = "table")]
pub format: OutputFormat,
}
pub async fn cmd_explore(args: ExploreArgs, client: &ApiClient) -> Result<()> {
let origin = Location {
loc_identifier: args.from.to_uppercase(),
loc_type: PlaceType::Airport,
location_name: None,
};
let travelers = gflights::parsers::common::Travelers::new(vec![args.adults as i32, 0, 0, 0])?;
let trip_date = args.month.map(|m| ExploreDate { month: m });
let max_flight_duration_minutes = args.max_flight_hours.map(|h| h * 60);
let baggage = match (args.carry_on, args.checked) {
(None, None) => None,
(carry_on, checked) => Some((carry_on.unwrap_or(0), checked.unwrap_or(0))),
};
let interest = args.interest.as_deref().map(resolve_interest).transpose()?;
let destination = args.to.as_deref().map(resolve_destination).transpose()?;
let config = ExploreConfig {
origin: vec![origin],
destination,
trip_date,
trip_duration: args.duration.into(),
max_price: args.budget,
interest,
airline_alliance: None,
max_flight_duration_minutes,
baggage,
map_bounds: None,
travellers: travelers,
travel_class: args.class,
currency: args.currency,
language: args.lang,
country: args.country,
};
let mut results = client.request_explore(&config).await?;
results.retain(|r| r.price.is_some());
if results.is_empty() {
eprintln!("No destinations found.");
return Ok(());
}
match args.format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&results)?);
}
OutputFormat::Table => {
results.sort_by_key(|r| r.price.unwrap_or(i32::MAX));
println!(
"{:<20} {:<12} {:<5} {:>6} {:<8} {:>5} DATES",
"DESTINATION", "COUNTRY", "ARPT", "PRICE", "AIRLINE", "STOPS"
);
println!("{}", "-".repeat(80));
for r in &results {
let price_str = r.price.map(|p| p.to_string()).unwrap_or_else(|| "—".into());
let airline_str = r.airline.as_deref().unwrap_or("—");
let stops_str = r.stops.map(|s| s.to_string()).unwrap_or_else(|| "—".into());
let dates_str = match (r.date_from, r.date_to) {
(Some(f), Some(t)) => format!("{f} → {t}"),
(Some(f), None) => f.to_string(),
_ => "—".to_string(),
};
println!(
"{:<20} {:<12} {:<5} {:>6} {:<8} {:>5} {}",
truncate(&r.name, 20),
truncate(&r.country, 12),
r.flight_airport.as_deref().unwrap_or(&r.nearest_airport),
price_str,
airline_str,
stops_str,
dates_str,
);
}
}
}
Ok(())
}
fn truncate(s: &str, max_chars: usize) -> &str {
match s.char_indices().nth(max_chars) {
None => s,
Some((i, _)) => &s[..i],
}
}