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/// Columns the task table can be sorted by.
 11#[derive(Clone, Copy, PartialEq, Eq)]
 12pub(super) enum SortField {
 13    Id,
 14    Status,
 15    Priority,
 16    Effort,
 17    Title,
 18    Created,
 19}
 20
 21impl SortField {
 22    pub(super) fn parse(s: &str) -> Option<Self> {
 23        match s {
 24            "id" => Some(Self::Id),
 25            "status" => Some(Self::Status),
 26            "priority" => Some(Self::Priority),
 27            "effort" => Some(Self::Effort),
 28            "title" => Some(Self::Title),
 29            "created" => Some(Self::Created),
 30            _ => None,
 31        }
 32    }
 33
 34    pub(super) fn as_str(self) -> &'static str {
 35        match self {
 36            Self::Id => "id",
 37            Self::Status => "status",
 38            Self::Priority => "priority",
 39            Self::Effort => "effort",
 40            Self::Title => "title",
 41            Self::Created => "created",
 42        }
 43    }
 44
 45    /// Sensible default direction when the user first clicks a column.
 46    pub(super) fn default_order(self) -> SortOrder {
 47        match self {
 48            // Newest first, alphabetical ascending for text fields.
 49            Self::Created => SortOrder::Desc,
 50            Self::Title | Self::Id => SortOrder::Asc,
 51            // Highest priority/effort first; open before closed.
 52            Self::Priority | Self::Effort | Self::Status => SortOrder::Asc,
 53        }
 54    }
 55}
 56
 57#[derive(Clone, Copy, PartialEq, Eq)]
 58pub(super) enum SortOrder {
 59    Asc,
 60    Desc,
 61}
 62
 63impl SortOrder {
 64    pub(super) fn parse(s: &str) -> Option<Self> {
 65        match s {
 66            "asc" => Some(Self::Asc),
 67            "desc" => Some(Self::Desc),
 68            _ => None,
 69        }
 70    }
 71
 72    pub(super) fn as_str(self) -> &'static str {
 73        match self {
 74            Self::Asc => "asc",
 75            Self::Desc => "desc",
 76        }
 77    }
 78}
 79
 80/// Map a `Status` to a numeric value for semantic sorting.
 81/// Lower values sort first in ascending order: open → in_progress → closed.
 82fn status_sort_key(s: db::Status) -> i32 {
 83    match s {
 84        db::Status::Open => 1,
 85        db::Status::InProgress => 2,
 86        db::Status::Closed => 3,
 87    }
 88}
 89
 90/// Apply the chosen sort field and direction to a filtered task list.
 91pub(super) fn sort_tasks(tasks: &mut [&db::Task], field: SortField, order: SortOrder) {
 92    tasks.sort_by(|a, b| {
 93        let cmp = match field {
 94            SortField::Id => a.id.as_str().cmp(b.id.as_str()),
 95            SortField::Status => status_sort_key(a.status).cmp(&status_sort_key(b.status)),
 96            SortField::Priority => a.priority.score().cmp(&b.priority.score()),
 97            SortField::Effort => a.effort.score().cmp(&b.effort.score()),
 98            SortField::Title => a
 99                .title
100                .to_ascii_lowercase()
101                .cmp(&b.title.to_ascii_lowercase()),
102            SortField::Created => a.created_at.cmp(&b.created_at),
103        };
104        match order {
105            SortOrder::Asc => cmp,
106            SortOrder::Desc => cmp.reverse(),
107        }
108    });
109}
110
111/// Return a human-friendly status label (e.g. "In progress" instead of
112/// "in_progress").
113pub(super) fn friendly_status(raw: &str) -> &'static str {
114    match raw {
115        "open" => "Open",
116        "in_progress" => "In progress",
117        "closed" => "Closed",
118        _ => "Open",
119    }
120}
121
122/// Format an ISO 8601 timestamp into a human-friendly form (e.g. "15 Mar 2026")
123/// for the noscript fallback. Returns the original string unchanged on parse
124/// failure so the page still renders something sensible.
125pub(super) fn friendly_date(iso: &str) -> String {
126    chrono::NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%SZ")
127        .map(|dt| dt.format("%-d %b %Y %H:%M").to_string())
128        .unwrap_or_else(|_| iso.to_string())
129}
130
131/// Render a markdown string to sanitised HTML.
132///
133/// Uses pulldown-cmark for parsing and ammonia for sanitisation so that
134/// untrusted user input can be displayed safely. Images are stripped
135/// entirely — only structural/inline markup is allowed through.
136pub(super) fn render_markdown(src: &str) -> String {
137    use pulldown_cmark::{html::push_html, Parser};
138
139    let parser = Parser::new(src);
140    let mut raw_html = String::new();
141    push_html(&mut raw_html, parser);
142
143    ammonia::Builder::default()
144        .rm_tags(&["img"])
145        .clean(&raw_html)
146        .to_string()
147}
148
149pub(super) fn render(tmpl: impl Template) -> Response {
150    match tmpl.render() {
151        Ok(html) => Html(html).into_response(),
152        Err(e) => error_response(500, &format!("template render failed: {e}"), &[]),
153    }
154}
155
156pub(super) fn error_response(code: u16, msg: &str, all_projects: &[String]) -> Response {
157    let body = ErrorTemplate {
158        all_projects: all_projects.to_vec(),
159        active_project: None,
160        status_code: code,
161        message: msg.to_string(),
162    };
163    let html = body
164        .render()
165        .unwrap_or_else(|_| format!("<h1>{code}</h1><p>{msg}</p>"));
166    let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
167    (status, Html(html)).into_response()
168}
169
170pub(super) fn list_projects_safe(root: &std::path::Path) -> Vec<String> {
171    db::list_projects_in(root).unwrap_or_default()
172}
173
174/// Returns true when the client prefers a JSON response.
175fn wants_json(headers: &HeaderMap) -> bool {
176    headers
177        .get("accept")
178        .and_then(|v| v.to_str().ok())
179        .map(|v| v.contains("application/json"))
180        .unwrap_or(false)
181}
182
183/// Build a redirect-or-JSON response after a successful mutation.
184pub(super) fn mutation_response(
185    headers: &HeaderMap,
186    redirect_to: &str,
187    json_body: serde_json::Value,
188) -> Response {
189    if wants_json(headers) {
190        Json(json_body).into_response()
191    } else {
192        Redirect::to(redirect_to).into_response()
193    }
194}
195
196/// Build an error response appropriate for the caller (JSON or HTML).
197pub(super) fn mutation_error(headers: &HeaderMap, code: u16, err: &anyhow::Error) -> Response {
198    let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
199    if wants_json(headers) {
200        (status, Json(serde_json::json!({"error": err.to_string()}))).into_response()
201    } else {
202        error_response(code, &err.to_string(), &[])
203    }
204}