1use crate::utils::{RetryConfig, classify_host, retry_async_if, truncate_content};
2use anyhow::{Result, anyhow};
3use async_trait::async_trait;
4use reqwest::Client;
5use serde::{Deserialize, Serialize};
6use std::time::Duration;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SearchResult {
11 pub title: String,
12 pub url: String,
13 pub snippet: String,
14 pub full_content: String,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct WebFetchResult {
20 pub title: String,
21 pub content: String,
22}
23
24#[async_trait]
27pub trait SearchProvider: Send + Sync {
28 async fn search(&self, query: &str, count: usize) -> Result<Vec<SearchResult>>;
29}
30
31#[async_trait]
35pub trait FetchProvider: Send + Sync {
36 async fn fetch(&self, url: &str) -> Result<WebFetchResult>;
37}
38
39#[derive(Debug, Deserialize)]
41struct OllamaSearchResponse {
42 results: Vec<OllamaSearchResult>,
43}
44
45#[derive(Debug, Deserialize)]
46struct OllamaSearchResult {
47 title: String,
48 url: String,
49 content: String,
50}
51
52#[derive(Debug, Deserialize)]
54struct OllamaFetchResponse {
55 title: Option<String>,
56 content: Option<String>,
57}
58
59#[derive(Debug, Deserialize)]
62struct SearxngResponse {
63 #[serde(default)]
64 results: Vec<SearxngResult>,
65}
66
67#[derive(Debug, Deserialize)]
68struct SearxngResult {
69 #[serde(default)]
70 title: String,
71 url: String,
72 #[serde(default)]
73 content: String,
74}
75
76const OLLAMA_API_BASE: &str = "https://ollama.com/api";
77
78const NATIVE_FETCH_UA: &str =
81 "Mozilla/5.0 (compatible; MermaidBot/1.0; +https://github.com/noahsabaj/mermaid-cli)";
82
83#[derive(Debug)]
87struct HttpStatusError {
88 status: u16,
89}
90
91impl std::fmt::Display for HttpStatusError {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 write!(f, "HTTP {}", self.status)
94 }
95}
96
97impl std::error::Error for HttpStatusError {}
98
99fn web_error_is_retryable(e: &anyhow::Error) -> bool {
104 if let Some(re) = e.downcast_ref::<reqwest::Error>() {
105 return re.is_timeout() || re.is_connect();
106 }
107 if let Some(h) = e.downcast_ref::<HttpStatusError>() {
108 return h.status == 429 || (500..600).contains(&h.status);
109 }
110 false
111}
112
113#[derive(Clone)]
116pub struct OllamaWebClient {
117 client: Client,
118 api_key: String,
119}
120
121impl OllamaWebClient {
122 pub fn new(api_key: String) -> Self {
123 Self {
124 client: Client::new(),
125 api_key,
126 }
127 }
128
129 async fn search_impl(&self, query: &str, count: usize) -> Result<Vec<SearchResult>> {
135 if count == 0 || count > 10 {
136 return Err(anyhow!(
137 "Result count must be between 1 and 10, got {}",
138 count
139 ));
140 }
141
142 let retry_config = RetryConfig {
143 max_attempts: 3,
144 initial_delay_ms: 500,
145 max_delay_ms: 5000,
146 backoff_multiplier: 2.0,
147 };
148
149 let client = self.client.clone();
150 let api_key = self.api_key.clone();
151 let query_owned = query.to_string();
152 let ollama_response: OllamaSearchResponse = retry_async_if(
154 || {
155 let client = client.clone();
156 let api_key = api_key.clone();
157 let query = query_owned.clone();
158 async move {
159 let response = client
160 .post(format!("{}/web_search", OLLAMA_API_BASE))
161 .header("Authorization", format!("Bearer {}", api_key))
162 .json(&serde_json::json!({
163 "query": query,
164 "max_results": count,
165 }))
166 .timeout(Duration::from_secs(30))
167 .send()
168 .await
169 .map_err(|e| {
170 anyhow::Error::new(e).context("Failed to reach Ollama web search API")
171 })?;
172
173 if !response.status().is_success() {
174 let status = response.status();
175 let body = response.text().await.unwrap_or_default();
176 return Err(anyhow::Error::new(HttpStatusError {
177 status: status.as_u16(),
178 })
179 .context(format!(
180 "Ollama web search API returned error {}: {}",
181 status, body
182 )));
183 }
184
185 let body =
186 read_body_capped(response, crate::constants::MAX_WEB_BODY_BYTES).await?;
187 serde_json::from_slice::<OllamaSearchResponse>(&body)
188 .map_err(|e| anyhow!("Failed to parse Ollama search response: {}", e))
189 }
190 },
191 &retry_config,
192 web_error_is_retryable,
193 )
194 .await?;
195
196 let search_results = map_search_results(
197 ollama_response
198 .results
199 .into_iter()
200 .map(|r| (r.title, r.url, r.content)),
201 count,
202 );
203
204 Ok(search_results)
206 }
207
208 async fn fetch_impl(&self, url: &str) -> Result<WebFetchResult> {
210 let retry_config = RetryConfig {
211 max_attempts: 2,
212 initial_delay_ms: 200,
213 max_delay_ms: 2000,
214 backoff_multiplier: 2.0,
215 };
216
217 let client = self.client.clone();
218 let api_key = self.api_key.clone();
219 let url_owned = url.to_string();
220 let response: OllamaFetchResponse = retry_async_if(
221 || {
222 let client = client.clone();
223 let api_key = api_key.clone();
224 let url = url_owned.clone();
225 async move {
226 let response = client
227 .post(format!("{}/web_fetch", OLLAMA_API_BASE))
228 .header("Authorization", format!("Bearer {}", api_key))
229 .json(&serde_json::json!({ "url": url }))
230 .timeout(Duration::from_secs(15))
231 .send()
232 .await
233 .map_err(|e| {
234 anyhow::Error::new(e).context(format!("Failed to fetch {}", url))
235 })?;
236
237 if !response.status().is_success() {
238 let status = response.status();
239 return Err(anyhow::Error::new(HttpStatusError {
240 status: status.as_u16(),
241 })
242 .context(format!("Failed to fetch {}", url)));
243 }
244
245 let body =
246 read_body_capped(response, crate::constants::MAX_WEB_BODY_BYTES).await?;
247 serde_json::from_slice::<OllamaFetchResponse>(&body)
248 .map_err(|e| anyhow!("Failed to parse fetch response: {}", e))
249 }
250 },
251 &retry_config,
252 web_error_is_retryable,
253 )
254 .await?;
255
256 Ok(WebFetchResult {
257 title: response.title.unwrap_or_default(),
258 content: response.content.unwrap_or_default(),
259 })
260 }
261}
262
263#[async_trait]
264impl SearchProvider for OllamaWebClient {
265 async fn search(&self, query: &str, count: usize) -> Result<Vec<SearchResult>> {
266 self.search_impl(query, count).await
267 }
268}
269
270#[async_trait]
271impl FetchProvider for OllamaWebClient {
272 async fn fetch(&self, url: &str) -> Result<WebFetchResult> {
273 self.fetch_impl(url).await
274 }
275}
276
277#[derive(Clone)]
281pub struct SearxngClient {
282 client: Client,
283 base_url: String,
284}
285
286impl SearxngClient {
287 pub fn new(base_url: String) -> Self {
288 Self {
289 client: Client::new(),
290 base_url: base_url.trim_end_matches('/').to_string(),
291 }
292 }
293}
294
295#[async_trait]
296impl SearchProvider for SearxngClient {
297 async fn search(&self, query: &str, count: usize) -> Result<Vec<SearchResult>> {
298 let request_url = reqwest::Url::parse_with_params(
299 &format!("{}/search", self.base_url),
300 &[("q", query), ("format", "json")],
301 )
302 .map_err(|e| anyhow!("invalid SearXNG URL {}: {e}", self.base_url))?;
303
304 let response = self
305 .client
306 .get(request_url)
307 .timeout(Duration::from_secs(30))
308 .send()
309 .await
310 .map_err(|e| {
311 anyhow::Error::new(e).context(format!(
312 "Failed to reach SearXNG at {} — is it running?",
313 self.base_url
314 ))
315 })?;
316
317 if !response.status().is_success() {
318 let status = response.status();
319 return Err(anyhow!(
320 "SearXNG at {} returned {status}. A 403 usually means the JSON format is \
321 disabled — add `json` to `search.formats` in its settings.yml.",
322 self.base_url
323 ));
324 }
325
326 let body = read_body_capped(response, crate::constants::MAX_WEB_BODY_BYTES).await?;
327 let parsed: SearxngResponse = serde_json::from_slice(&body).map_err(|e| {
328 anyhow!("Failed to parse SearXNG response (is `format=json` enabled?): {e}")
329 })?;
330
331 let results = map_search_results(
332 parsed
333 .results
334 .into_iter()
335 .map(|r| (r.title, r.url, r.content)),
336 count,
337 );
338
339 Ok(results)
342 }
343}
344
345pub struct ManagedSearxngBackend;
350
351#[async_trait]
352impl SearchProvider for ManagedSearxngBackend {
353 async fn search(&self, query: &str, count: usize) -> Result<Vec<SearchResult>> {
354 let base_url = crate::searxng::manager().ensure_running().await?;
355 SearxngClient::new(base_url).search(query, count).await
356 }
357}
358
359pub struct NativeFetchClient {
365 client: Client,
366}
367
368impl Default for NativeFetchClient {
369 fn default() -> Self {
370 Self::new()
371 }
372}
373
374impl NativeFetchClient {
375 pub fn new() -> Self {
376 let client = Client::builder()
377 .user_agent(NATIVE_FETCH_UA)
378 .timeout(Duration::from_secs(20))
379 .redirect(reqwest::redirect::Policy::limited(5))
380 .build()
381 .unwrap_or_else(|_| Client::new());
382 Self { client }
383 }
384}
385
386#[async_trait]
387impl FetchProvider for NativeFetchClient {
388 async fn fetch(&self, url: &str) -> Result<WebFetchResult> {
389 guard_resolved_ips(url).await?;
393
394 let response = self
395 .client
396 .get(url)
397 .send()
398 .await
399 .map_err(|e| anyhow::Error::new(e).context(format!("Failed to fetch {url}")))?;
400
401 if !response.status().is_success() {
402 let status = response.status();
403 return Err(anyhow::Error::new(HttpStatusError {
404 status: status.as_u16(),
405 })
406 .context(format!("Failed to fetch {url}")));
407 }
408
409 let content_type = response
410 .headers()
411 .get(reqwest::header::CONTENT_TYPE)
412 .and_then(|v| v.to_str().ok())
413 .unwrap_or("")
414 .to_ascii_lowercase();
415
416 let body = read_body_capped(response, crate::constants::MAX_WEB_BODY_BYTES).await?;
417
418 let looks_textual = content_type.is_empty()
422 || content_type.contains("html")
423 || content_type.contains("xml")
424 || content_type.contains("json")
425 || content_type.starts_with("text/");
426 if !looks_textual {
427 return Err(anyhow!(
428 "web_fetch: unsupported content-type '{content_type}' (only html/text pages)"
429 ));
430 }
431
432 let html = String::from_utf8_lossy(&body).into_owned();
433 let url_owned = url.to_string();
434 let (title, content) =
437 tokio::task::spawn_blocking(move || extract_readable(&html, &url_owned))
438 .await
439 .map_err(|e| anyhow!("content extraction failed: {e}"))?;
440
441 Ok(WebFetchResult { title, content })
442 }
443}
444
445async fn guard_resolved_ips(url: &str) -> Result<()> {
450 let parsed = reqwest::Url::parse(url).map_err(|e| anyhow!("invalid URL: {e}"))?;
451 let host = parsed
452 .host_str()
453 .ok_or_else(|| anyhow!("URL has no host"))?;
454 let port = parsed.port_or_known_default().unwrap_or(80);
455 let addrs = tokio::net::lookup_host((host, port))
456 .await
457 .map_err(|e| anyhow!("DNS resolution failed for {host}: {e}"))?;
458 for addr in addrs {
459 if classify_host(&addr.ip().to_string()).is_internal() {
460 return Err(anyhow!(
461 "refusing to fetch '{host}' — it resolves to an internal address"
462 ));
463 }
464 }
465 Ok(())
466}
467
468fn extract_readable(html: &str, url: &str) -> (String, String) {
473 use dom_smoothie::Readability;
474
475 if let Ok(mut readability) = Readability::new(html, Some(url), None)
476 && let Ok(article) = readability.parse()
477 {
478 let content_html = article.content.to_string();
479 let markdown = htmd::convert(&content_html).unwrap_or_default();
480 let markdown = markdown.trim();
481 if !markdown.is_empty() {
485 let title = if article.title.trim().is_empty() {
486 fallback_title(html)
487 } else {
488 article.title
489 };
490 return (title, markdown.to_string());
491 }
492 }
493
494 let markdown = htmd::convert(html).unwrap_or_default();
495 (fallback_title(html), markdown.trim().to_string())
496}
497
498fn fallback_title(html: &str) -> String {
501 let lower = html.to_ascii_lowercase();
502 let Some(open) = lower.find("<title") else {
503 return String::new();
504 };
505 let after_tag = match html[open..].find('>') {
506 Some(gt) => &html[open + gt + 1..],
507 None => return String::new(),
508 };
509 match after_tag.to_ascii_lowercase().find("</title>") {
510 Some(end) => after_tag[..end].trim().to_string(),
511 None => String::new(),
512 }
513}
514
515fn map_search_results(
518 hits: impl Iterator<Item = (String, String, String)>,
519 count: usize,
520) -> Vec<SearchResult> {
521 hits.take(count)
522 .map(|(title, url, content)| {
523 let full_content = truncate_content(&content, crate::constants::WEB_CONTENT_MAX_CHARS);
524 let snippet = content.chars().take(200).collect();
525 SearchResult {
526 title,
527 url,
528 snippet,
529 full_content,
530 }
531 })
532 .collect()
533}
534
535pub fn format_results(results: &[SearchResult]) -> String {
540 let mut formatted = String::from("[SEARCH_RESULTS]\n");
541
542 for (i, result) in results.iter().enumerate() {
543 formatted.push_str(&format!(
544 "[{}] Title: {}\nURL: {}\nContent:\n{}\n---\n",
545 i + 1,
546 result.title,
547 result.url,
548 result.full_content
549 ));
550 }
551
552 formatted.push_str("[/SEARCH_RESULTS]\n\n");
553
554 formatted.push_str("Sources:\n");
556 for (i, result) in results.iter().enumerate() {
557 formatted.push_str(&format!("{}. {} - {}\n", i + 1, result.title, result.url));
558 }
559
560 formatted
561}
562
563async fn read_body_capped(response: reqwest::Response, max_bytes: usize) -> Result<Vec<u8>> {
570 use futures::StreamExt;
571 if let Some(len) = response.content_length()
572 && len as usize > max_bytes
573 {
574 return Err(anyhow!(
575 "response body too large: {len} bytes exceeds {max_bytes} cap"
576 ));
577 }
578 let mut stream = response.bytes_stream();
579 let mut buf = Vec::new();
580 while let Some(chunk) = stream.next().await {
581 let chunk = chunk.map_err(|e| anyhow!("error reading response body: {e}"))?;
582 if buf.len() + chunk.len() > max_bytes {
583 return Err(anyhow!("response body exceeded {max_bytes} byte cap"));
584 }
585 buf.extend_from_slice(&chunk);
586 }
587 Ok(buf)
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 #[test]
595 fn test_ollama_web_client_creation() {
596 let client = OllamaWebClient::new("test-key".to_string());
597 assert_eq!(client.api_key, "test-key");
598 }
599
600 #[test]
601 fn test_format_results() {
602 let results = vec![SearchResult {
603 title: "Test Article".to_string(),
604 url: "https://example.com".to_string(),
605 snippet: "This is a test".to_string(),
606 full_content: "Full content here".to_string(),
607 }];
608
609 let formatted = format_results(&results);
610 assert!(formatted.contains("[SEARCH_RESULTS]"));
611 assert!(formatted.contains("Test Article"));
612 assert!(formatted.contains("https://example.com"));
613 assert!(formatted.contains("[/SEARCH_RESULTS]"));
614 }
615
616 #[test]
617 fn map_search_results_truncates_and_caps_count() {
618 let hits = (0..5).map(|i| {
619 (
620 format!("t{i}"),
621 format!("https://e{i}.com"),
622 "x".repeat(crate::constants::WEB_CONTENT_MAX_CHARS * 2),
623 )
624 });
625 let out = map_search_results(hits, 3);
626 assert_eq!(out.len(), 3, "count cap applied");
627 assert!(
628 out[0].full_content.len() <= crate::constants::WEB_CONTENT_MAX_CHARS + 64,
629 "content truncated"
630 );
631 assert!(out[0].snippet.chars().count() <= 200);
632 }
633
634 #[test]
635 fn searxng_response_parses_results() {
636 let json = serde_json::json!({
637 "results": [
638 {"title": "A", "url": "https://a.com", "content": "alpha"},
639 {"url": "https://b.com"},
640 ]
641 })
642 .to_string();
643 let parsed: SearxngResponse = serde_json::from_str(&json).unwrap();
644 assert_eq!(parsed.results.len(), 2);
645 assert_eq!(parsed.results[0].url, "https://a.com");
646 assert_eq!(parsed.results[1].title, "");
648 assert_eq!(parsed.results[1].content, "");
649 }
650
651 #[test]
652 fn extract_readable_produces_markdown() {
653 let html = r#"<html><head><title>My Page</title></head>
654 <body><article><h1>Heading</h1><p>Hello <a href="https://x.com">link</a>.</p>
655 <p>More text to satisfy the readability length heuristic so this block is
656 treated as the main article content rather than boilerplate chrome.</p>
657 </article></body></html>"#;
658 let (title, md) = extract_readable(html, "https://example.com/page");
659 assert!(!title.is_empty(), "title extracted, got {title:?}");
660 assert!(
661 md.contains("Hello"),
662 "content converted to markdown: {md:?}"
663 );
664 assert!(md.contains("](https://x.com)"), "links preserved: {md:?}");
665 }
666
667 #[test]
668 fn extract_readable_fallback_title_on_unparseable() {
669 let html = "<title>Bare</title><p>just a snippet</p>";
671 let (title, md) = extract_readable(html, "https://example.com");
672 assert_eq!(title, "Bare");
673 assert!(md.contains("just a snippet"));
674 }
675
676 #[test]
677 fn web_error_is_retryable_classifies_status() {
678 assert!(web_error_is_retryable(&anyhow::Error::new(
680 HttpStatusError { status: 500 }
681 )));
682 assert!(web_error_is_retryable(&anyhow::Error::new(
683 HttpStatusError { status: 429 }
684 )));
685 assert!(!web_error_is_retryable(&anyhow::Error::new(
686 HttpStatusError { status: 404 }
687 )));
688 assert!(!web_error_is_retryable(&anyhow::Error::new(
689 HttpStatusError { status: 401 }
690 )));
691 assert!(!web_error_is_retryable(&anyhow!("parse failed")));
692 let wrapped = anyhow::Error::new(HttpStatusError { status: 503 }).context("upstream");
695 assert!(web_error_is_retryable(&wrapped));
696 }
697}