search_hub

at 9ceb48b Raw

pub mod handlers;

use crate::config::{EngineConfig, Shortcut};
use crate::search_engines::ResultEntry;
use crate::storage;
use actix_web::{web, App, HttpServer};
use chrono::{DateTime, Utc};
use rusqlite::Connection;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use tera::Tera;
use tracing::info;

const USER_AGENT: &str = concat!("search_hub/", env!("CARGO_PKG_VERSION"));

pub struct DbPool(Mutex<Connection>);

impl DbPool {
    pub fn new(path: &str) -> Self {
        let conn = storage::init_db(path).expect("Failed to initialize database");
        DbPool(Mutex::new(conn))
    }

    pub fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
        self.0.lock().unwrap()
    }
}

const VERSION: &str = concat!(
    env!("CARGO_PKG_VERSION"),
    " (",
    env!("SEARCH_HUB_GIT_HASH"),
    ")",
);

#[derive(Clone)]
pub struct ServerConfig {
    pub port: u16,
    pub bind_address: String,
    pub page_size: usize,
    pub workers: usize,
}

/// Simplified shortcut info for rendering in Tera templates.
/// `has_at` is `false` for standalone [[bangs]] that have no engine backing.
#[derive(Serialize)]
pub struct ShortcutHelp {
    pub trigger: String,
    pub name: String,
    pub has_at: bool,
}

#[derive(serde::Deserialize)]
pub struct SearchQuery {
    pub q: Option<String>,
    pub page: Option<usize>,
    pub page_size: Option<usize>,
}

#[derive(Serialize)]
pub struct SearchApiResponse {
    query: String,
    page: usize,
    page_size: usize,
    total_results: usize,
    total_pages: usize,
    page_time_ms: f64,
    results: Vec<SearchApiResult>,
}

#[derive(Serialize)]
#[serde(tag = "type")]
pub enum SearchApiResult {
    #[serde(rename = "bookmark")]
    Bookmark(ApiBookmark),
    #[serde(rename = "external")]
    External(ApiExternal),
}

#[derive(Serialize)]
pub struct ApiBookmark {
    pub id: i32,
    pub title: String,
    pub url: String,
    pub description: Option<String>,
    pub source: String,
    pub tags: Option<String>,
    pub created_at: DateTime<Utc>,
}

#[derive(Serialize)]
pub struct ApiExternal {
    pub title: String,
    pub url: String,
    pub description: Option<String>,
    pub engine: String,
}

pub async fn run_server(
    db_path: &str,
    cfg: ServerConfig,
    engines: Arc<RwLock<Vec<EngineConfig>>>,
    shortcuts: HashMap<String, Shortcut>,
) -> std::io::Result<()> {
    let db_pool = web::Data::new(DbPool::new(db_path));
    let engines = web::Data::new(engines);
    let cfg = web::Data::new(cfg);
    let shortcuts = web::Data::new(shortcuts);
    let mut tera = Tera::default();
    tera.add_raw_template("index.html", include_str!("../../templates/index.html"))
        .expect("Failed to parse index template");
    tera.add_raw_template("opensearch.xml", include_str!("../../templates/opensearch.xml"))
        .expect("Failed to parse opensearch template");
    let tera = web::Data::new(tera);

    let bind_addr = cfg.bind_address.clone();
    let bind_port = cfg.port;
    let workers = cfg.workers;

    info!(
        "Starting server on {}:{}, {} workers",
        bind_addr, bind_port, workers
    );

    HttpServer::new(move || {
        App::new()
            .app_data(tera.clone())
            .app_data(db_pool.clone())
            .app_data(engines.clone())
            .app_data(cfg.clone())
            .app_data(shortcuts.clone())
            .service(handlers::index)
            .service(handlers::search)
            .service(handlers::api_search)
            .service(handlers::opensearch)
    })
    .workers(workers)
    .bind((bind_addr.as_str(), bind_port))?
    .run()
    .await
}

pub fn interleave(per_engine: &[Vec<ResultEntry>]) -> Vec<ResultEntry> {
    let max_len = per_engine.iter().map(|r| r.len()).max().unwrap_or(0);
    let mut out = Vec::with_capacity(max_len * per_engine.len());
    for i in 0..max_len {
        for results in per_engine {
            if let Some(entry) = results.get(i) {
                out.push(entry.clone());
            }
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::Bookmark;
    use chrono::Utc;

    fn test_tera() -> Tera {
        let mut tera = Tera::default();
        tera.add_raw_template("index.html", include_str!("../../templates/index.html"))
            .expect("template parse");
        tera
    }

    #[test]
    fn render_template_no_query() {
        let mut ctx = tera::Context::new();
        ctx.insert("version", &"0.0.0");
        let rendered = test_tera().render("index.html", &ctx).expect("render");
        assert!(rendered.contains("Bookmark Search"));
        assert!(rendered.contains("enter a query"));
    }

    #[test]
    fn render_template_with_results() {
        let mut ctx = tera::Context::new();
        ctx.insert("query", &"rust");
        ctx.insert("page", &1usize);
        ctx.insert("total_pages", &1usize);
        ctx.insert("total_results", &1usize);
        ctx.insert("bookmarks", &vec![
            Bookmark {
                id: 1,
                title: "Rust Lang".into(),
                url: "https://rust-lang.org".into(),
                description: Some("The Rust programming language".into()),
                source: "bookmark".into(),
                content: None,
                tags: None,
                created_at: Utc::now(),
            },
        ]);
        ctx.insert("version", &"0.0.0");
        ctx.insert("external_results", &Vec::<ResultEntry>::new());
        let rendered = test_tera().render("index.html", &ctx).expect("render");
        assert!(rendered.contains("Rust Lang"));
        assert!(rendered.contains("rust-lang.org"));
        assert!(rendered.contains("1 result"));
    }

    #[test]
    fn render_template_no_results() {
        let mut ctx = tera::Context::new();
        ctx.insert("query", &"zzznotfound");
        ctx.insert("page", &1usize);
        ctx.insert("total_pages", &0usize);
        ctx.insert("total_results", &0usize);
        ctx.insert("bookmarks", &Vec::<Bookmark>::new());
        ctx.insert("version", &"0.0.0");
        ctx.insert("external_results", &Vec::<ResultEntry>::new());
        let rendered = test_tera().render("index.html", &ctx).expect("render");
        assert!(rendered.contains("no bookmarks found"));
    }

    #[test]
    fn test_interleave_empty() {
        let result = interleave(&[]);
        assert!(result.is_empty());
    }

    #[test]
    fn test_interleave_single_engine() {
        let e = vec![
            ResultEntry { title: "A".into(), url: "http://a".into(), description: None, engine: "e1".into() },
            ResultEntry { title: "B".into(), url: "http://b".into(), description: None, engine: "e1".into() },
        ];
        let result = interleave(&[e]);
        assert_eq!(result.len(), 2);
        assert_eq!(result[0].title, "A");
        assert_eq!(result[1].title, "B");
    }

    #[test]
    fn test_interleave_two_engines_equal_length() {
        let e1 = vec![
            ResultEntry { title: "A1".into(), url: "http://a1".into(), description: None, engine: "e1".into() },
            ResultEntry { title: "A2".into(), url: "http://a2".into(), description: None, engine: "e1".into() },
        ];
        let e2 = vec![
            ResultEntry { title: "B1".into(), url: "http://b1".into(), description: None, engine: "e2".into() },
            ResultEntry { title: "B2".into(), url: "http://b2".into(), description: None, engine: "e2".into() },
        ];
        let result = interleave(&[e1, e2]);
        assert_eq!(result.len(), 4);
        assert_eq!(result[0].title, "A1");
        assert_eq!(result[1].title, "B1");
        assert_eq!(result[2].title, "A2");
        assert_eq!(result[3].title, "B2");
    }

    #[test]
    fn test_interleave_uneven_length() {
        let e1 = vec![
            ResultEntry { title: "A1".into(), url: "http://a1".into(), description: None, engine: "e1".into() },
            ResultEntry { title: "A2".into(), url: "http://a2".into(), description: None, engine: "e1".into() },
            ResultEntry { title: "A3".into(), url: "http://a3".into(), description: None, engine: "e1".into() },
        ];
        let e2 = vec![
            ResultEntry { title: "B1".into(), url: "http://b1".into(), description: None, engine: "e2".into() },
        ];
        let result = interleave(&[e1, e2]);
        assert_eq!(result.len(), 4);
        assert_eq!(result[0].title, "A1");
        assert_eq!(result[1].title, "B1");
        assert_eq!(result[2].title, "A2");
        assert_eq!(result[3].title, "A3");
    }
}