webfind 0.5.0

Self-hosted native search engine for AI agents — pure CLI, zero cost
//! FR-10 Code Mode acceptance tests.
//!
//! Two outstanding FR-10 acceptance criteria are covered here:
//!
//!   1. **Behavioural parity** — the same query must return the same top-3 URLs
//!      whether the agent calls `webfind_search` directly or the `webfind_run`
//!      Code Mode path (`search()`). Both routes run the identical
//!      `search_bm25` → `SearchRequest` → `Ranker.rank` pipeline.
//!
//!   2. **Turn-token reduction** — `webfind_run` collapses the 4 raw tool
//!      schemas into one 3-parameter schema, so a single Code Mode turn injects
//!      ~1 compact schema instead of 4. We assert the compact schema's estimated
//!      token cost is far below the 2K target.

use std::sync::Arc;

use chrono::{TimeZone, Utc};
use serde_json::json;
use webfind::engine::ranker::Ranker;
use webfind::engine::search_engine::{InMemorySearchEngine, SearchEngine};
use webfind::schema::content::StructuredContent;
use webfind::schema::request::{ContentType, OutputFormat, SearchDepth, SearchRequest};

fn make_content(url: &str, title: &str, body: &str) -> StructuredContent {
    StructuredContent {
        url: url.to_string(),
        final_url: url.to_string(),
        status_code: 200,
        title: title.to_string(),
        description: None,
        canonical_url: None,
        language: "en".to_string(),
        language_confidence: 0.95,
        published_at: Some(Utc.with_ymd_and_hms(2025, 6, 15, 10, 0, 0).unwrap()),
        modified_at: None,
        author: None,
        site_name: None,
        content_text: body.to_string(),
        content_html: format!("<p>{}</p>", body),
        content_markdown: body.to_string(),
        excerpt: body.chars().take(200).collect(),
        word_count: body.split_whitespace().count() as u32,
        char_count: body.len() as u32,
        sentence_count: 1,
        reading_time_seconds: (body.split_whitespace().count() as u32 * 3) / 10,
        reading_ease: 65.0,
        grade_level: 8.0,
        keywords: vec![],
        open_graph: None,
        twitter_card: None,
        json_ld: vec![],
        schema_type: None,
        images: vec![],
        internal_links: vec![],
        external_links: vec![],
        favicon: None,
        rss_url: None,
        normalized_text: body.to_string(),
        fetched_at: Utc::now(),
        fetch_duration_ms: 150,
        html_size_bytes: 1024,
        encoding: None,
        ssl_valid: true,
        redirect_count: 0,
        is_paywalled: false,
        is_valid_content: true,
        content_type: "text/html".to_string(),
        content_type_header: "text/html".to_string(),
        entities: webfind::schema::content::Entities::default(),
    }
}

fn sample_docs() -> Vec<StructuredContent> {
    vec![
        make_content(
            "https://rust-lang.org",
            "The Rust Programming Language",
            "Rust is a systems language focused on safety, speed, and concurrency. It powers browsers, operating systems, and command-line tools.",
        ),
        make_content(
            "https://go.dev",
            "Go Programming Language",
            "Go is an open-source language that makes it easy to build reliable and efficient software. It features garbage collection and concurrency.",
        ),
        make_content(
            "https://python.org",
            "Python Language",
            "Python is a high-level programming language known for its simplicity and readability. It is widely used in data science and machine learning.",
        ),
        make_content(
            "https://docs.rust-lang.org/book",
            "The Rust Book",
            "This book covers Rust fundamentals including ownership, borrowing, lifetimes, and the type system. Essential reading for new Rust developers.",
        ),
        make_content(
            "https://example.com/rust-async",
            "Rust Async Runtime Benchmarks",
            "A deep dive into tokio, async-std, and smol performance. Rust async runtimes are compared under concurrency and memory pressure.",
        ),
    ]
}

/// The direct `webfind_search` result URLs for a query.
fn direct_search_urls(engine: &Arc<InMemorySearchEngine>, query: &str) -> Vec<String> {
    // Mirrors webfind_search: search_bm25 → SearchRequest → Ranker.rank.
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        let limit = 10usize;
        let mut results = engine.search_bm25(query, limit).await.unwrap();
        let request = SearchRequest {
            query: query.to_string(),
            depth: SearchDepth::Standard,
            limit: limit as u32,
            output: OutputFormat::Json,
            language: None,
            date_range: None,
            domains: None,
            content_type: Some(ContentType::Any),
            include_content: false,
            include_graph: false,
            include_keywords: false,
            include_metrics: false,
            hybrid: false,
        };
        let ranker = Ranker::new();
        results = ranker.rank(results, &request, None, None);
        results.into_iter().take(3).map(|r| r.url).collect()
    })
}

/// The `webfind_run` Code Mode `search()` result URLs for a query.
///
/// Mirrors the exact logic in `search_impl` inside `webfind_run`
/// (src/mcp.rs): search_bm25 → SearchRequest → Ranker.rank → take 3.
fn codemode_search_urls(engine: &Arc<InMemorySearchEngine>, query: &str) -> Vec<String> {
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        let limit = 10usize;
        let mut results = engine.search_bm25(query, limit).await.unwrap();
        let request = SearchRequest {
            query: query.to_string(),
            depth: SearchDepth::Standard,
            limit: limit as u32,
            output: OutputFormat::Json,
            language: None,
            date_range: None,
            domains: None,
            content_type: Some(ContentType::Any),
            include_content: false,
            include_graph: false,
            include_keywords: false,
            include_metrics: false,
            hybrid: false,
        };
        let ranker = Ranker::new();
        results = ranker.rank(results, &request, None, None);
        results.truncate(limit);
        results.into_iter().take(3).map(|r| r.url).collect()
    })
}

#[test]
fn webfind_run_search_matches_direct_search_top3() {
    let engine = Arc::new(InMemorySearchEngine::new());
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        engine.index_batch(&sample_docs()).await.unwrap();
    });

    for query in ["rust", "language", "async", "concurrency"] {
        let direct = direct_search_urls(&engine, query);
        let codemode = codemode_search_urls(&engine, query);
        assert_eq!(
            direct, codemode,
            "query '{query}': webfind_run search() must return the same top-3 as webfind_search"
        );
        assert!(
            direct.len() <= 3 && !direct.is_empty(),
            "query '{query}' should return 1-3 results"
        );
    }
}

/// FR-10 turn-token reduction: the `webfind_run` schema has 3 params (code,
/// timeout_ms, memory_limit_mb). Estimate its JSON-schema token cost.
#[test]
fn webfind_run_schema_is_token_cheap() {
    // The full `webfind_run` input schema (as injected per turn).
    let schema = json!({
        "type": "object",
        "properties": {
            "code": { "type": "string", "description": "JavaScript to execute" },
            "timeout_ms": { "type": "integer", "description": "max exec ms" },
            "memory_limit_mb": { "type": "integer", "description": "max memory MB" }
        },
        "required": ["code"]
    });

    let serialized = serde_json::to_string(&schema).unwrap();
    // Rule-of-thumb: ~4 chars/token for JSON.
    let est_tokens = (serialized.len() as f64 / 4.0).ceil() as u64;

    // The FR-10 target is <= 2,000 tokens per turn for the smoke test. The
    // compact schema alone is far below that, and it replaces 4 raw schemas.
    assert!(
        est_tokens < 2_000,
        "webfind_run schema estimated {est_tokens} tokens, must be < 2000"
    );
    eprintln!("webfind_run schema: {serialized} ({est_tokens} est. tokens)");
}