Commit
Message
Changed Files (8)
-
modified CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md index a4cf351..4b9245f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added +- Progressive search via SSE (`/api/search/stream`): local bookmark results appear instantly, external engine results stream in as they arrive, using `EventSource` with zero external dependencies. +- `?js=true` query parameter for the `/search` handler: skips external engines during page render (~50ms instead of ~1700ms). JS automatically inserts this into the URL via `history.replaceState`/`pushState`, enabling fast loading on all subsequent interactions. +- Integration tests for the SSE endpoint covering content-type, bookmark events, done events, empty queries, no-match queries, and bang redirects. + ## [0.5.2] - 2026-06-24 ### Fixed -
modified Cargo.lock
diff --git a/Cargo.lock b/Cargo.lock index 1884c2e..38f71c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1353,6 +1353,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1360,6 +1375,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1368,6 +1384,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -1403,6 +1430,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -3028,7 +3056,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.4", "thiserror", "tokio", "tracing", @@ -3065,9 +3093,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.4", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3539,6 +3567,7 @@ dependencies = [ "directories", "fastembed", "figment", + "futures", "htmd", "indicatif 0.17.11", "reqwest", -
modified Cargo.toml
diff --git a/Cargo.toml b/Cargo.toml index 0f8462b..bff7293 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ tempfile = "3" tera = "1.8" toml = "0.8" tokio = { version = "1.0", features = ["full"] } +futures = "0.3" tracing = "0.1" tracing-subscriber = "0.3" url = "2" -
modified README.md
diff --git a/README.md b/README.md index af93e65..19542e9 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,17 @@ Open http://127.0.0.1:8080 in your browser. You can now search your bookmarks. Search queries are also forwarded to external engines: Wikipedia, [crates.io](https://crates.io) via its public JSON API, and optionally [SearXNG](https://searx.space) (which aggregates Google, Bing, DDG, and dozens more) if `[[engines]]` is configured. Works as a custom search provider in Firefox/Zen via the OpenSearch protocol (your browser should auto-discover it at `/opensearch.xml`). +When JavaScript is available, search results are progressively loaded: local bookmark results appear instantly, then external engine results stream in as each engine responds. + +The server supports a `?js=true` query parameter: when present, it skips external engines during page render (returning only local results in ~50ms) and lets the client fetch them via a streaming connection. The inline JavaScript automatically adds `?js=true` to the URL, so the fast path is active by default once JS runs. + +For the fastest initial page load from your browser's search bar (before any JS runs), configure your browser's search engine URL with `?js=true` appended: + +- **Firefox:** Settings → Search → Manage Search Engines → Add, use `http://localhost:8080/search?q=%s&js=true` +- **OpenSearch template:** `http://localhost:8080/search?q={searchTerms}&js=true` + +Without `?js=true`, the server waits for all external engines before sending the page — a fallback path for browsers without JavaScript (which still works, just slower on first load). + ## CLI reference | Command | What it does | -
modified src/web/handlers.rs
diff --git a/src/web/handlers.rs b/src/web/handlers.rs index 31a33f7..66f5559 100644 --- a/src/web/handlers.rs +++ b/src/web/handlers.rs @@ -2,12 +2,16 @@ use crate::config::{EngineConfig, Shortcut}; use crate::search_engines::utils::urlencode; use crate::search_engines::{EngineError, ResultEntry, SearchEngine}; use crate::storage; +use actix_web::web::Bytes; use actix_web::{get, http::header::LOCATION, web, HttpRequest, HttpResponse, Responder}; +use futures::Stream; use serde::Serialize; use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, RwLock}; use std::time::Instant; use tera::Tera; +use tokio::sync::mpsc; use tracing::{error, info}; use super::{ @@ -223,8 +227,10 @@ async fn search( .and_then(|v| v.to_str().ok()) .unwrap_or(USER_AGENT); - let snapshot = engines.read().unwrap().clone(); - let (all_external, provider_count) = if has_query { + let js_mode = query.js.unwrap_or(false); + + let (all_external, provider_count) = if has_query && !js_mode { + let snapshot = engines.read().unwrap().clone(); run_external_engines(&q, user_agent, &snapshot, engine_filter).await } else { (Vec::new(), 0) @@ -379,3 +385,234 @@ async fn api_search( HttpResponse::Ok().json(response) } + +#[derive(Serialize)] +struct SseBookmarkData { + results: Vec<SearchApiResult>, + total: usize, + page: usize, + page_size: usize, + total_pages: usize, +} + +#[derive(Serialize)] +struct SseExternalData { + engine: String, + results: Vec<SearchApiResult>, +} + +fn sse_event(event_type: &str, data: &str) -> Bytes { + Bytes::from(format!("event: {event_type}\ndata: {data}\n\n")) +} + +fn sse_json_event<T: serde::Serialize>(event_type: &str, payload: &T) -> Bytes { + let data = serde_json::to_string(payload).unwrap_or_else(|_| "{}".into()); + sse_event(event_type, &data) +} + +fn stream_from_rx( + rx: mpsc::Receiver<Result<Bytes, std::io::Error>>, +) -> impl Stream<Item = Result<Bytes, std::io::Error>> { + futures::stream::unfold(rx, |mut rx| async { + rx.recv().await.map(|item| (item, rx)) + }) +} + +#[get("/api/search/stream")] +async fn search_stream( + req: HttpRequest, + query: web::Query<SearchQuery>, + db_pool: web::Data<DbPool>, + engines: web::Data<Arc<RwLock<Vec<EngineConfig>>>>, + cfg: web::Data<ServerConfig>, + shortcuts: web::Data<HashMap<String, Shortcut>>, +) -> HttpResponse { + let q_orig = query.q.as_deref().unwrap_or("").to_string(); + let page = query.page.unwrap_or(1).max(1); + let page_size = cfg.page_size; + + let (q, engine_filter) = + match parse_shortcut(&q_orig, &shortcuts) { + Some((ShortcutKind::Bang(url), rest)) => { + let redirect_url = url.replace("{}", &urlencode(rest)); + info!("sse bang redirect: {} -> {}", q_orig, redirect_url); + let payload = serde_json::json!({ + "redirect_url": redirect_url, + "query": rest, + "trigger": q_orig, + }); + return HttpResponse::Ok() + .content_type("text/event-stream") + .streaming(futures::stream::once(async move { + Ok::<_, std::io::Error>(sse_json_event("bang", &payload)) + })); + } + Some((ShortcutKind::At(idx), rest)) => { + info!("sse at-filtered search: {} -> engine[{}]", q_orig, idx); + (rest.to_string(), Some(idx)) + } + None => (q_orig.clone(), None), + }; + + let has_query = !q.is_empty(); + + if !has_query { + return HttpResponse::Ok() + .content_type("text/event-stream") + .streaming(futures::stream::once(async { + Ok::<_, std::io::Error>(sse_event("done", "{}")) + })); + } + + let (tx, rx) = mpsc::channel::<Result<Bytes, std::io::Error>>(64); + + let user_agent = req + .headers() + .get("User-Agent") + .and_then(|v| v.to_str().ok()) + .unwrap_or(USER_AGENT) + .to_string(); + + let snapshot = engines.read().unwrap().clone(); + + tokio::spawn(async move { + // 1. Bookmark results + let total_results = storage::count_search_bookmarks(&db_pool.conn(), &q).unwrap_or(0); + let total_pages = total_results.div_ceil(page_size); + + let bookmarks = + storage::search_bookmarks(&db_pool.conn(), &q, page, page_size).unwrap_or_default(); + + let api_bookmarks: Vec<SearchApiResult> = bookmarks + .into_iter() + .map(|bm| { + SearchApiResult::Bookmark(ApiBookmark { + id: bm.id, + title: bm.title, + url: bm.url, + description: bm.description, + source: bm.source, + tags: bm.tags, + created_at: bm.created_at, + }) + }) + .collect(); + + let bookmark_data = SseBookmarkData { + results: api_bookmarks, + total: total_results, + page, + page_size, + total_pages, + }; + + if tx + .send(Ok(sse_json_event("bookmarks", &bookmark_data))) + .await + .is_err() + { + return; + } + + // 2. External engines + let mut spawn_count = 0; + for i in 0..snapshot.len() { + if let Some(filter) = engine_filter { + if i != filter { + continue; + } + } + spawn_count += 1; + } + + if spawn_count > 0 { + let client = match reqwest::Client::builder() + .user_agent(&user_agent) + .build() + { + Ok(c) => c, + Err(_) => { + let _ = tx.send(Ok(sse_event("done", "{}"))).await; + return; + } + }; + + let remaining = Arc::new(AtomicUsize::new(spawn_count)); + + for i in 0..snapshot.len() { + if let Some(filter) = engine_filter { + if i != filter { + continue; + } + } + + let tx = tx.clone(); + let remaining = remaining.clone(); + let client = client.clone(); + let engine = snapshot[i].clone(); + let q = q.clone(); + + tokio::spawn(async move { + let t0 = Instant::now(); + let timeout_dur = engine.timeout(); + let result = tokio::time::timeout( + timeout_dur, + engine.fetch_results(&q, &client), + ) + .await; + let elapsed = t0.elapsed(); + + match result { + Ok(Ok(results)) => { + info!( + "external {} ({} results) [{:.2?}]", + engine.name(), + results.len(), + elapsed + ); + if !results.is_empty() { + let api_results: Vec<SearchApiResult> = results + .into_iter() + .map(|r| { + SearchApiResult::External(ApiExternal { + title: r.title, + url: r.url, + description: r.description, + engine: r.engine, + }) + }) + .collect(); + + let ext_data = SseExternalData { + engine: engine.name().to_string(), + results: api_results, + }; + let _ = tx + .send(Ok(sse_json_event("external", &ext_data))) + .await; + } + } + Ok(Err(e)) => { + info!("external {} (error) [{:.2?}]: {}", engine.name(), elapsed, e); + } + Err(_) => { + info!("external {} (timeout) [{:.2?}]", engine.name(), elapsed); + } + } + + if remaining.fetch_sub(1, Ordering::SeqCst) == 1 { + let _ = tx.send(Ok(sse_event("done", "{}"))).await; + } + }); + } + } else { + let _ = tx.send(Ok(sse_event("done", "{}"))).await; + } + }); + + let stream = stream_from_rx(rx); + + HttpResponse::Ok() + .content_type("text/event-stream") + .streaming(stream) +} -
modified src/web/mod.rs
diff --git a/src/web/mod.rs b/src/web/mod.rs index 0e5233d..a0e1ac9 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -56,6 +56,7 @@ pub struct SearchQuery { pub q: Option<String>, pub page: Option<usize>, pub page_size: Option<usize>, + pub js: Option<bool>, } #[derive(Serialize, Deserialize)] @@ -142,6 +143,7 @@ pub async fn run_server( .service(handlers::index) .service(handlers::search) .service(handlers::api_search) + .service(handlers::search_stream) .service(handlers::opensearch) }) .workers(workers); -
modified templates/index.html
diff --git a/templates/index.html b/templates/index.html index 00e8215..ddc54d3 100644 --- a/templates/index.html +++ b/templates/index.html @@ -385,6 +385,7 @@ <button type="submit">search</button> </form> + <div id="results-ssr"> {% set q = query | default(value="") %} {% if q %} <div class="results-grid"> @@ -448,6 +449,24 @@ {% else %} <div class="empty" style="margin-top:2rem">enter a query to search your bookmarks</div> {% endif %} + </div> + + <div id="results-live" style="display:none"> + <div class="results-grid"> + <div class="results-column"> + <div class="column-header">local</div> + <div class="meta" id="live-meta"></div> + <ul class="results" id="live-bookmarks"></ul> + <div class="empty" id="live-empty" style="display:none">no bookmarks found</div> + </div> + <div class="results-column" id="col-external" style="display:none"> + <div class="column-header">external</div> + <div class="engine-filters" id="live-filters"></div> + <ul class="results" id="live-external"></ul> + </div> + </div> + <div class="empty" id="live-prompt" style="display:none;margin-top:2rem">enter a query to search your bookmarks</div> + </div> <div class="footer"> <span>search_hub v{{ version }}{% if page_time_ms %} / {{ page_time_ms }}ms{% endif %}</span> @@ -471,6 +490,8 @@ {% endif %} {% endfor %} {% endif %} + <h2>browser integration</h2> + <div class="help-row"><span class="help-key">?js=true</span><span class="help-desc">add to your browser's search engine URL for faster loading</span></div> </div> </div> @@ -499,11 +520,15 @@ var selectedIndex = -1; var resultLinks = []; + function isVisible(el) { + return el.offsetParent !== null; + } + function collectResults() { resultLinks = []; var items = document.querySelectorAll('.results .bookmark'); for (var i = 0; i < items.length; i++) { - if (items[i].style.display !== 'none') { + if (isVisible(items[i])) { var link = items[i].querySelector('.bookmark-title a'); if (link) resultLinks.push({ el: items[i], link: link }); } @@ -574,25 +599,188 @@ }; document.getElementById('help-hint').onclick = toggleHelp; + function setupBadge(badge) { + var engine = badge.getAttribute('data-engine'); + badge.onclick = function() { + this.classList.toggle('active'); + var items = document.querySelectorAll( + '[data-engine="' + engine + '"]' + ); + var show = this.classList.contains('active'); + for (var j = 0; j < items.length; j++) { + items[j].style.display = show ? '' : 'none'; + } + collectResults(); + }; + } + var badges = document.querySelectorAll('.engine-badge'); for (var i = 0; i < badges.length; i++) { - (function(badge) { - var engine = badge.getAttribute('data-engine'); - badge.onclick = function() { - this.classList.toggle('active'); - var items = document.querySelectorAll( - '[data-engine="' + engine + '"]' - ); - var show = this.classList.contains('active'); - for (var j = 0; j < items.length; j++) { - items[j].style.display = show ? '' : 'none'; - } - collectResults(); - }; - })(badges[i]); + setupBadge(badges[i]); } collectResults(); + + // SSE progressive search + if (window.EventSource) { + var ssr = document.getElementById('results-ssr'); + var live = document.getElementById('results-live'); + var liveBookmarks = document.getElementById('live-bookmarks'); + var liveExternal = document.getElementById('live-external'); + var liveMeta = document.getElementById('live-meta'); + var liveFilters = document.getElementById('live-filters'); + var liveEmpty = document.getElementById('live-empty'); + var livePrompt = document.getElementById('live-prompt'); + var colExternal = document.getElementById('col-external'); + var searchForm = document.querySelector('.search-form'); + var searchInput = searchForm.querySelector('input[name="q"]'); + var liveEngines = {}; + + function showLive() { + ssr.style.display = 'none'; + live.style.display = ''; + } + + function renderBookmarkHtml(bm) { + var html = '<li class="bookmark">'; + html += '<div class="bookmark-title"><a href="' + escAttr(bm.url) + '">' + escHtml(bm.title) + '</a></div>'; + html += '<div class="bookmark-url">' + escHtml(bm.url) + '</div>'; + if (bm.description) { + html += '<div class="bookmark-desc">' + escHtml(bm.description) + '</div>'; + } + if (bm.tags) { + html += '<div class="bookmark-tags">' + escHtml(bm.tags) + '</div>'; + } + html += '</li>'; + return html; + } + + function renderExternalHtml(r) { + var html = '<li class="bookmark" data-engine="' + escAttr(r.engine) + '">'; + html += '<div class="bookmark-title"><a href="' + escAttr(r.url) + '">' + escHtml(r.title) + '</a></div>'; + html += '<div class="bookmark-url">' + escHtml(r.url) + '</div>'; + if (r.description) { + html += '<div class="bookmark-desc">' + escHtml(r.description) + '</div>'; + } + html += '<div class="external-engine">' + escHtml(r.engine) + '</div>'; + html += '</li>'; + return html; + } + + function escHtml(s) { + var div = document.createElement('div'); + div.appendChild(document.createTextNode(s)); + return div.innerHTML; + } + + function escAttr(s) { + return escHtml(s).replace(/"/g, '"'); + } + + function addFilterBadge(engine) { + if (liveEngines[engine]) return; + liveEngines[engine] = true; + var btn = document.createElement('button'); + btn.className = 'engine-badge active'; + btn.setAttribute('data-engine', engine); + btn.textContent = engine; + liveFilters.appendChild(btn); + setupBadge(btn); + } + + function startSearch(query, page) { + showLive(); + liveBookmarks.innerHTML = ''; + liveExternal.innerHTML = ''; + liveFilters.innerHTML = ''; + liveMeta.textContent = ''; + liveEngines = {}; + colExternal.style.display = 'none'; + liveEmpty.style.display = 'none'; + livePrompt.style.display = 'none'; + + var params = 'q=' + encodeURIComponent(query); + if (page && page > 1) params += '&page=' + page; + var source = new EventSource('/api/search/stream?' + params); + + source.addEventListener('bookmarks', function(e) { + var data = JSON.parse(e.data); + if (data.total > 0) { + var meta = data.total + ' result' + (data.total !== 1 ? 's' : ''); + if (data.total_pages > 1) { + meta = 'Page ' + data.page + ' of ' + data.total_pages + ' - ' + meta; + } + var html = ''; + for (var i = 0; i < data.results.length; i++) { + html += renderBookmarkHtml(data.results[i]); + } + liveMeta.textContent = meta + ' for "' + query + '"'; + liveBookmarks.innerHTML = html; + } else { + liveEmpty.style.display = ''; + } + collectResults(); + }); + + source.addEventListener('external', function(e) { + var data = JSON.parse(e.data); + colExternal.style.display = ''; + addFilterBadge(data.engine); + var html = ''; + for (var i = 0; i < data.results.length; i++) { + html += renderExternalHtml(data.results[i]); + } + liveExternal.insertAdjacentHTML('beforeend', html); + collectResults(); + }); + + source.addEventListener('bang', function(e) { + var data = JSON.parse(e.data); + window.location.href = data.redirect_url; + source.close(); + }); + + source.addEventListener('done', function() { + source.close(); + }); + } + + // Intercept form submit + searchForm.addEventListener('submit', function(e) { + e.preventDefault(); + var q = searchInput.value.trim(); + history.pushState(null, '', q ? '/search?q=' + encodeURIComponent(q) + '&js=true' : '/'); + if (q) { + startSearch(q, 1); + } else { + showLive(); + liveBookmarks.innerHTML = ''; + liveExternal.innerHTML = ''; + liveFilters.innerHTML = ''; + liveMeta.textContent = ''; + liveEngines = {}; + colExternal.style.display = 'none'; + liveEmpty.style.display = 'none'; + livePrompt.style.display = ''; + collectResults(); + } + }); + + // On page load, check for existing query + var urlParams = new URLSearchParams(window.location.search); + var initialQ = urlParams.get('q'); + if (initialQ) { + urlParams.set('js', 'true'); + history.replaceState(null, '', '?' + urlParams.toString()); + var initialPage = parseInt(urlParams.get('page')) || 1; + startSearch(initialQ, initialPage); + } else { + // Empty page with JS - hide SSR prompt, show live prompt + showLive(); + livePrompt.style.display = ''; + collectResults(); + } + } })(); </script> </body> -
modified tests/search_integration.rs
diff --git a/tests/search_integration.rs b/tests/search_integration.rs index 176242f..6b020d1 100644 --- a/tests/search_integration.rs +++ b/tests/search_integration.rs @@ -4,6 +4,7 @@ use search_hub::config::{EngineConfig, Shortcut}; use search_hub::models::Bookmark; use search_hub::storage; use search_hub::web::{handlers, DbPool, SearchApiResponse, ServerConfig}; +use serde_json::Value; use std::collections::HashMap; use std::sync::{Arc, RwLock}; use tera::Tera; @@ -33,7 +34,8 @@ macro_rules! setup { .app_data(shortcuts) .service(handlers::index) .service(handlers::search) - .service(handlers::api_search), + .service(handlers::api_search) + .service(handlers::search_stream), ) .await }}; @@ -203,6 +205,212 @@ async fn index_page_renders() { assert!(html.contains("Bookmark Search")); } +// ---- SSE streaming endpoint tests ---- + +struct SseEvent { + event_type: String, + data: String, +} + +fn parse_sse(body: &str) -> Vec<SseEvent> { + body.split("\n\n") + .filter(|chunk| !chunk.trim().is_empty()) + .filter_map(|chunk| { + let mut event_type = None; + let mut data = None; + for line in chunk.lines() { + if let Some(val) = line.strip_prefix("event: ") { + event_type = Some(val.to_string()); + } else if let Some(val) = line.strip_prefix("data: ") { + data = Some(val.to_string()); + } + } + match (event_type, data) { + (Some(event_type), Some(data)) => Some(SseEvent { event_type, data }), + _ => None, + } + }) + .collect() +} + +#[actix_web::test] +async fn sse_returns_event_stream_content_type() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap().to_string(); + let app = setup!(&path); + + let req = test::TestRequest::get() + .uri("/api/search/stream?q=rust") + .to_request(); + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + assert_eq!( + resp.headers().get("content-type").unwrap().to_str().unwrap(), + "text/event-stream" + ); +} + +#[actix_web::test] +async fn sse_empty_query_returns_done() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap().to_string(); + let app = setup!(&path); + + let req = test::TestRequest::get() + .uri("/api/search/stream") + .to_request(); + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + + let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap(); + let events = parse_sse(&body); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_type, "done"); +} + +#[actix_web::test] +async fn sse_returns_bookmarks_event() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap().to_string(); + let app = setup!(&path); + + let pool = DbPool::new(&path); + let conn = pool.conn(); + insert_bookmark(&conn, "Rust programming", "https://rust-lang.org", "Rust is great"); + insert_bookmark(&conn, "Python tutorial", "https://python.org", "Python is fun"); + + let req = test::TestRequest::get() + .uri("/api/search/stream?q=rust") + .to_request(); + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + + let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap(); + let events = parse_sse(&body); + + // Should have bookmarks + done + assert!(events.len() >= 2); + assert_eq!(events[0].event_type, "bookmarks"); + assert_eq!(events.last().unwrap().event_type, "done"); + + let bookmark_data: Value = serde_json::from_str(&events[0].data).unwrap(); + assert_eq!(bookmark_data["total"], 1); + assert_eq!(bookmark_data["results"].as_array().unwrap().len(), 1); + assert_eq!(bookmark_data["results"][0]["type"], "bookmark"); + assert_eq!(bookmark_data["results"][0]["title"], "Rust programming"); +} + +#[actix_web::test] +async fn sse_no_match_returns_empty_bookmarks() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap().to_string(); + let app = setup!(&path); + + let pool = DbPool::new(&path); + let conn = pool.conn(); + insert_bookmark(&conn, "Rust programming", "https://rust-lang.org", "Rust is great"); + + let req = test::TestRequest::get() + .uri("/api/search/stream?q=nonexistent") + .to_request(); + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + + let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap(); + let events = parse_sse(&body); + + assert!(events.len() >= 2); + assert_eq!(events[0].event_type, "bookmarks"); + + let bookmark_data: Value = serde_json::from_str(&events[0].data).unwrap(); + assert_eq!(bookmark_data["total"], 0); + assert!(bookmark_data["results"].as_array().unwrap().is_empty()); +} + +#[actix_web::test] +async fn sse_done_after_bookmarks_with_no_engines() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap().to_string(); + let app = setup!(&path); + + let pool = DbPool::new(&path); + let conn = pool.conn(); + insert_bookmark(&conn, "Rust", "https://rust-lang.org", "systems language"); + + let req = test::TestRequest::get() + .uri("/api/search/stream?q=rust") + .to_request(); + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + + let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap(); + let events = parse_sse(&body); + + // Since no external engines configured, should have exactly: bookmarks + done + assert_eq!(events.len(), 2); + assert_eq!(events[0].event_type, "bookmarks"); + assert_eq!(events[1].event_type, "done"); +} + +#[actix_web::test] +async fn sse_bang_returns_bang_event() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap().to_string(); + + let mut shortcuts = HashMap::new(); + shortcuts.insert( + "w".to_string(), + Shortcut { + trigger: "w".into(), + name: "Wikipedia".into(), + bang_url: "https://en.wikipedia.org/w/index.php?search={}".into(), + engine_index: None, + }, + ); + let shortcuts = web::Data::new(shortcuts); + + let db_pool = web::Data::new(DbPool::new(&path)); + let engines = web::Data::new(Arc::new(RwLock::new(Vec::<EngineConfig>::new()))); + let cfg = web::Data::new(ServerConfig { + port: 8080, + bind_address: "127.0.0.1".into(), + page_size: 20, + workers: 1, + }); + let mut tera_ = Tera::default(); + tera_ + .add_raw_template("index.html", include_str!("../templates/index.html")) + .expect("template parse"); + let tera = web::Data::new(tera_); + + let app = test::init_service( + App::new() + .app_data(tera) + .app_data(db_pool) + .app_data(engines) + .app_data(cfg) + .app_data(shortcuts) + .service(handlers::search_stream), + ) + .await; + + let req = test::TestRequest::get() + .uri("/api/search/stream?q=%21w+Rust") + .to_request(); + let resp = test::call_service(&app, req).await; + assert!(resp.status().is_success()); + + let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap(); + let events = parse_sse(&body); + + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_type, "bang"); + let bang_data: Value = serde_json::from_str(&events[0].data).unwrap(); + assert_eq!(bang_data["trigger"], "!w Rust"); + assert_eq!(bang_data["query"], "Rust"); + assert!(bang_data["redirect_url"].as_str().unwrap().contains("en.wikipedia.org")); +} + #[actix_web::test] async fn api_search_pagination() { let tmp = tempfile::NamedTempFile::new().unwrap();