darash 0.5.2

Provider-neutral async client with an in-process search engine and SearxNG support
Documentation
use std::env;

use darash::{SearchClient, SearchMode, SearchRequest, SearchResponse, SearchSource};

struct CliArgs {
    query: String,
    mode: SearchMode,
    sources: Vec<SearchSource>,
    endpoint: Option<String>,
}

#[tokio::main]
async fn main() {
    let args = match parse_args(env::args().skip(1)) {
        Ok(Some(args)) => args,
        Ok(None) => {
            print_usage();
            return;
        }
        Err(error) => {
            eprintln!("{error}");
            print_usage();
            std::process::exit(2);
        }
    };

    if let Err(error) = run(args).await {
        eprintln!("search failed: {error}");
        std::process::exit(1);
    }
}

async fn run(args: CliArgs) -> Result<(), String> {
    let request = SearchRequest::new(args.query)
        .with_mode(args.mode)
        .with_sources(args.sources);
    let client = match args.endpoint {
        Some(endpoint) => SearchClient::new(endpoint),
        None => SearchClient::local(),
    }
    .map_err(|error| error.to_string())?;
    let response = client
        .search_request(&request)
        .await
        .map_err(|error| error.to_string())?;
    print_response(&response);
    Ok(())
}

fn parse_args<I>(args: I) -> Result<Option<CliArgs>, String>
where
    I: IntoIterator<Item = String>,
{
    let mut args = args.into_iter();
    match args.next().as_deref() {
        Some("search") => {}
        Some("--help" | "-h") => return Ok(None),
        Some(command) => return Err(format!("unknown command: {command}")),
        None => return Err("missing command".to_owned()),
    }

    let mut query = Vec::new();
    let mut mode = SearchMode::default();
    let mut sources = vec![SearchSource::default()];
    let mut endpoint = None;
    let mut source_set = false;

    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--mode" => {
                mode = parse_mode(&args.next().ok_or("--mode requires a value")?)?;
            }
            "--source" => {
                if !source_set {
                    sources.clear();
                    source_set = true;
                }
                sources.push(parse_source(
                    &args.next().ok_or("--source requires a value")?,
                )?);
            }
            "--url" => {
                endpoint = Some(args.next().ok_or("--url requires a value")?);
            }
            "--help" | "-h" => return Ok(None),
            value if value.starts_with('-') => return Err(format!("unknown option: {value}")),
            value => query.push(value.to_owned()),
        }
    }

    if query.is_empty() {
        return Err("search requires a query".to_owned());
    }

    Ok(Some(CliArgs {
        query: query.join(" "),
        mode,
        sources,
        endpoint,
    }))
}

fn parse_mode(value: &str) -> Result<SearchMode, String> {
    match value {
        "speed" => Ok(SearchMode::Speed),
        "balanced" => Ok(SearchMode::Balanced),
        "quality" => Ok(SearchMode::Quality),
        _ => Err(format!("invalid mode: {value}")),
    }
}

fn parse_source(value: &str) -> Result<SearchSource, String> {
    match value {
        "web" => Ok(SearchSource::Web),
        "academic" => Ok(SearchSource::Academic),
        "discussions" => Ok(SearchSource::Discussions),
        _ => Err(format!("invalid source: {value}")),
    }
}

fn print_response(response: &SearchResponse) {
    if let Some(answer) = &response.answer {
        println!("{answer}\n");
    }
    for source in response.cited_sources() {
        println!("{source}\n");
    }
}

fn usage() -> &'static str {
    "Usage: darash search <query> [--mode speed|balanced|quality] [--source web|academic|discussions] [--url endpoint]"
}

fn print_usage() {
    eprintln!(
        "{} (default: in-process Darash search; --url selects an external endpoint)",
        usage()
    );
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_search_options_without_environment() {
        let args = parse_args([
            "search".to_owned(),
            "rust".to_owned(),
            "async".to_owned(),
            "--mode".to_owned(),
            "quality".to_owned(),
            "--source".to_owned(),
            "academic".to_owned(),
            "--url".to_owned(),
            "http://localhost:9090".to_owned(),
        ])
        .expect("valid arguments")
        .expect("not help");

        assert_eq!(args.query, "rust async");
        assert_eq!(args.mode, SearchMode::Quality);
        assert_eq!(args.sources, [SearchSource::Academic]);
        assert_eq!(args.endpoint.as_deref(), Some("http://localhost:9090"));
    }

    #[test]
    fn parses_help_without_error() {
        assert!(parse_args(["--help".to_owned()])
            .expect("help is valid")
            .is_none());
        assert!(parse_args(["search".to_owned(), "--help".to_owned()])
            .expect("help is valid")
            .is_none());
    }
}