grapheme-stdlib 0.3.0

Standard library operation implementations for Grapheme
Documentation
use serde_json::{json, Value as JsonValue};
use websearch::{providers::DuckDuckGoProvider, web_search, SearchOptions};

pub fn search(args: &JsonValue) -> JsonValue {
    let request = SearchRequest::from_args(args);
    if request.query.trim().is_empty() {
        return json!({ "error": "missing required arg: query" });
    }

    let provider = request.provider.clone();

    if provider != "duckduckgo" {
        return json!({
            "error": format!("unsupported websearch provider '{}'; currently supported: duckduckgo", provider)
        });
    }

    let runtime = match tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
    {
        Ok(rt) => rt,
        Err(err) => {
            return json!({ "error": format!("websearch runtime init failed: {err}") });
        }
    };

    let search_result = runtime.block_on(async {
        let provider = DuckDuckGoProvider::new();
        web_search(SearchOptions {
            query: request.query.clone(),
            max_results: request.max_results,
            provider: Box::new(provider),
            ..Default::default()
        })
        .await
    });

    match search_result {
        Ok(results) => {
            let results_json =
                serde_json::to_value(results).unwrap_or_else(|_| JsonValue::Array(Vec::new()));
            SearchResponse::success(request.query, provider, results_json).to_json()
        }
        Err(err) => SearchResponse::failure(request.query, provider, err.to_string()).to_json(),
    }
}

pub fn search_provider(args: &JsonValue, provider: &str) -> JsonValue {
    let request = SearchRequest::from_args(args).with_provider(provider);
    search(&request.to_json())
}

pub fn providers() -> JsonValue {
    let providers = provider_catalog()
        .into_iter()
        .map(|provider| provider.to_json())
        .collect::<Vec<_>>();

    json!({
        "count": providers.len(),
        "providers": providers,
    })
}

pub fn capabilities(args: &JsonValue) -> JsonValue {
    let Some(target) = args
        .get("provider")
        .and_then(|v| v.as_str())
        .map(|s| s.to_ascii_lowercase())
    else {
        return providers();
    };

    let Some(provider) = provider_catalog().into_iter().find(|p| p.id == target) else {
        return json!({
            "error": format!("unknown provider '{}'", target),
            "available_providers": provider_catalog().into_iter().map(|p| p.id).collect::<Vec<_>>()
        });
    };

    json!({ "provider": provider.to_json() })
}

#[derive(Debug, Clone)]
struct SearchRequest {
    query: String,
    provider: String,
    max_results: Option<u32>,
}

#[derive(Debug, Clone)]
struct SearchResponse {
    query: String,
    provider: String,
    results: JsonValue,
    error: Option<String>,
}

impl SearchResponse {
    fn success(query: String, provider: String, results: JsonValue) -> Self {
        Self {
            query,
            provider,
            results,
            error: None,
        }
    }

    fn failure(query: String, provider: String, error: String) -> Self {
        Self {
            query,
            provider,
            results: JsonValue::Array(Vec::new()),
            error: Some(error),
        }
    }

    fn to_json(&self) -> JsonValue {
        let count = self
            .results
            .as_array()
            .map(|items| items.len())
            .unwrap_or(0);
        if let Some(error) = &self.error {
            json!({
                "query": self.query,
                "provider": self.provider,
                "error": error,
                "results": self.results,
            })
        } else {
            json!({
                "query": self.query,
                "provider": self.provider,
                "count": count,
                "results": self.results,
            })
        }
    }
}

impl SearchRequest {
    fn from_args(args: &JsonValue) -> Self {
        Self {
            query: arg_text(args, "query"),
            provider: args
                .get("provider")
                .and_then(|v| v.as_str())
                .unwrap_or("duckduckgo")
                .to_ascii_lowercase(),
            max_results: args
                .get("max_results")
                .and_then(|v| v.as_u64())
                .map(|v| v.min(20) as u32),
        }
    }

    fn with_provider(mut self, provider: &str) -> Self {
        self.provider = provider.to_ascii_lowercase();
        self
    }

    fn to_json(&self) -> JsonValue {
        json!({
            "query": self.query,
            "provider": self.provider,
            "max_results": self.max_results,
        })
    }
}

#[derive(Debug, Clone)]
struct WebProvider {
    id: &'static str,
    status: &'static str,
    supports_search: bool,
    supports_research_flow: bool,
    note: &'static str,
}

impl WebProvider {
    fn to_json(&self) -> JsonValue {
        json!({
            "id": self.id,
            "status": self.status,
            "supports": {
                "search": self.supports_search,
                "research_materials": self.supports_research_flow,
                "research_report": self.supports_research_flow,
            },
            "note": self.note,
        })
    }
}

fn provider_catalog() -> Vec<WebProvider> {
    vec![
        WebProvider {
            id: "duckduckgo",
            status: "available",
            supports_search: true,
            supports_research_flow: true,
            note: "Native provider is wired and active.",
        },
        WebProvider {
            id: "google",
            status: "planned",
            supports_search: false,
            supports_research_flow: false,
            note: "Provider namespace is reserved; backend not yet wired.",
        },
        WebProvider {
            id: "xaviv",
            status: "planned",
            supports_search: false,
            supports_research_flow: false,
            note: "Provider namespace is reserved; backend not yet wired.",
        },
    ]
}

fn arg_text(args: &JsonValue, key: &str) -> String {
    args.get(key)
        .and_then(|v| v.as_str())
        .map(ToOwned::to_owned)
        .or_else(|| {
            args.get("__input")
                .and_then(|v| v.as_str())
                .map(ToOwned::to_owned)
        })
        .unwrap_or_default()
}

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

    #[test]
    fn search_rejects_unsupported_provider() {
        let out = search(&json!({
            "query": "rust",
            "provider": "google"
        }));
        assert!(out.get("error").and_then(|v| v.as_str()).is_some());
    }

    #[test]
    fn search_provider_alias_routes_semantics() {
        let out = search_provider(&json!({ "query": "rust" }), "xaviv");
        let err = out.get("error").and_then(|v| v.as_str()).unwrap_or("");
        assert!(err.contains("unsupported websearch provider 'xaviv'"));
    }

    #[test]
    fn providers_lists_known_catalog() {
        let out = providers();
        let providers = out
            .get("providers")
            .and_then(|v| v.as_array())
            .cloned()
            .unwrap_or_default();

        assert!(providers.iter().any(|item| {
            item.get("id")
                .and_then(|v| v.as_str())
                .map(|id| id == "duckduckgo")
                .unwrap_or(false)
        }));
    }

    #[test]
    fn capabilities_rejects_unknown_provider() {
        let out = capabilities(&json!({ "provider": "unknown" }));
        assert!(out.get("error").and_then(|v| v.as_str()).is_some());
    }
}