lingshu-tools 0.10.0

Tool registry, ToolHandler trait, and 50+ tool implementations
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
#![allow(clippy::await_holding_lock)]
#![allow(dead_code)]
//! Live E2E tests for pluggable web search backends.
//!
//! Run with:
//! ```bash
//! cargo test -p lingshu-tools --test web_search_e2e -- --include-ignored --nocapture
//! ```

mod common;

use common::{
    DEFAULT_SEARXNG_DOCKER_URL, apply_searxng_docker_env, ctx_with_config, register_ddgs_mock_fail,
    register_ddgs_mock_success, register_mock, registry_guard, searxng_docker_url_if_ready,
    searxng_json_api_ready, test_ctx,
};
use lingshu_tools::config_ref::WebSearchBackendConfigRef;
use lingshu_tools::tools::web::search::backend::SearchResult;
use lingshu_tools::tools::web::search::backends::mock::MockMode;
use lingshu_tools::{AppConfigRef, ToolHandler, ToolRegistry, WebSearchTool};
use serde_json::json;

#[tokio::test]
#[ignore = "requires internet — live DuckDuckGo HTML search"]
async fn e2e_ddgs_search_without_api_key() {
    let _lock = registry_guard();
    let result = WebSearchTool
        .execute(
            json!({"query": "Rust programming language", "backend": "ddgs", "max_results": 3}),
            &test_ctx(),
        )
        .await;
    match result {
        Ok(body) => {
            let parsed: serde_json::Value = serde_json::from_str(&body).expect("json");
            assert_eq!(parsed["success"], true);
            assert_eq!(parsed["backend"], "ddgs");
            let results = parsed["results"].as_array().expect("results array");
            assert!(!results.is_empty(), "ddgs should return at least one hit");
            for hit in results {
                let url = hit["url"].as_str().expect("url field");
                assert!(
                    url.starts_with("http"),
                    "result URL must be absolute and fetchable: {url}"
                );
                assert!(
                    !url.contains("bing.com/ck/a"),
                    "Bing tracking URL must be decoded before web_extract: {url}"
                );
            }
            eprintln!("E2E DDGS proof: {body}");
        }
        Err(e)
            if e.to_string().contains("bot-challenge")
                || e.to_string().contains("bot challenge")
                || e.to_string().contains("blocked this request") =>
        {
            eprintln!(
                "Skipping: DDG returned bot-challenge in this environment (use SEARXNG_URL or BRAVE_API_KEY for reliable live search)"
            );
        }
        Err(e) => panic!("unexpected ddgs error: {e}"),
    }
}

#[tokio::test]
#[ignore = "requires internet — live DuckDuckGo HTML search"]
async fn e2e_ddgs_person_query_never_returns_spam_hits() {
    let _lock = registry_guard();
    let result = WebSearchTool
        .execute(
            json!({"query": "Raphaël MANSUY", "backend": "ddgs", "max_results": 5}),
            &test_ctx(),
        )
        .await;
    match result {
        Ok(body) => {
            let parsed: serde_json::Value = serde_json::from_str(&body).expect("json");
            if parsed["success"] != true {
                eprintln!("E2E person query: no results (acceptable): {body}");
                return;
            }
            let results = parsed["results"].as_array().expect("results array");
            for hit in results {
                let title = hit["title"].as_str().unwrap_or("").to_ascii_lowercase();
                let url = hit["url"].as_str().unwrap_or("").to_ascii_lowercase();
                assert!(
                    !title.contains("dexsport") && !url.contains("dexsport"),
                    "must not return Dexsport spam: {hit:?}"
                );
                assert!(
                    !title.contains("tiktok") && !url.contains("tiktok"),
                    "must not return TikTok spam batch: {hit:?}"
                );
                let combined = format!("{title} {}", hit["snippet"].as_str().unwrap_or(""));
                assert!(
                    combined.contains("mansuy"),
                    "relevant person hit must mention MANSUY: {hit:?}"
                );
            }
            eprintln!("E2E person query proof: {body}");
        }
        Err(e)
            if e.to_string().contains("bot-challenge")
                || e.to_string().contains("bot challenge")
                || e.to_string().contains("blocked this request")
                || e.to_string().contains("All metasearch engines failed") =>
        {
            eprintln!("Skipping or blocked (acceptable): {e}");
        }
        Err(e) => panic!("unexpected ddgs error: {e}"),
    }
}

#[tokio::test]
#[ignore = "requires SEARXNG_URL pointing at a reachable public instance"]
async fn e2e_searxng_search_when_configured() {
    let _lock = registry_guard();
    let url = match std::env::var("SEARXNG_URL") {
        Ok(v) if !v.trim().is_empty() => v,
        _ => {
            eprintln!("Skipping: set SEARXNG_URL to a reachable SearXNG instance");
            return;
        }
    };
    unsafe { std::env::set_var("SEARXNG_URL", &url) };

    let mut cfg = AppConfigRef::default();
    cfg.web_search.primary = "searxng".into();
    cfg.web_search.fallbacks = vec![];

    let result = WebSearchTool
        .execute(
            json!({"query": "Lingshu agent", "max_results": 3}),
            &ctx_with_config(cfg),
        )
        .await
        .expect("searxng search");
    let parsed: serde_json::Value = serde_json::from_str(&result).expect("json");
    assert_eq!(parsed["success"], true);
    assert_eq!(parsed["backend"], "searxng");
    eprintln!("E2E SearXNG proof: {result}");
}

#[tokio::test]
#[ignore = "requires Docker SearXNG — run specs/001-gap-analysis-v14/014-web-search-backends/e2e/run-searxng-e2e.sh"]
async fn e2e_searxng_docker_live_search() {
    let _lock = registry_guard();
    let url =
        std::env::var("SEARXNG_URL").unwrap_or_else(|_| DEFAULT_SEARXNG_DOCKER_URL.to_string());

    let docker_e2e = std::env::var("EDGECRAB_SEARXNG_DOCKER_E2E")
        .ok()
        .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes"));

    if !docker_e2e && !searxng_json_api_ready(&url) {
        eprintln!(
            "Skipping: start SearXNG with run-searxng-e2e.sh or set EDGECRAB_SEARXNG_DOCKER_E2E=1"
        );
        return;
    }

    if !searxng_json_api_ready(&url) {
        panic!("EDGECRAB_SEARXNG_DOCKER_E2E set but SearXNG not ready at {url}");
    }

    unsafe {
        std::env::set_var("SEARXNG_URL", &url);
        std::env::set_var("EDGECRAB_E2E_SSRF_ALLOW_LOCALHOST", "1");
    }

    let mut cfg = AppConfigRef::default();
    cfg.web_search.primary = "searxng".into();
    cfg.web_search.fallbacks = vec![];

    let result = WebSearchTool
        .execute(
            json!({"query": "Rust programming language", "max_results": 3}),
            &ctx_with_config(cfg),
        )
        .await
        .expect("docker searxng search");
    let parsed: serde_json::Value = serde_json::from_str(&result).expect("json");
    assert_eq!(parsed["success"], true);
    assert_eq!(parsed["backend"], "searxng");
    let results = parsed["results"].as_array().expect("results");
    assert!(!results.is_empty(), "SearXNG docker should return hits");
    assert!(results[0]["title"].is_string());
    assert!(results[0]["url"].is_string());
    assert_eq!(results[0]["rank"], 1);
    eprintln!("E2E SearXNG Docker proof: {result}");
}

#[tokio::test]
#[ignore = "requires Docker SearXNG — run run-searxng-e2e.sh"]
async fn e2e_searxng_docker_max_results_honored() {
    let _lock = registry_guard();
    let Some(url) = searxng_docker_url_if_ready() else {
        eprintln!("Skipping: SearXNG docker not running");
        return;
    };
    apply_searxng_docker_env(&url);

    let mut cfg = AppConfigRef::default();
    cfg.web_search.primary = "searxng".into();

    let result = WebSearchTool
        .execute(
            json!({"query": "Python programming", "max_results": 2}),
            &ctx_with_config(cfg),
        )
        .await
        .expect("searxng max_results");
    let parsed: serde_json::Value = serde_json::from_str(&result).expect("json");
    let results = parsed["results"].as_array().expect("results");
    assert_eq!(results.len(), 2);
    assert_eq!(results[0]["rank"], 1);
    assert_eq!(results[1]["rank"], 2);
    eprintln!("E2E SearXNG max_results proof: {result}");
}

#[tokio::test]
#[ignore = "requires Docker SearXNG — run run-searxng-e2e.sh"]
async fn e2e_searxng_docker_explicit_backend_override() {
    let _lock = registry_guard();
    let Some(url) = searxng_docker_url_if_ready() else {
        eprintln!("Skipping: SearXNG docker not running");
        return;
    };
    apply_searxng_docker_env(&url);

    let result = WebSearchTool
        .execute(
            json!({"query": "lingshu agent", "backend": "searxng", "max_results": 3}),
            &test_ctx(),
        )
        .await
        .expect("explicit searxng");
    let parsed: serde_json::Value = serde_json::from_str(&result).expect("json");
    assert_eq!(parsed["backend"], "searxng");
    assert!(parsed["fallback_from"].is_null());
    assert!(!parsed["results"].as_array().unwrap().is_empty());
    eprintln!("E2E SearXNG explicit override proof: {result}");
}

#[tokio::test]
#[ignore = "requires Docker SearXNG — run run-searxng-e2e.sh"]
async fn e2e_searxng_docker_config_endpoint_without_env() {
    let _lock = registry_guard();
    let Some(url) = searxng_docker_url_if_ready() else {
        eprintln!("Skipping: SearXNG docker not running");
        return;
    };
    unsafe {
        std::env::remove_var("SEARXNG_URL");
        std::env::set_var("EDGECRAB_E2E_SSRF_ALLOW_LOCALHOST", "1");
    }

    let mut cfg = AppConfigRef::default();
    cfg.web_search.primary = "searxng".into();
    cfg.web_search.backends.insert(
        "searxng".into(),
        WebSearchBackendConfigRef {
            endpoint: Some(url.clone()),
            ..Default::default()
        },
    );

    let result = WebSearchTool
        .execute(
            json!({"query": "Lingshu web search", "max_results": 2}),
            &ctx_with_config(cfg),
        )
        .await
        .expect("config endpoint searxng");
    let parsed: serde_json::Value = serde_json::from_str(&result).expect("json");
    assert_eq!(parsed["backend"], "searxng");
    assert!(!parsed["results"].as_array().unwrap().is_empty());
    let web = parsed["data"]["web"].as_array().expect("hermes data.web");
    assert!(!web.is_empty());
    assert!(web[0]["description"].is_string());
    eprintln!("E2E SearXNG config endpoint proof: {result}");
}

#[tokio::test]
#[ignore = "requires Docker SearXNG — run run-searxng-e2e.sh"]
async fn e2e_searxng_docker_fallback_from_failing_mock() {
    let _lock = registry_guard();
    let Some(url) = searxng_docker_url_if_ready() else {
        eprintln!("Skipping: SearXNG docker not running");
        return;
    };
    apply_searxng_docker_env(&url);
    register_mock("primary-down", MockMode::Server(503));

    let mut cfg = AppConfigRef::default();
    cfg.web_search.primary = "primary-down".into();
    cfg.web_search.fallbacks = vec!["searxng".into()];

    let result = WebSearchTool
        .execute(
            json!({"query": "Rust language", "max_results": 2}),
            &ctx_with_config(cfg),
        )
        .await
        .expect("503 mock -> searxng docker");
    let parsed: serde_json::Value = serde_json::from_str(&result).expect("json");
    assert_eq!(parsed["fallback_from"], "primary-down");
    assert_eq!(parsed["backend"], "searxng");
    assert!(!parsed["results"].as_array().unwrap().is_empty());
    eprintln!("E2E SearXNG docker fallback proof: {result}");
}

#[tokio::test]
#[ignore = "requires Docker SearXNG — run run-searxng-e2e.sh"]
async fn e2e_searxng_docker_auto_backend_uses_config_chain() {
    let _lock = registry_guard();
    let Some(url) = searxng_docker_url_if_ready() else {
        eprintln!("Skipping: SearXNG docker not running");
        return;
    };
    apply_searxng_docker_env(&url);
    register_ddgs_mock_success();

    let mut cfg = AppConfigRef::default();
    cfg.web_search.primary = "searxng".into();
    cfg.web_search.fallbacks = vec!["ddgs".into()];

    let result = WebSearchTool
        .execute(
            json!({"query": "Tokyo Japan", "backend": "auto", "max_results": 2}),
            &ctx_with_config(cfg),
        )
        .await
        .expect("auto uses searxng first");
    let parsed: serde_json::Value = serde_json::from_str(&result).expect("json");
    assert_eq!(parsed["backend"], "searxng");
    assert!(parsed["fallback_from"].is_null());
    eprintln!("E2E SearXNG auto chain proof: {result}");
}

#[tokio::test]
#[ignore = "requires BRAVE_API_KEY"]
async fn e2e_brave_search_when_key_set() {
    let _lock = registry_guard();
    if std::env::var("BRAVE_API_KEY")
        .or_else(|_| std::env::var("BRAVE_SEARCH_API_KEY"))
        .is_err()
    {
        eprintln!("Skipping: BRAVE_API_KEY not set");
        return;
    }
    let result = WebSearchTool
        .execute(
            json!({"query": "Lingshu", "backend": "brave", "max_results": 3}),
            &test_ctx(),
        )
        .await
        .expect("brave search");
    let parsed: serde_json::Value = serde_json::from_str(&result).expect("json");
    assert_eq!(parsed["backend"], "brave");
    eprintln!("E2E Brave proof: {result}");
}

#[tokio::test]
async fn e2e_fallback_from_rate_limited_primary() {
    let _lock = registry_guard();
    register_ddgs_mock_success();
    register_mock("always-429", MockMode::RateLimit);
    let mut cfg = AppConfigRef::default();
    cfg.web_search.primary = "always-429".into();
    cfg.web_search.fallbacks = vec!["ddgs".into()];

    let result = WebSearchTool
        .execute(
            json!({"query": "Paris France", "max_results": 2}),
            &ctx_with_config(cfg),
        )
        .await
        .expect("fallback to ddgs");
    let parsed: serde_json::Value = serde_json::from_str(&result).expect("json");
    assert_eq!(parsed["fallback_from"], "always-429");
    assert_eq!(parsed["backend"], "ddgs");
    eprintln!("E2E fallback proof: {result}");
}

#[tokio::test]
async fn e2e_all_fail_returns_descriptive_error() {
    let _lock = registry_guard();
    register_mock("fail-one", MockMode::Network);
    register_mock("fail-two", MockMode::Server(503));
    register_ddgs_mock_fail();
    let mut cfg = AppConfigRef::default();
    cfg.web_search.primary = "fail-one".into();
    cfg.web_search.fallbacks = vec!["fail-two".into()];

    let err = WebSearchTool
        .execute(json!({"query": "test"}), &ctx_with_config(cfg))
        .await
        .expect_err("all backends fail");
    let msg = err.to_string();
    assert!(msg.contains("fail-one"));
    assert!(msg.contains("fail-two"));
    eprintln!("E2E all-fail proof: {msg}");
}

#[tokio::test]
async fn e2e_plugin_registered_backend_via_tool() {
    let _lock = registry_guard();
    register_mock(
        "plugin-demo",
        MockMode::Success(vec![SearchResult::new(
            1,
            "Plugin Hit",
            "https://plugin.example",
            "from plugin",
            "plugin-demo",
        )]),
    );
    let mut cfg = AppConfigRef::default();
    cfg.web_search.primary = "plugin-demo".into();

    let result = WebSearchTool
        .execute(json!({"query": "anything"}), &ctx_with_config(cfg))
        .await
        .expect("plugin backend");
    let parsed: serde_json::Value = serde_json::from_str(&result).expect("json");
    assert_eq!(parsed["backend"], "plugin-demo");
    assert_eq!(parsed["results"][0]["title"], "Plugin Hit");
}

#[test]
fn e2e_tool_registered_in_registry() {
    let registry = ToolRegistry::new();
    assert!(registry.tool_names().contains(&"web_search"));
}