agent-search 0.5.1

Unified multi-provider search CLI for AI agents — 12 providers, 14 modes, email verification, one binary
use directories::ProjectDirs;
use figment::{
    providers::{Env, Format, Serialized, Toml},
    Figment,
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
    pub keys: ApiKeys,
    pub settings: Settings,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiKeys {
    #[serde(default)]
    pub parallel: String,
    #[serde(default)]
    pub brave: String,
    #[serde(default)]
    pub serper: String,
    #[serde(default)]
    pub exa: String,
    #[serde(default)]
    pub jina: String,
    #[serde(default)]
    pub firecrawl: String,
    #[serde(default)]
    pub tavily: String,
    #[serde(default)]
    pub serpapi: String,
    #[serde(default)]
    pub perplexity: String,
    #[serde(default)]
    pub browserless: String,
    #[serde(default)]
    pub xai: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
    #[serde(default = "default_timeout")]
    pub timeout: u64,
    #[serde(default = "default_count")]
    pub count: usize,
}

fn default_timeout() -> u64 {
    10
}
fn default_count() -> usize {
    10
}

impl Default for AppConfig {
    fn default() -> Self {
        Self {
            keys: ApiKeys {
                parallel: String::new(),
                brave: String::new(),
                serper: String::new(),
                exa: String::new(),
                jina: String::new(),
                firecrawl: String::new(),
                tavily: String::new(),
                serpapi: String::new(),
                perplexity: String::new(),
                browserless: String::new(),
                xai: String::new(),
            },
            settings: Settings {
                timeout: default_timeout(),
                count: default_count(),
            },
        }
    }
}

pub fn config_dir() -> PathBuf {
    if let Some(proj) = ProjectDirs::from("", "", "search") {
        proj.config_dir().to_path_buf()
    } else {
        dirs_fallback()
    }
}

fn dirs_fallback() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
    PathBuf::from(home).join(".config").join("search")
}

pub fn config_path() -> PathBuf {
    config_dir().join("config.toml")
}

pub fn load_config() -> Result<AppConfig, Box<figment::Error>> {
    Ok(Figment::new()
        .merge(Serialized::defaults(AppConfig::default()))
        .merge(Toml::file(config_path()))
        .merge(Env::prefixed("SEARCH_").split("_"))
        .extract()?)
}

pub fn mask_key(key: &str) -> String {
    if key.len() <= 8 {
        if key.is_empty() {
            "(not set)".to_string()
        } else {
            format!("{}***", &key[..2])
        }
    } else {
        format!("{}...{}", &key[..4], &key[key.len() - 4..])
    }
}

pub fn config_show(config: &AppConfig) {
    use owo_colors::OwoColorize;
    use std::io::IsTerminal;
    let c = std::io::stdout().is_terminal();

    if c {
        println!("\n{}  Configuration\n", "search".bold().cyan());
        println!("  {} {}\n", "path:".dimmed(), config_path().display().to_string().dimmed());
    } else {
        println!("Configuration ({})\n", config_path().display());
    }

    use crate::providers;

    let keys: &[(&str, &str, &str)] = &[
        ("parallel",   &config.keys.parallel,   "PARALLEL_API_KEY"),
        ("brave",      &config.keys.brave,      "BRAVE_API_KEY"),
        ("serper",     &config.keys.serper,      "SERPER_API_KEY"),
        ("exa",        &config.keys.exa,         "EXA_API_KEY"),
        ("jina",       &config.keys.jina,        "JINA_API_KEY"),
        ("firecrawl",  &config.keys.firecrawl,   "FIRECRAWL_API_KEY"),
        ("tavily",     &config.keys.tavily,      "TAVILY_API_KEY"),
        ("serpapi",    &config.keys.serpapi,      "SERPAPI_API_KEY"),
        ("perplexity", &config.keys.perplexity,  "PERPLEXITY_API_KEY"),
        ("browserless",&config.keys.browserless,  "BROWSERLESS_API_KEY"),
        ("xai",        &config.keys.xai,         "XAI_API_KEY"),
    ];

    if c { println!("  {}", "[keys]".bold()); } else { println!("[keys]"); }
    for (name, config_val, env_var) in keys {
        let effective = providers::resolve_key(config_val, env_var);
        let masked = mask_key(&effective);
        if c {
            let val = if effective.is_empty() {
                masked.red().to_string()
            } else {
                masked.green().to_string()
            };
            println!("    {:<12} {}", name.white(), val);
        } else {
            println!("  {:<12} = {}", name, masked);
        }
    }

    println!();
    if c { println!("  {}", "[settings]".bold()); } else { println!("[settings]"); }
    if c {
        println!("    {:<10} {}", "timeout".white(), format!("{}s", config.settings.timeout).cyan());
        println!("    {:<10} {}", "count".white(), config.settings.count.to_string().cyan());
    } else {
        println!("  timeout  = {}s", config.settings.timeout);
        println!("  count    = {}", config.settings.count);
    }
    println!();
}

pub fn config_set(key: &str, value: &str) -> Result<(), crate::errors::SearchError> {
    let path = config_path();
    let mut doc: toml::Table = if path.exists() {
        let content = std::fs::read_to_string(&path)?;
        content
            .parse()
            .map_err(|e: toml::de::Error| crate::errors::SearchError::Config(e.to_string()))?
    } else {
        toml::Table::new()
    };

    // Support dotted keys: keys.brave, settings.timeout
    let parts: Vec<&str> = key.split('.').collect();
    match parts.len() {
        1 => {
            doc.insert(parts[0].to_string(), toml::Value::String(value.to_string()));
        }
        2 => {
            let section = doc
                .entry(parts[0])
                .or_insert_with(|| toml::Value::Table(toml::Table::new()));
            if let toml::Value::Table(t) = section {
                t.insert(parts[1].to_string(), toml::Value::String(value.to_string()));
            }
        }
        _ => {
            return Err(crate::errors::SearchError::Config(format!(
                "Invalid key: {key}"
            )));
        }
    }

    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(&path, doc.to_string())?;
    Ok(())
}

pub fn config_check(config: &AppConfig) {
    use owo_colors::OwoColorize;
    use std::io::IsTerminal;
    let c = std::io::stdout().is_terminal();

    use crate::providers;

    let all: &[(&str, &str, &str, &str)] = &[
        ("parallel",    &config.keys.parallel,    "PARALLEL_API_KEY",    "Independent web index (Parallel AI)"),
        ("brave",       &config.keys.brave,       "BRAVE_API_KEY",       "Web + News search"),
        ("serper",      &config.keys.serper,       "SERPER_API_KEY",      "Google SERP, Scholar, Patents, Images, Places"),
        ("exa",         &config.keys.exa,          "EXA_API_KEY",         "Semantic search, People, Similar pages"),
        ("jina",        &config.keys.jina,         "JINA_API_KEY",        "Web search + URL reader"),
        ("firecrawl",   &config.keys.firecrawl,    "FIRECRAWL_API_KEY",   "Web scraping + extraction"),
        ("tavily",      &config.keys.tavily,       "TAVILY_API_KEY",      "General, News, Academic, Deep search"),
        ("serpapi",     &config.keys.serpapi,       "SERPAPI_API_KEY",     "80+ engines: Google, Bing, YouTube, Baidu, Scholar"),
        ("perplexity",  &config.keys.perplexity,   "PERPLEXITY_API_KEY",  "AI-powered answers with citations (Perplexity Sonar)"),
        ("browserless", &config.keys.browserless,   "BROWSERLESS_API_KEY", "Cloud browser for Cloudflare/JS-heavy pages"),
        ("xai",         &config.keys.xai,          "XAI_API_KEY",         "X/Twitter social search via xAI Grok"),
    ];

    if c {
        println!("\n{}  Provider Health Check\n", "search".bold().cyan());
    }

    let mut configured = 0;
    for (name, config_val, env_var, desc) in all {
        let is_configured = !providers::resolve_key(config_val, env_var).is_empty();
        if !is_configured {
            if c {
                println!("  {} {:<12} {}", "x".red().bold(), name.white(), desc.dimmed());
            } else {
                println!("  [x] {name}: NOT SET - {desc}");
            }
        } else {
            configured += 1;
            if c {
                println!("  {} {:<12} {}", "+".green().bold(), name.white().bold(), desc.dimmed());
            } else {
                println!("  [+] {name}: OK - {desc}");
            }
        }
    }

    println!();
    if configured == 0 {
        if c {
            println!("  {} No providers configured.\n", "!".yellow().bold());
            println!("  Set API keys via environment or config:");
            println!("    {} export BRAVE_API_KEY=YOUR_KEY", "$".dimmed());
            println!("    {} search config set keys.brave YOUR_KEY", "$".dimmed());
        } else {
            println!("  No providers configured. Set API keys via:");
            println!("    export BRAVE_API_KEY=<YOUR_KEY>");
            println!("    search config set keys.brave <YOUR_KEY>");
        }
    } else if c {
        println!(
            "  {}/{} providers ready",
            configured.to_string().green().bold(),
            all.len()
        );
    } else {
        println!("  {configured}/{} providers configured", all.len());
    }
    println!();
}