use std::io;
use clap::{Args, CommandFactory, Parser, Subcommand};
use phrona::{Category, Profile, SearchOptions, TimeRange};
#[derive(Parser)]
#[command(
name = "phrona",
version,
about = "Phrona command-line interface",
long_about = "Search 26 engines across 5 categories, get suggestions, extract pages, \
produce AI-grounded answers, and probe engine availability. \
All commands accept --json for machine-readable output."
)]
pub struct Cli {
#[arg(long, global = true)]
pub json: bool,
#[arg(long, global = true, value_parser = profile_parser)]
pub profile: Option<Profile>,
#[arg(long, global = true)]
pub proxy: Vec<String>,
#[arg(long, global = true)]
pub timeout: Option<u64>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
Search(SearchArgs),
Suggest(SuggestArgs),
Extract(ExtractArgs),
Ground(GroundArgs),
Engines(EnginesArgs),
Test(TestArgs),
Serve(ServeArgs),
Mcp,
Completions(CompletionsArgs),
}
#[derive(Args)]
pub struct SearchArgs {
pub query: String,
#[arg(long, value_parser = category_parser, default_value = "web")]
pub category: Category,
#[arg(long)]
pub engines: Option<String>,
#[arg(long, default_value_t = 20)]
pub max_results: usize,
#[arg(long, value_parser = safesearch_parser, default_value = "moderate")]
pub safesearch: phrona::SafeSearch,
#[arg(long)]
pub region: Option<String>,
#[arg(long)]
pub language: Option<String>,
#[arg(long, value_parser = time_range_parser)]
pub time_range: Option<TimeRange>,
#[arg(long)]
pub filters: Option<String>,
#[arg(long, default_value_t = 1)]
pub page: u32,
}
#[derive(Args)]
pub struct SuggestArgs {
pub query: String,
#[arg(long)]
pub source: Option<String>,
#[arg(long, default_value = "us-en")]
pub region: String,
}
#[derive(Args)]
pub struct ExtractArgs {
#[arg(required = true)]
pub urls: Vec<String>,
#[arg(long, default_value_t = 5000)]
pub max_chars: usize,
#[arg(long)]
pub query: Option<String>,
}
#[derive(Args)]
pub struct GroundArgs {
pub query: String,
#[arg(long, default_value_t = 8)]
pub max_results: usize,
#[arg(long)]
pub engines: Option<String>,
#[arg(long, value_parser = category_parser, default_value = "web")]
pub category: Category,
#[arg(long)]
pub region: Option<String>,
#[arg(long)]
pub language: Option<String>,
#[arg(long, value_parser = time_range_parser)]
pub time_range: Option<TimeRange>,
#[arg(long, value_parser = safesearch_parser, default_value = "moderate")]
pub safesearch: phrona::SafeSearch,
#[arg(long)]
pub filters: Option<String>,
#[arg(long, default_value_t = 1)]
pub page: u32,
}
#[derive(Args)]
pub struct EnginesArgs {
#[arg(long, value_parser = category_parser)]
pub category: Option<Category>,
}
#[derive(Args)]
pub struct TestArgs {
#[arg(long, default_value = "rust programming")]
pub query: String,
#[arg(long, value_parser = category_parser)]
pub category: Option<Category>,
#[arg(long, default_value_t = 5)]
pub max_results: usize,
}
#[derive(Args)]
pub struct ServeArgs {
#[arg(long)]
pub addr: Option<String>,
#[arg(long)]
pub mcp_addr: Option<String>,
#[arg(long)]
pub api_key: Option<String>,
#[arg(long)]
pub no_mcp: bool,
#[arg(long)]
pub no_rest: bool,
}
#[derive(Args)]
pub struct CompletionsArgs {
#[arg(value_parser = ["bash", "zsh", "fish", "powershell", "elvish"])]
pub shell: String,
}
pub fn category_parser(s: &str) -> Result<Category, String> {
s.parse::<Category>().map_err(|_| {
"invalid category, expected one of: web, images, news, videos, books".to_string()
})
}
fn safesearch_parser(s: &str) -> Result<phrona::SafeSearch, String> {
s.parse::<phrona::SafeSearch>()
.map_err(|_| "invalid safesearch, expected one of: off, moderate, strict".to_string())
}
fn time_range_parser(s: &str) -> Result<TimeRange, String> {
s.parse::<TimeRange>()
.map_err(|_| "invalid time_range, expected one of: day, week, month, year".to_string())
}
fn profile_parser(s: &str) -> Result<Profile, String> {
Profile::from_name(s).ok_or_else(|| {
format!(
"unknown profile '{s}', expected chrome, firefox, safari, edge, opera, okhttp, random"
)
})
}
impl Cli {
pub fn base_options(
&self,
timeout: std::time::Duration,
query: impl Into<String>,
) -> SearchOptions {
let mut opts = SearchOptions::new(query);
opts.timeout = timeout;
opts
}
}
pub fn print_completions(shell: &str) -> anyhow::Result<()> {
let mut cmd = Cli::command();
let shell = match shell {
"bash" => clap_complete::Shell::Bash,
"zsh" => clap_complete::Shell::Zsh,
"fish" => clap_complete::Shell::Fish,
"powershell" => clap_complete::Shell::PowerShell,
"elvish" => clap_complete::Shell::Elvish,
_ => unreachable!("validated by clap"),
};
clap_complete::generate(shell, &mut cmd, "phrona", &mut io::stdout());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_parser_accepts_aliases() {
for s in [
"chrome",
"chrome148",
"chrome149",
"chrome140",
"firefox",
"safari26",
"random",
] {
assert!(profile_parser(s).is_ok(), "{s} should parse");
}
assert!(profile_parser("netscape").is_err());
}
#[test]
fn category_and_range_parsers() {
assert_eq!(category_parser("news").unwrap(), Category::News);
assert!(category_parser("nope").is_err());
assert_eq!(time_range_parser("week").unwrap(), TimeRange::Week);
assert!(time_range_parser("yesterday").is_err());
assert!(safesearch_parser("strict").is_ok());
assert!(safesearch_parser("x").is_err());
}
}