exauro 0.1.0

Exa neural search API CLI — search, find-similar, contents, answer
use clap::{Parser, Subcommand, ValueEnum};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::env;
use std::process;

#[derive(Parser)]
#[command(name = "exauro")]
#[command(about = "A command-line wrapper for the Exa search API", long_about = None)]
#[command(version)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Search the web
    Search {
        query: String,
        /// Search type
        #[arg(long, value_enum, default_value_t = SearchType::Neural)]
        search_type: SearchType,
        /// Number of results
        #[arg(short = 'n', long, default_value_t = 5)]
        num_results: usize,
        /// Print raw API response as pretty JSON
        #[arg(long)]
        json: bool,
    },
    /// Find pages similar to a URL
    Similar {
        url: String,
        /// Number of results
        #[arg(short = 'n', long, default_value_t = 5)]
        num_results: usize,
        /// Print raw API response as pretty JSON
        #[arg(long)]
        json: bool,
    },
    /// Get full content of a URL
    Contents {
        url: String,
        /// Print raw API response as pretty JSON
        #[arg(long)]
        json: bool,
    },
    /// Get LLM answer with citations
    Answer {
        query: String,
        /// Print raw API response as pretty JSON
        #[arg(long)]
        json: bool,
    },
}

#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Serialize)]
#[serde(rename_all = "lowercase")]
enum SearchType {
    Auto,
    Neural,
    Fast,
    Deep,
}

#[derive(Deserialize, Serialize, Debug)]
struct SearchResult {
    title: Option<String>,
    url: String,
    summary: Option<String>,
    text: Option<String>,
}

#[derive(Deserialize, Serialize, Debug)]
struct SearchResponse {
    results: Vec<SearchResult>,
}

#[derive(Deserialize, Serialize, Debug)]
struct Citation {
    url: String,
    title: Option<String>,
}

#[derive(Deserialize, Serialize, Debug)]
struct AnswerResponse {
    answer: String,
    citations: Vec<Citation>,
}

fn main() {
    let cli = Cli::parse();

    let api_key = match env::var("EXA_API_KEY") {
        Ok(key) => key,
        Err(_) => {
            eprintln!("Set EXA_API_KEY env var (stored in 1Password vault Agents, item Agent Environment)");
            process::exit(1);
        }
    };

    let client = Client::new();
    let base_url = "https://api.exa.ai";

    match cli.command {
        Commands::Search {
            query,
            search_type,
            num_results,
            json,
        } => {
            let body = json!({
                "query": query,
                "type": search_type,
                "numResults": num_results,
                "contents": { "summary": true }
            });
            handle_post_request(&client, &format!("{}/search", base_url), &api_key, body, json, |resp: SearchResponse| {
                for (i, result) in resp.results.iter().enumerate() {
                    let title = result.title.as_deref().unwrap_or("No Title");
                    let summary = result.summary.as_deref().unwrap_or("No Summary");
                    let summary_truncated = if summary.len() > 200 {
                        format!("{}...", &summary[..197])
                    } else {
                        summary.to_string()
                    };
                    println!("{}. {}\n   {}\n   {}\n", i + 1, title, result.url, summary_truncated);
                }
            });
        }
        Commands::Similar {
            url,
            num_results,
            json,
        } => {
            let body = json!({
                "url": url,
                "numResults": num_results,
                "contents": { "summary": true }
            });
            handle_post_request(&client, &format!("{}/findSimilar", base_url), &api_key, body, json, |resp: SearchResponse| {
                for (i, result) in resp.results.iter().enumerate() {
                    let title = result.title.as_deref().unwrap_or("No Title");
                    let summary = result.summary.as_deref().unwrap_or("No Summary");
                    let summary_truncated = if summary.len() > 200 {
                        format!("{}...", &summary[..197])
                    } else {
                        summary.to_string()
                    };
                    println!("{}. {}\n   {}\n   {}\n", i + 1, title, result.url, summary_truncated);
                }
            });
        }
        Commands::Contents { url, json } => {
            let body = json!({
                "ids": [url],
                "livecrawl": "always"
            });
            handle_post_request(&client, &format!("{}/contents", base_url), &api_key, body, json, |resp: SearchResponse| {
                if let Some(result) = resp.results.first() {
                    let title = result.title.as_deref().unwrap_or("No Title");
                    let text = result.text.as_deref().unwrap_or("No Content");
                    println!("Title: {}\n\n{}", title, text);
                } else {
                    eprintln!("No content found.");
                }
            });
        }
        Commands::Answer { query, json } => {
            let body = json!({
                "query": query,
                "type": "auto"
            });
            handle_post_request(&client, &format!("{}/answer", base_url), &api_key, body, json, |resp: AnswerResponse| {
                println!("{}\n", resp.answer);
                if !resp.citations.is_empty() {
                    println!("Sources:");
                    for (i, c) in resp.citations.iter().enumerate() {
                        if let Some(ref t) = c.title {
                            println!("{}. {}{}", i + 1, t, c.url);
                        } else {
                            println!("{}. {}", i + 1, c.url);
                        }
                    }
                }
            });
        }
    }
}

fn handle_post_request<T, F>(
    client: &Client,
    url: &str,
    api_key: &str,
    body: serde_json::Value,
    as_json: bool,
    on_success: F,
) where
    T: Serialize + for<'de> Deserialize<'de>,
    F: FnOnce(T),
{
    let response = client
        .post(url)
        .header("x-api-key", api_key)
        .json(&body)
        .send();

    match response {
        Ok(res) => {
            if !res.status().is_success() {
                let status = res.status();
                let error_text = res.text().unwrap_or_else(|_| "Could not read error response".to_string());
                eprintln!("API Error ({}): {}", status, error_text);
                process::exit(1);
            }

            if as_json {
                match res.json::<serde_json::Value>() {
                    Ok(val) => println!("{}", serde_json::to_string_pretty(&val).unwrap()),
                    Err(e) => {
                        eprintln!("Failed to parse JSON response: {}", e);
                        process::exit(1);
                    }
                }
            } else {
                match res.json::<T>() {
                    Ok(val) => on_success(val),
                    Err(e) => {
                        eprintln!("Failed to parse API response: {}", e);
                        process::exit(1);
                    }
                }
            }
        }
        Err(e) => {
            eprintln!("Network Error: {}", e);
            process::exit(1);
        }
    }
}