helpers.rs

  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}