1use askama::Template;
2use axum::http::{HeaderMap, StatusCode};
3use axum::response::{Html, IntoResponse, Redirect, Response};
4use axum::Json;
5
6use crate::db;
7
8use super::views::ErrorTemplate;
9
10/// Return a human-friendly status label (e.g. "In progress" instead of
11/// "in_progress").
12pub(super) fn friendly_status(raw: &str) -> &'static str {
13 match raw {
14 "open" => "Open",
15 "in_progress" => "In progress",
16 "closed" => "Closed",
17 _ => "Open",
18 }
19}
20
21/// Format an ISO 8601 timestamp into a human-friendly form (e.g. "15 Mar 2026")
22/// for the noscript fallback. Returns the original string unchanged on parse
23/// failure so the page still renders something sensible.
24pub(super) fn friendly_date(iso: &str) -> String {
25 chrono::NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%SZ")
26 .map(|dt| dt.format("%-d %b %Y %H:%M").to_string())
27 .unwrap_or_else(|_| iso.to_string())
28}
29
30/// Render a markdown string to sanitised HTML.
31///
32/// Uses pulldown-cmark for parsing and ammonia for sanitisation so that
33/// untrusted user input can be displayed safely. Images are stripped
34/// entirely — only structural/inline markup is allowed through.
35pub(super) fn render_markdown(src: &str) -> String {
36 use pulldown_cmark::{html::push_html, Parser};
37
38 let parser = Parser::new(src);
39 let mut raw_html = String::new();
40 push_html(&mut raw_html, parser);
41
42 ammonia::Builder::default()
43 .rm_tags(&["img"])
44 .clean(&raw_html)
45 .to_string()
46}
47
48pub(super) fn render(tmpl: impl Template) -> Response {
49 match tmpl.render() {
50 Ok(html) => Html(html).into_response(),
51 Err(e) => error_response(500, &format!("template render failed: {e}"), &[]),
52 }
53}
54
55pub(super) fn error_response(code: u16, msg: &str, all_projects: &[String]) -> Response {
56 let body = ErrorTemplate {
57 all_projects: all_projects.to_vec(),
58 active_project: None,
59 status_code: code,
60 message: msg.to_string(),
61 };
62 let html = body
63 .render()
64 .unwrap_or_else(|_| format!("<h1>{code}</h1><p>{msg}</p>"));
65 let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
66 (status, Html(html)).into_response()
67}
68
69pub(super) fn list_projects_safe(root: &std::path::Path) -> Vec<String> {
70 db::list_projects_in(root).unwrap_or_default()
71}
72
73/// Returns true when the client prefers a JSON response.
74fn wants_json(headers: &HeaderMap) -> bool {
75 headers
76 .get("accept")
77 .and_then(|v| v.to_str().ok())
78 .map(|v| v.contains("application/json"))
79 .unwrap_or(false)
80}
81
82/// Build a redirect-or-JSON response after a successful mutation.
83pub(super) fn mutation_response(
84 headers: &HeaderMap,
85 redirect_to: &str,
86 json_body: serde_json::Value,
87) -> Response {
88 if wants_json(headers) {
89 Json(json_body).into_response()
90 } else {
91 Redirect::to(redirect_to).into_response()
92 }
93}
94
95/// Build an error response appropriate for the caller (JSON or HTML).
96pub(super) fn mutation_error(headers: &HeaderMap, code: u16, err: &anyhow::Error) -> Response {
97 let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
98 if wants_json(headers) {
99 (status, Json(serde_json::json!({"error": err.to_string()}))).into_response()
100 } else {
101 error_response(code, &err.to_string(), &[])
102 }
103}