use anyhow::Result;
use chrono::NaiveDate;
use clap::{Parser, Subcommand, ValueEnum};
use gflights::parsers::common::{StopOptions, TravelClass, Travelers};
use gflights::requests::api::ApiClient;
use gflights::requests::config::{Config, Currency};
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
pub mod date_grid;
pub mod graph;
pub mod offer;
pub mod search;
use date_grid::{cmd_date_grid, DateGridArgs};
use graph::{cmd_graph, GraphArgs};
use offer::{cmd_offer, OfferArgs};
use search::{cmd_search, SearchArgs};
#[derive(Debug, Clone, Copy, ValueEnum, Default)]
pub enum OutputFormat {
#[default]
Table,
Json,
}
#[derive(Parser, Debug)]
#[command(name = "gflights", version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Parser, Debug)]
#[command(name = "gflights", disable_help_flag = true)]
pub struct ReplCommand {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Search(SearchArgs),
Graph(GraphArgs),
#[command(name = "dgrid")]
DateGrid(DateGridArgs),
Offer(OfferArgs),
#[command(alias = "exit")]
Quit,
}
#[derive(Parser, Debug)]
pub struct CommonArgs {
#[arg(long)]
pub from: String,
#[arg(long)]
pub to: String,
#[arg(long)]
pub date: NaiveDate,
#[arg(long)]
pub r#return: Option<NaiveDate>,
#[arg(long, default_value = "1")]
pub adults: u32,
#[arg(long, default_value = "economy")]
pub class: TravelClass,
#[arg(long, default_value = "all")]
pub stops: StopOptions,
#[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 build_config(common: &CommonArgs, client: &ApiClient) -> Result<Config> {
let travelers = Travelers::new(vec![common.adults as i32, 0, 0, 0])?;
let mut builder = Config::builder()
.departure(&common.from, client)
.await?
.destination(&common.to, client)
.await?
.departing_date(common.date)
.travelers(travelers)
.travel_class(common.class)
.stop_options(common.stops)
.currency(common.currency.clone())
.language(common.lang.clone())
.country(common.country.clone());
if let Some(ret) = common.r#return {
builder = builder.return_date(ret);
}
builder.build()
}
pub async fn run_command(cmd: Commands, client: &ApiClient) -> Result<()> {
match cmd {
Commands::Search(args) => cmd_search(args, client).await,
Commands::Graph(args) => cmd_graph(args, client).await,
Commands::DateGrid(args) => cmd_date_grid(args, client).await,
Commands::Offer(args) => cmd_offer(args, client).await,
Commands::Quit => Ok(()),
}
}
pub async fn run_repl(client: &ApiClient) -> Result<()> {
let mut rl = DefaultEditor::new()?;
println!("gflights interactive mode (type 'help' for usage, 'quit' to exit)");
loop {
match rl.readline("gflights> ") {
Ok(line) => {
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
let _ = rl.add_history_entry(&line);
if line == "help" || line == "--help" || line == "-h" {
println!("Commands:");
println!(" search --from <CODE> --to <CODE> --date <YYYY-MM-DD> [OPTIONS]");
println!(" graph --from <CODE> --to <CODE> --date <YYYY-MM-DD> [--months N]");
println!(" dgrid --from <CODE> --to <CODE> --dep-start <DATE> --dep-end <DATE> --ret-start <DATE> --ret-end <DATE>");
println!(" offer --from <CODE> --to <CODE> --date <YYYY-MM-DD> [OPTIONS]");
println!(" quit / exit");
continue;
}
let parts: Vec<String> = std::iter::once("gflights".to_string())
.chain(line.split_whitespace().map(String::from))
.collect();
match ReplCommand::try_parse_from(&parts) {
Ok(rc) => {
if matches!(rc.command, Commands::Quit) {
break;
}
if let Err(e) = run_command(rc.command, client).await {
eprintln!("Error: {e:#}");
}
}
Err(e)
if matches!(
e.kind(),
clap::error::ErrorKind::DisplayHelp
| clap::error::ErrorKind::DisplayVersion
) =>
{
print!("{e}");
}
Err(e) => eprintln!("{e}"),
}
}
Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => break,
Err(e) => {
eprintln!("Readline error: {e}");
break;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::error::ErrorKind;
fn parse(args: &[&str]) -> Result<ReplCommand, clap::Error> {
let parts: Vec<String> = std::iter::once("gflights")
.chain(args.iter().copied())
.map(String::from)
.collect();
ReplCommand::try_parse_from(&parts)
}
#[test]
fn repl_parse_quit_command() {
let rc = parse(&["quit"]).expect("quit should parse");
assert!(matches!(rc.command, Commands::Quit));
}
#[test]
fn repl_parse_exit_alias() {
let rc = parse(&["exit"]).expect("exit should parse");
assert!(matches!(rc.command, Commands::Quit));
}
#[test]
fn repl_parse_search_minimal() {
let rc = parse(&[
"search",
"--from",
"LHR",
"--to",
"JFK",
"--date",
"2026-08-01",
])
.expect("minimal search should parse");
match rc.command {
Commands::Search(args) => {
assert_eq!(args.common.from, "LHR");
assert_eq!(args.common.to, "JFK");
}
other => panic!("expected Search, got {other:?}"),
}
}
#[test]
fn repl_parse_search_with_return() {
let rc = parse(&[
"search",
"--from",
"MXP",
"--to",
"SVO",
"--date",
"2026-08-01",
"--return",
"2026-08-15",
])
.expect("search with return should parse");
match rc.command {
Commands::Search(args) => {
assert!(args.common.r#return.is_some());
}
other => panic!("expected Search, got {other:?}"),
}
}
#[test]
fn repl_parse_dgrid_command() {
let rc = parse(&[
"dgrid",
"--from",
"LHR",
"--to",
"JFK",
"--dep-start",
"2026-08-01",
"--dep-end",
"2026-08-07",
"--ret-start",
"2026-08-15",
"--ret-end",
"2026-08-22",
])
.expect("dgrid should parse");
assert!(matches!(rc.command, Commands::DateGrid(_)));
}
#[test]
fn repl_parse_graph_with_months() {
let rc = parse(&[
"graph",
"--from",
"SVO",
"--to",
"CDG",
"--date",
"2026-09-01",
"--months",
"6",
])
.expect("graph with months should parse");
match rc.command {
Commands::Graph(args) => assert_eq!(args.months, 6),
other => panic!("expected Graph, got {other:?}"),
}
}
#[test]
fn repl_parse_offer_command() {
let rc = parse(&[
"offer",
"--from",
"FRA",
"--to",
"NRT",
"--date",
"2026-09-01",
])
.expect("offer should parse");
assert!(matches!(rc.command, Commands::Offer(_)));
}
#[test]
fn repl_parse_invalid_command_returns_error() {
let result = parse(&["bogus"]);
assert!(result.is_err(), "unknown subcommand should error");
}
#[test]
fn repl_parse_missing_required_returns_error() {
let result = parse(&["search"]);
assert!(result.is_err(), "search without required args should error");
}
#[test]
fn repl_parse_help_flag_always_errors() {
let result = parse(&["search", "--help"]);
assert!(result.is_err(), "--help in REPL context must always error");
let e = result.unwrap_err();
assert!(
matches!(
e.kind(),
ErrorKind::DisplayHelp | ErrorKind::UnknownArgument
),
"expected DisplayHelp or UnknownArgument, got: {:?}",
e.kind()
);
}
#[test]
fn repl_parse_search_filter_flags() {
let rc = parse(&[
"search",
"--from",
"LHR",
"--to",
"JFK",
"--date",
"2026-08-01",
"--min-layover",
"60",
"--max-layover",
"180",
"--lower-emissions",
"--airline",
"LX",
"--airline",
"ONEWORLD",
"--exclude-airline",
"FR",
"--via",
"CDG",
])
.expect("search with filter flags should parse");
match rc.command {
Commands::Search(args) => {
assert_eq!(args.min_layover, Some(60));
assert_eq!(args.max_layover, Some(180));
assert!(args.lower_emissions);
assert_eq!(args.airlines.len(), 2);
assert_eq!(args.exclude_airlines.len(), 1);
assert_eq!(args.connecting_airports, vec!["CDG"]);
}
other => panic!("expected Search, got {other:?}"),
}
}
#[test]
fn repl_parse_invalid_date_returns_error() {
let result = parse(&[
"search",
"--from",
"LHR",
"--to",
"JFK",
"--date",
"not-a-date",
]);
assert!(result.is_err(), "invalid date should error");
}
#[test]
fn repl_parse_search_accepts_sort_order_values() {
for sort in &["best", "price", "duration"] {
let rc = parse(&[
"search",
"--from",
"LHR",
"--to",
"JFK",
"--date",
"2026-08-01",
"--sort",
sort,
])
.unwrap_or_else(|e| panic!("sort={sort} should parse: {e}"));
assert!(matches!(rc.command, Commands::Search(_)));
}
}
}