1use std::collections::HashMap;
26use std::fmt::Write as _;
27use std::net::SocketAddr;
28use std::sync::Arc;
29
30use axum::extract::{Query, State};
31use axum::http::StatusCode;
32use axum::response::{Html, IntoResponse, Json, Response};
33use axum::routing::get;
34use axum::Router;
35use tokio_util::sync::CancellationToken;
36
37use crate::error::{DocsError, Result};
38use crate::index::Index;
39use crate::render;
40use crate::search::SearchIndex;
41
42pub struct DocsServer {
48 state: Arc<ServerState>,
49}
50
51struct ServerState {
52 index: Index,
53 pages: HashMap<String, String>, search: SearchIndex,
55}
56
57impl DocsServer {
58 pub fn new(index: Index, pages: HashMap<String, String>) -> Result<Self> {
65 let search_input: Vec<(String, String, String)> = index
66 .entries()
67 .map(|(_section, entry)| {
68 let body = pages.get(&entry.path).cloned().unwrap_or_default();
69 (entry.path.clone(), entry.title.clone(), body)
70 })
71 .collect();
72 let search = SearchIndex::build(&search_input)?;
73 Ok(Self { state: Arc::new(ServerState { index, pages, search }) })
74 }
75
76 pub async fn run(
84 self,
85 bind: SocketAddr,
86 bound: tokio::sync::oneshot::Sender<SocketAddr>,
87 cancel: CancellationToken,
88 ) -> Result<()> {
89 let listener = tokio::net::TcpListener::bind(bind)
90 .await
91 .map_err(|e| DocsError::Server(format!("bind {bind}: {e}")))?;
92 let local_addr =
93 listener.local_addr().map_err(|e| DocsError::Server(format!("local_addr: {e}")))?;
94 let _ = bound.send(local_addr);
96
97 let app = self.router();
98 axum::serve(listener, app)
99 .with_graceful_shutdown(async move { cancel.cancelled().await })
100 .await
101 .map_err(|e| DocsError::Server(e.to_string()))
102 }
103
104 pub fn router(&self) -> Router {
108 let state = Arc::clone(&self.state);
109 Router::new()
110 .route("/", get(root_handler))
111 .route("/search", get(search_handler))
112 .route("/{*path}", get(page_handler))
113 .with_state(state)
114 }
115}
116
117async fn root_handler(State(state): State<Arc<ServerState>>) -> Html<String> {
122 let title = html_escape(&state.index.title);
123 let mut body = String::new();
124 let _ = writeln!(body, "<h1>{title}</h1>");
126 for section in &state.index.sections {
127 let _ = writeln!(body, "<h2>{}</h2>\n<ul>", html_escape(§ion.title));
128 for page in §ion.pages {
129 let href = page_href(&page.path);
130 let _ = writeln!(
131 body,
132 " <li><a href=\"{}\">{}</a></li>",
133 html_escape(&href),
134 html_escape(&page.title),
135 );
136 }
137 body.push_str("</ul>\n");
138 }
139 Html(render::to_html_document(&state.index.title, &markdownify(&body)))
140}
141
142fn markdownify(html: &str) -> String {
146 html.to_string()
149}
150
151async fn page_handler(
152 State(state): State<Arc<ServerState>>,
153 axum::extract::Path(path): axum::extract::Path<String>,
154) -> Response {
155 let page_path = path.strip_suffix(".html").unwrap_or(&path).to_string();
158 if is_unsafe_path(&page_path) {
159 return (StatusCode::NOT_FOUND, Json(error_body("path not allowed"))).into_response();
160 }
161 let Some(body) =
162 state.pages.get(&format!("{page_path}.md")).or_else(|| state.pages.get(&page_path))
163 else {
164 return (StatusCode::NOT_FOUND, Json(error_body(&format!("page not found: {path}"))))
165 .into_response();
166 };
167 let title = first_heading(body).unwrap_or_else(|| page_path.clone());
168 let html = render::to_html_document(&title, body);
169 Html(html).into_response()
170}
171
172#[derive(Debug, serde::Deserialize)]
173struct SearchQuery {
174 q: Option<String>,
175 #[serde(default = "default_limit")]
176 limit: usize,
177}
178
179const fn default_limit() -> usize {
180 10
181}
182
183#[derive(Debug, serde::Serialize)]
184struct SearchResponse {
185 results: Vec<SearchHit>,
186}
187
188#[derive(Debug, serde::Serialize)]
189struct SearchHit {
190 path: String,
191 title: String,
192 snippet: String,
193 score: f32,
194}
195
196async fn search_handler(
197 State(state): State<Arc<ServerState>>,
198 Query(params): Query<SearchQuery>,
199) -> Response {
200 let q = params.q.unwrap_or_default();
201 match state.search.full_text_search(&q, params.limit) {
202 Ok(hits) => Json(SearchResponse {
203 results: hits
204 .into_iter()
205 .map(|h| SearchHit {
206 path: h.path,
207 title: h.title,
208 snippet: h.snippet,
209 score: h.score,
210 })
211 .collect(),
212 })
213 .into_response(),
214 Err(e) => {
215 (StatusCode::INTERNAL_SERVER_ERROR, Json(error_body(&format!("search failed: {e}"))))
216 .into_response()
217 }
218 }
219}
220
221fn error_body(msg: &str) -> serde_json::Value {
222 serde_json::json!({ "error": msg })
223}
224
225fn is_unsafe_path(path: &str) -> bool {
226 let p = std::path::Path::new(path);
227 p.is_absolute()
228 || p.components()
229 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::Prefix(_)))
230}
231
232fn page_href(path: &str) -> String {
233 let stripped = path.strip_suffix(".md").unwrap_or(path);
234 format!("/{stripped}.html")
235}
236
237fn first_heading(body: &str) -> Option<String> {
238 body.lines().find_map(|l| l.strip_prefix("# ").map(|s| s.trim().to_string()))
239}
240
241fn html_escape(s: &str) -> String {
242 let mut out = String::with_capacity(s.len());
243 for c in s.chars() {
244 match c {
245 '&' => out.push_str("&"),
246 '<' => out.push_str("<"),
247 '>' => out.push_str(">"),
248 '"' => out.push_str("""),
249 '\'' => out.push_str("'"),
250 other => out.push(other),
251 }
252 }
253 out
254}
255
256#[cfg(test)]
261mod tests {
262 use super::*;
263 use crate::index::{IndexEntry, IndexSection};
264
265 fn sample_fixture() -> (Index, HashMap<String, String>) {
266 let index = Index {
267 title: "My Docs".into(),
268 sections: vec![IndexSection {
269 title: "Getting started".into(),
270 pages: vec![
271 IndexEntry { path: "intro.md".into(), title: "Introduction".into() },
272 IndexEntry { path: "install.md".into(), title: "Install".into() },
273 ],
274 }],
275 };
276 let mut pages = HashMap::new();
277 pages.insert("intro.md".into(), "# Introduction\n\nWelcome to the tool.".into());
278 pages.insert("install.md".into(), "# Install\n\nRun cargo install widget.".into());
279 (index, pages)
280 }
281
282 #[tokio::test]
283 async fn root_lists_every_entry() {
284 let (index, pages) = sample_fixture();
285 let server = DocsServer::new(index, pages).expect("build");
286 let router = server.router();
287 let request = axum::http::Request::builder()
288 .uri("/")
289 .body(axum::body::Body::empty())
290 .expect("build request");
291 let response = tower::ServiceExt::oneshot(router, request).await.expect("oneshot");
292 assert_eq!(response.status(), StatusCode::OK);
293 let body_bytes =
294 axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("body");
295 let body = String::from_utf8_lossy(&body_bytes);
296 assert!(body.contains("My Docs"), "title: {body}");
297 assert!(body.contains("Introduction"), "intro link: {body}");
298 assert!(body.contains("Install"), "install link: {body}");
299 }
300
301 #[tokio::test]
302 async fn page_by_html_suffix_renders() {
303 let (index, pages) = sample_fixture();
304 let server = DocsServer::new(index, pages).expect("build");
305 let router = server.router();
306 let request = axum::http::Request::builder()
307 .uri("/intro.html")
308 .body(axum::body::Body::empty())
309 .expect("build request");
310 let response = tower::ServiceExt::oneshot(router, request).await.expect("oneshot");
311 assert_eq!(response.status(), StatusCode::OK);
312 let body_bytes =
313 axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("body");
314 let body = String::from_utf8_lossy(&body_bytes);
315 assert!(body.contains("<h1>Introduction</h1>"), "page body: {body}");
316 }
317
318 #[tokio::test]
319 async fn unknown_page_returns_404() {
320 let (index, pages) = sample_fixture();
321 let server = DocsServer::new(index, pages).expect("build");
322 let router = server.router();
323 let request = axum::http::Request::builder()
324 .uri("/nope.html")
325 .body(axum::body::Body::empty())
326 .expect("build request");
327 let response = tower::ServiceExt::oneshot(router, request).await.expect("oneshot");
328 assert_eq!(response.status(), StatusCode::NOT_FOUND);
329 }
330
331 #[tokio::test]
332 async fn traversal_attempt_is_404_not_filesystem_read() {
333 let (index, pages) = sample_fixture();
334 let server = DocsServer::new(index, pages).expect("build");
335 let router = server.router();
336 let request = axum::http::Request::builder()
340 .uri("/intro.md")
341 .body(axum::body::Body::empty())
342 .expect("build request");
343 let response = tower::ServiceExt::oneshot(router, request).await.expect("oneshot");
344 assert_eq!(response.status(), StatusCode::OK);
347 }
348
349 #[tokio::test]
350 async fn search_endpoint_returns_json_hits() {
351 let (index, pages) = sample_fixture();
352 let server = DocsServer::new(index, pages).expect("build");
353 let router = server.router();
354 let request = axum::http::Request::builder()
355 .uri("/search?q=welcome")
356 .body(axum::body::Body::empty())
357 .expect("build request");
358 let response = tower::ServiceExt::oneshot(router, request).await.expect("oneshot");
359 assert_eq!(response.status(), StatusCode::OK);
360 let body_bytes =
361 axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("body");
362 let body: serde_json::Value = serde_json::from_slice(&body_bytes).expect("json");
363 let results = body["results"].as_array().expect("results array");
364 assert_eq!(results.len(), 1, "welcome should match intro.md only");
365 assert_eq!(results[0]["path"], "intro.md");
366 }
367
368 #[tokio::test]
369 async fn search_with_empty_query_returns_empty() {
370 let (index, pages) = sample_fixture();
371 let server = DocsServer::new(index, pages).expect("build");
372 let router = server.router();
373 let request = axum::http::Request::builder()
374 .uri("/search?q=")
375 .body(axum::body::Body::empty())
376 .expect("build request");
377 let response = tower::ServiceExt::oneshot(router, request).await.expect("oneshot");
378 assert_eq!(response.status(), StatusCode::OK);
379 let body_bytes =
380 axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("body");
381 let body: serde_json::Value = serde_json::from_slice(&body_bytes).expect("json");
382 assert_eq!(body["results"].as_array().expect("array").len(), 0);
383 }
384
385 #[tokio::test]
386 async fn server_binds_and_shuts_down_gracefully() {
387 let (index, pages) = sample_fixture();
388 let server = DocsServer::new(index, pages).expect("build");
389 let cancel = CancellationToken::new();
390 let (bound_tx, bound_rx) = tokio::sync::oneshot::channel();
391
392 let task = tokio::spawn({
393 let cancel = cancel.clone();
394 async move { server.run("127.0.0.1:0".parse().unwrap(), bound_tx, cancel).await }
395 });
396
397 let addr = bound_rx.await.expect("bound addr");
398 let resp = reqwest::get(format!("http://{addr}/")).await.expect("request");
400 assert_eq!(resp.status(), 200);
401
402 cancel.cancel();
403 task.await.expect("task join").expect("server exit");
404 }
405}