1pub mod engine;
22pub mod fetch;
23pub mod format;
24pub mod http;
25pub mod mcp;
26pub mod polite;
27pub mod request;
28pub mod text;
29pub mod vertical;
30
31use std::sync::Arc;
32use std::time::Duration;
33
34use engine::{Engine, Hit};
35use polite::{Cache, CircuitBreaker, RateLimiter};
36pub use request::{RequestError, SafeSearch, SearchRequest, TimeLimit};
37
38pub fn user_agent() -> &'static str {
46 static AGENT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
47 AGENT.get_or_init(|| agent_for(std::env::var("RATATOSKR_CONTACT").ok().as_deref()))
48}
49
50fn agent_for(contact: Option<&str>) -> String {
51 let base = concat!("Mozilla/5.0 (compatible; Ratatoskr/", env!("CARGO_PKG_VERSION"));
52 match contact.map(str::trim).filter(|c| !c.is_empty()) {
53 Some(contact) => format!("{base}; +{contact})"),
54 None => format!("{base})"),
55 }
56}
57
58#[derive(Clone, Debug, PartialEq)]
60pub struct SearchOutcome {
61 pub hits: Vec<Hit>,
62 pub engine: &'static str,
64 pub sources: Vec<&'static str>,
67 pub merged: usize,
69 pub from_cache: bool,
70}
71
72impl SearchOutcome {
73 pub fn provenance(&self) -> String {
77 let mut parts = Vec::new();
78 if self.sources.as_slice() != [self.engine] && !self.sources.is_empty() {
79 parts.push(self.sources.join(", "));
80 }
81 match self.merged {
82 0 => {}
83 1 => parts.push("1 duplicate merged".into()),
84 n => parts.push(format!("{n} duplicates merged")),
85 }
86 parts.push(format!("cached: {}", self.from_cache));
87 format!("{} ({})", self.engine, parts.join("; "))
88 }
89}
90
91#[derive(Clone, Debug, PartialEq)]
93pub struct SearchFailure {
94 pub attempts: Vec<(&'static str, String)>,
96}
97
98impl std::fmt::Display for SearchFailure {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 let parts: Vec<String> = self.attempts.iter().map(|(e, why)| format!("{e}: {why}")).collect();
101 write!(f, "search unavailable ({})", parts.join("; "))
102 }
103}
104
105struct Slot {
106 engine: Arc<dyn Engine>,
107 limiter: RateLimiter,
108 breaker: CircuitBreaker,
109}
110
111#[derive(Clone, Debug)]
113pub struct Settings {
114 pub min_interval: Duration,
116 pub cooldown: Duration,
118 pub cache_ttl: Duration,
120 pub cache_entries: usize,
121 pub timeout: Duration,
122}
123
124impl Default for Settings {
125 fn default() -> Self {
126 Self {
127 min_interval: Duration::from_secs(2),
128 cooldown: Duration::from_secs(600),
129 cache_ttl: Duration::from_secs(900),
130 cache_entries: 256,
131 timeout: Duration::from_secs(15),
132 }
133 }
134}
135
136#[derive(Clone, Debug, Default)]
139pub struct Sources {
140 pub search_api_url: Option<String>,
142 pub brave_key: Option<String>,
143 pub google_key: Option<String>,
144 pub google_cx: Option<String>,
146}
147
148impl Sources {
149 pub fn from_env() -> Self {
151 let var = |key: &str| std::env::var(key).ok().filter(|v| !v.trim().is_empty());
152 Self {
153 search_api_url: var("SEARCH_API_URL"),
154 brave_key: var("BRAVE_SEARCH_API_KEY"),
155 google_key: var("GOOGLE_CSE_KEY"),
156 google_cx: var("GOOGLE_CSE_ID"),
157 }
158 }
159}
160
161#[derive(Clone, Debug, PartialEq, Eq)]
163pub struct Found<T> {
164 pub results: Vec<T>,
165 pub sources: Vec<&'static str>,
167 pub from_cache: bool,
168}
169
170struct Kind<T> {
172 pool: vertical::Pool<T>,
173 cache: Cache<(Vec<T>, Vec<&'static str>)>,
174}
175
176impl<T: vertical::Keyed + Clone + Send + 'static> Kind<T> {
177 fn new(sources: Vec<Arc<dyn vertical::Source<T>>>, settings: &Settings) -> Self {
178 Self {
179 pool: vertical::Pool::new(sources, settings.cooldown, settings.min_interval),
180 cache: Cache::new(settings.cache_ttl, settings.cache_entries),
181 }
182 }
183
184 async fn search(&self, label: &'static str, request: &SearchRequest) -> Result<Found<T>, SearchFailure> {
185 let key = request.cache_key(label);
186 if let Some((_, (results, sources))) = self.cache.get(&key) {
187 return Ok(Found { results, sources, from_cache: true });
188 }
189 match self.pool.search(request).await {
190 Ok((results, sources)) if !results.is_empty() => {
191 self.cache.put(key, label, (results.clone(), sources.clone()));
192 Ok(Found { results, sources, from_cache: false })
193 }
194 Ok(_) => Err(SearchFailure { attempts: vec![(label, "no results".into())] }),
195 Err(error) => Err(SearchFailure { attempts: vec![(label, error.to_string())] }),
196 }
197 }
198}
199
200pub struct Verticals {
202 pub news: Vec<Arc<dyn vertical::Source<vertical::NewsResult>>>,
203 pub images: Vec<Arc<dyn vertical::Source<vertical::ImageResult>>>,
204 pub videos: Vec<Arc<dyn vertical::Source<vertical::VideoResult>>>,
205 pub books: Vec<Arc<dyn vertical::Source<vertical::BookResult>>>,
206}
207
208impl Verticals {
209 pub fn none() -> Self {
211 Self { news: Vec::new(), images: Vec::new(), videos: Vec::new(), books: Vec::new() }
212 }
213
214 pub fn standard(settings: &Settings) -> Self {
221 use vertical::{bing, duckduckgo, openlibrary, yahoo};
222 let t = settings.timeout;
223 Self {
224 news: vec![
225 Arc::new(duckduckgo::DuckDuckGo::new(t).news()),
226 Arc::new(bing::Bing::new(t).news()),
227 Arc::new(yahoo::News(yahoo::Yahoo::new(t))),
228 ],
229 images: vec![Arc::new(bing::Bing::new(t).images()), Arc::new(duckduckgo::DuckDuckGo::new(t).images())],
230 videos: vec![Arc::new(duckduckgo::DuckDuckGo::new(t).videos())],
231 books: vec![Arc::new(openlibrary::OpenLibrary::new(engine::http_client(t)))],
232 }
233 }
234}
235
236pub struct Ratatoskr {
237 slots: Vec<Slot>,
238 cache: Cache<engine::Report>,
240 fetcher: fetch::Fetcher,
241 news: Kind<vertical::NewsResult>,
242 images: Kind<vertical::ImageResult>,
243 videos: Kind<vertical::VideoResult>,
244 books: Kind<vertical::BookResult>,
245}
246
247impl Ratatoskr {
248 pub fn new(engines: Vec<Arc<dyn Engine>>, settings: Settings) -> Self {
250 let slots = engines
251 .into_iter()
252 .map(|engine| Slot {
253 engine,
254 limiter: RateLimiter::new(settings.min_interval),
255 breaker: CircuitBreaker::new(settings.cooldown),
256 })
257 .collect();
258 let none = Verticals::none();
259 Self {
260 slots,
261 cache: Cache::new(settings.cache_ttl, settings.cache_entries),
262 fetcher: fetch::Fetcher::new(settings.timeout),
263 news: Kind::new(none.news, &settings),
264 images: Kind::new(none.images, &settings),
265 videos: Kind::new(none.videos, &settings),
266 books: Kind::new(none.books, &settings),
267 }
268 }
269
270 pub fn with_verticals(mut self, verticals: Verticals, settings: &Settings) -> Self {
272 self.news = Kind::new(verticals.news, settings);
273 self.images = Kind::new(verticals.images, settings);
274 self.videos = Kind::new(verticals.videos, settings);
275 self.books = Kind::new(verticals.books, settings);
276 self
277 }
278
279 pub async fn news(&self, request: &SearchRequest) -> Result<Found<vertical::NewsResult>, SearchFailure> {
281 self.news.search("news", request).await
282 }
283
284 pub async fn images(&self, request: &SearchRequest) -> Result<Found<vertical::ImageResult>, SearchFailure> {
286 self.images.search("images", request).await
287 }
288
289 pub async fn videos(&self, request: &SearchRequest) -> Result<Found<vertical::VideoResult>, SearchFailure> {
291 self.videos.search("videos", request).await
292 }
293
294 pub async fn books(&self, request: &SearchRequest) -> Result<Found<vertical::BookResult>, SearchFailure> {
296 self.books.search("books", request).await
297 }
298
299 pub fn with_fetcher(mut self, fetcher: fetch::Fetcher) -> Self {
301 self.fetcher = fetcher;
302 self
303 }
304
305 pub fn standard(sources: &Sources) -> Self {
307 let settings = Settings::default();
308 let http = engine::http_client(settings.timeout);
309 use engine::meta::Member;
315 let meta = vec![
316 Member::encyclopedia(Arc::new(engine::wikipedia::Wikipedia::best_page(http.clone(), "en"))),
317 Member::encyclopedia(Arc::new(engine::grokipedia::Grokipedia::new(http.clone()))),
318 Member::web(Arc::new(engine::yahoo::Yahoo::new(settings.timeout)), "bing"),
319 Member::web(Arc::new(engine::duckduckgo::DuckDuckGo::new(settings.timeout)), "bing"),
320 Member::web(Arc::new(engine::bing::Bing::new(settings.timeout)), "bing"),
321 Member::web(Arc::new(engine::yandex::Yandex::new(settings.timeout)), "yandex"),
322 ];
323 let mut engines: Vec<Arc<dyn Engine>> =
324 vec![Arc::new(engine::meta::Meta::new(meta, settings.cooldown, settings.min_interval))];
325 if let Some(base) = sources.search_api_url.as_deref().filter(|u| !u.trim().is_empty()) {
326 engines.push(Arc::new(engine::search_api::SearchApi::new(http.clone(), base.trim())));
327 }
328 if let Some(key) = sources.brave_key.as_deref().filter(|k| !k.trim().is_empty()) {
329 engines.push(Arc::new(engine::brave::Brave::new(http.clone(), key.trim())));
330 }
331 if let (Some(key), Some(cx)) = (sources.google_key.as_deref(), sources.google_cx.as_deref())
332 && !key.trim().is_empty()
333 && !cx.trim().is_empty()
334 {
335 engines.push(Arc::new(engine::google::Google::new(http.clone(), key.trim(), cx.trim())));
336 }
337 engines.push(Arc::new(engine::wikipedia::Wikipedia::new(http, "en")));
338 Self::new(engines, settings.clone()).with_verticals(Verticals::standard(&settings), &settings)
339 }
340
341 pub async fn search(&self, query: &str, limit: usize) -> Result<SearchOutcome, SearchFailure> {
345 self.search_in(query, limit, None).await
346 }
347
348 pub async fn search_in(
350 &self,
351 query: &str,
352 limit: usize,
353 country: Option<&str>,
354 ) -> Result<SearchOutcome, SearchFailure> {
355 self.search_with(&SearchRequest::new(query, limit).country(country)).await
356 }
357
358 pub async fn search_with(&self, request: &SearchRequest) -> Result<SearchOutcome, SearchFailure> {
361 let key = request.cache_key("text");
362 if let Some((engine, report)) = self.cache.get(&key) {
363 let engine::Report { hits, sources, merged } = report;
364 return Ok(SearchOutcome { hits, engine, sources, merged, from_cache: true });
365 }
366 let mut attempts = Vec::new();
367 for slot in &self.slots {
368 let name = slot.engine.name();
369 if let Some(left) = slot.breaker.open_for() {
370 attempts.push((name, format!("cooling down for {}s", left.as_secs())));
371 continue;
372 }
373 if !slot.engine.paces_itself() {
374 slot.limiter.wait_turn().await;
375 }
376 match slot.engine.search_report(request).await {
377 Ok(report) if !report.hits.is_empty() => {
378 slot.breaker.record_success();
379 self.cache.put(key, name, report.clone());
380 let engine::Report { hits, sources, merged } = report;
381 return Ok(SearchOutcome { hits, engine: name, sources, merged, from_cache: false });
382 }
383 Ok(_) => attempts.push((name, "no results".into())),
384 Err(error) => {
385 if error.is_refusal() {
386 slot.breaker.trip();
387 tracing::warn!("ratatoskr: {name} refused ({error}); skipping it for a while");
388 }
389 attempts.push((name, error.to_string()));
390 }
391 }
392 }
393 Err(SearchFailure { attempts })
394 }
395
396 pub async fn fetch(&self, url: &str) -> Result<fetch::Page, fetch::FetchError> {
398 self.fetcher.fetch(url).await
399 }
400
401 pub fn status(&self) -> Vec<(&'static str, Option<Duration>)> {
403 self.slots.iter().map(|s| (s.engine.name(), s.breaker.open_for())).collect()
404 }
405
406 pub fn vertical_status(&self) -> Vec<(&'static str, &'static str, Option<Duration>)> {
409 let tag = |kind, list: Vec<(&'static str, Option<Duration>)>| list.into_iter().map(move |(n, r)| (kind, n, r));
410 tag("news", self.news.pool.status())
411 .chain(tag("images", self.images.pool.status()))
412 .chain(tag("videos", self.videos.pool.status()))
413 .chain(tag("books", self.books.pool.status()))
414 .collect()
415 }
416}
417
418#[cfg(test)]
419mod outcome_tests {
420 use super::SearchOutcome;
421
422 #[test]
423 fn provenance_names_the_engines_and_what_was_merged() {
424 let outcome = |engine, sources: &[&'static str], merged| SearchOutcome {
425 hits: Vec::new(),
426 engine,
427 sources: sources.to_vec(),
428 merged,
429 from_cache: false,
430 };
431 assert_eq!(
432 outcome("meta", &["bing", "yandex", "wikipedia"], 3).provenance(),
433 "meta (bing, yandex, wikipedia; 3 duplicates merged; cached: false)"
434 );
435 assert_eq!(
436 outcome("meta", &["bing", "yandex"], 1).provenance(),
437 "meta (bing, yandex; 1 duplicate merged; cached: false)"
438 );
439 assert_eq!(
440 outcome("wikipedia", &["wikipedia"], 0).provenance(),
441 "wikipedia (cached: false)",
442 "one engine: itself"
443 );
444 }
445}
446
447#[cfg(test)]
448mod agent_tests {
449 use super::agent_for;
450
451 #[test]
452 fn the_contact_is_optional_and_never_built_in() {
453 let bare = agent_for(None);
454 assert!(bare.starts_with("Mozilla/5.0 (compatible; Ratatoskr/") && bare.ends_with(')'));
455 assert!(!bare.contains("+"));
456 assert_eq!(agent_for(Some(" ")), bare);
457 assert!(agent_for(Some("https://example.org/bot")).ends_with("; +https://example.org/bot)"));
458 }
459}