#![feature(option_result_contains, once_cell)] use actix_web::{get, http::header, web, App, Either, HttpResponse, HttpServer}; use data::{Card, CardInfo}; use filter::SearchCard; use itertools::Itertools; use serde::Deserialize; use std::{collections::HashMap, fmt::Write, fs::File, io::BufReader, net::Ipv4Addr, sync::LazyLock, time::Instant}; mod data; mod filter; mod parser; const RESULT_LIMIT: usize = 100; static CARDS: LazyLock> = LazyLock::new(|| { serde_json::from_reader::<_, CardInfo>(BufReader::new(File::open("cards.json").expect("cards.json not found"))) .expect("Could not deserialize cards") .data }); static CARDS_BY_ID: LazyLock> = LazyLock::new(|| CARDS.iter().map(|c| (c.id, Card { text: c.text.replace("\r", "").replace('\n', "
"), ..c.clone() })).collect()); static SEARCH_CARDS: LazyLock> = LazyLock::new(|| CARDS.iter().map(SearchCard::from).collect()); #[actix_web::main] async fn main() -> std::io::Result<()> { let now = Instant::now(); println!("Starting server"); // tap these so they’re initialized let num_cards = (CARDS_BY_ID.len() + SEARCH_CARDS.len()) / 2; println!("Read {num_cards} cards in {:?}", now.elapsed()); HttpServer::new(|| App::new().service(search).service(card_info).service(help)) .bind((Ipv4Addr::from([127, 0, 0, 1]), 8080))? .run() .await } #[derive(Debug, Deserialize)] struct Query { q: String, } const HEADER: &str = include_str!("../static/header.html"); const HELP_CONTENT: &str = include_str!("../static/help.html"); const FOOTER: &str = r#"
Home       Query Syntax
"#; #[get("/")] async fn search(q: Option, web::Form>>) -> Result> { let q = match q { Some(Either::Left(web::Query(Query { q }))) => Some(q), Some(Either::Right(web::Form(Query { q }))) => Some(q), None => None, } .filter(|s| !s.is_empty()); let mut res = String::with_capacity(10_000); res.push_str(HEADER); render_searchbox(&mut res, &q)?; match q { Some(q) => render_results(&mut res, &q)?, None => res.push_str("Enter a query above to search"), } finish_document(&mut res); Ok(HttpResponse::Ok().insert_header(header::ContentType::html()).body(res)) } #[get("/card/{id}")] async fn card_info(card_id: web::Path) -> Result> { let mut res = String::with_capacity(2_000); res.push_str(HEADER); render_searchbox(&mut res, &None)?; match CARDS_BY_ID.get(&card_id) { Some(card) => { res.push_str(r#""#); write!( res, r#"
{card}
"#, card.id, )?; } None => res.push_str("Card not found"), } Ok(HttpResponse::Ok().insert_header(header::ContentType::html()).body(res)) } #[get("/help")] async fn help() -> Result> { let mut res = String::with_capacity(HEADER.len() + HELP_CONTENT.len() + FOOTER.len() + 250); res.push_str(HEADER); render_searchbox(&mut res, &None)?; res.push_str(HELP_CONTENT); res.push_str(FOOTER); Ok(HttpResponse::Ok().insert_header(header::ContentType::html()).body(res)) } fn render_searchbox(res: &mut String, query: &Option) -> std::fmt::Result { write!( res, r#"
"#, match &query { Some(q) => q, None => "", } ) } fn render_results(res: &mut String, query: &str) -> Result<(), Box> { let query = match parser::parse_filters(query) { Ok(q) => q, Err(e) => { write!(res, "Could not parse query: {e:?}")?; return Ok(()); } }; let now = Instant::now(); let matches: Vec<&Card> = SEARCH_CARDS .iter() .filter(|card| query.iter().all(|(_, q)| q(card))) .map(|c| CARDS_BY_ID.get(&c.id).unwrap()) .take(RESULT_LIMIT) .collect(); write!( res, "Showing {} results where {} (took {:?})", matches.len(), query.iter().map(|(f, _)| f.to_string()).join(" and "), now.elapsed() )?; if matches.is_empty() { return Ok(()); } res.push_str(""); for card in matches { write!( res, r#""#, card.id, card.id )?; } Ok(()) } fn finish_document(res: &mut String) { res.push_str(FOOTER) }
{card}