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}