eggsearch 0.3.2

Lightweight MCP metasearch server for AI agents
Documentation
//! `eggsearch search`: manual live metasearch via the CLI.

use anyhow::{anyhow, Result};
use eggsearch::core::config::{AppConfig, Mode};
use eggsearch::core::WebSearchRequest;
use eggsearch::mcp::ServerState;
use std::sync::Arc;

pub async fn run(
    cfg: &AppConfig,
    query: &str,
    max_results: usize,
    as_json: bool,
    providers: &[String],
) -> Result<()> {
    if cfg.search.mode == Mode::Off {
        anyhow::bail!("search is disabled by policy; set [search].mode = \"live\" to enable");
    }

    let state = Arc::new(ServerState::build(cfg.clone())?);

    let effective_providers = cfg
        .resolve_providers(providers)
        .map_err(|e| anyhow!("{}", e))?;
    let (_, unknown) = state.adapter.select_engines(&effective_providers);
    if !unknown.is_empty() {
        anyhow::bail!("unknown provider id(s): {}", unknown.join(", "));
    }

    let req = WebSearchRequest {
        query: query.to_string(),
        max_results: Some(max_results),
        providers: effective_providers,
        safe_search: None,
        timeout_ms: None,
    };

    if let Err(e) = req.validate(cfg.search.max_query_chars) {
        return Err(anyhow!("invalid query: {e}"));
    }

    let resolution = eggsearch::core::query::resolve_max_results(
        req.max_results,
        cfg.search.default_max_results,
        cfg.search.max_results_cap,
    );
    let resp = state.adapter.web_search(&req, resolution.effective).await;

    if as_json {
        let payload = serde_json::json!({
            "query": resp.query,
            "mode": resp.mode,
            "results": resp.results,
            "providers_queried": resp.providers_queried,
            "providers_failed": resp.providers_failed,
            "warnings": resp.warnings.iter().map(|w| format!("[{}] {}", w.provider_id, w.message)).collect::<Vec<_>>(),
        });
        println!("{}", serde_json::to_string_pretty(&payload)?);
    } else {
        println!(
            "# Results for '{}' ({} items, {} failed)",
            query,
            resp.results.len(),
            resp.providers_failed.len()
        );
        for (i, c) in resp.results.iter().enumerate() {
            let snippet = c.snippet.as_deref().unwrap_or("").replace('\n', " ");
            let providers = c.providers.join(", ");
            println!(
                "\n{}. {}\n   {}\n   [{}]\n   {}",
                i + 1,
                c.title,
                c.url,
                providers,
                snippet
            );
        }
        if !resp.warnings.is_empty() {
            println!("\nWarnings:");
            for w in &resp.warnings {
                println!("  - [{}] {}", w.provider_id, w.message);
            }
        }
        if !resp.providers_failed.is_empty() {
            println!("\nFailed providers:");
            for f in &resp.providers_failed {
                println!("  - {}: {} ({})", f.id, f.message, f.error_class);
            }
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use eggsearch::core::config::{AppConfig, Mode};

    #[tokio::test]
    async fn run_respects_mode_off() {
        let mut cfg = AppConfig::default();
        cfg.search.mode = Mode::Off;
        let err = run(&cfg, "rust", 10, false, &[])
            .await
            .expect_err("expected policy denial");
        assert!(err.to_string().contains("disabled by policy"), "got: {err}");
        assert!(err.to_string().contains("[search].mode"), "got: {err}");
    }
}