use hyper::StatusCode;
use crate::error::Result;
use crate::http::{Request, Response};
use super::types::{AdminEntry, ListPage};
pub(crate) fn wants_csv(req: &Request) -> bool {
if let Some(accept) = req.header("accept") {
for raw in accept.split(',') {
let bare = raw.split(';').next().unwrap_or(raw).trim();
if bare.eq_ignore_ascii_case("text/csv") {
return true;
}
if bare.eq_ignore_ascii_case("text/html")
|| bare.eq_ignore_ascii_case("application/json")
|| bare.eq_ignore_ascii_case("application/*")
{
return false;
}
}
}
req.query()
.get("format")
.map(|s| s == "csv")
.unwrap_or(false)
}
pub(crate) fn list_response(entry: &AdminEntry, page_result: ListPage) -> Result<Response> {
let mut body = String::new();
let mut header: Vec<&str> = Vec::with_capacity(entry.fields.len() + 1);
header.push("id");
for f in entry.fields.iter() {
if f.name == "id" {
continue;
}
header.push(f.label);
}
write_row(&mut body, header.iter().copied());
for row in &page_result.rows {
let mut cells: Vec<String> = Vec::with_capacity(entry.fields.len() + 1);
cells.push(row.id.to_string());
for (i, f) in entry.fields.iter().enumerate() {
if f.name == "id" {
continue;
}
cells.push(row.cells.get(i).cloned().unwrap_or_default());
}
write_row(&mut body, cells.iter().map(String::as_str));
}
let filename = format!("{}.csv", entry.admin_name);
Ok(Response::new(StatusCode::OK, body)
.with_header("content-type", "text/csv; charset=utf-8")
.with_header(
"content-disposition",
format!("attachment; filename=\"{filename}\""),
))
}
fn write_row<'a, I: IntoIterator<Item = &'a str>>(out: &mut String, cells: I) {
let mut first = true;
for c in cells {
if !first {
out.push(',');
}
first = false;
write_cell(out, c);
}
out.push_str("\r\n");
}
fn write_cell(out: &mut String, cell: &str) {
let needs_quoting =
cell.contains(',') || cell.contains('"') || cell.contains('\n') || cell.contains('\r');
if !needs_quoting {
out.push_str(cell);
return;
}
out.push('"');
for ch in cell.chars() {
if ch == '"' {
out.push_str("\"\"");
} else {
out.push(ch);
}
}
out.push('"');
}
#[cfg(test)]
mod tests {
use super::*;
fn render_row(cells: &[&str]) -> String {
let mut out = String::new();
write_row(&mut out, cells.iter().copied());
out
}
fn cell_only(s: &str) -> String {
let mut out = String::new();
write_cell(&mut out, s);
out
}
#[test]
fn simple_cells_are_unquoted() {
assert_eq!(cell_only("abc"), "abc");
assert_eq!(cell_only("123"), "123");
assert_eq!(cell_only(""), "");
}
#[test]
fn cells_with_comma_are_quoted() {
assert_eq!(cell_only("a,b"), "\"a,b\"");
}
#[test]
fn cells_with_double_quote_double_the_quote() {
assert_eq!(cell_only(r#"she said "hi""#), r#""she said ""hi""""#);
}
#[test]
fn cells_with_newlines_are_quoted() {
assert_eq!(cell_only("line1\nline2"), "\"line1\nline2\"");
assert_eq!(cell_only("line1\r\nline2"), "\"line1\r\nline2\"");
}
#[test]
fn row_uses_crlf_and_comma() {
assert_eq!(render_row(&["a", "b", "c"]), "a,b,c\r\n");
assert_eq!(render_row(&["a", "", "c"]), "a,,c\r\n");
}
#[test]
fn row_quotes_only_offending_cells() {
assert_eq!(
render_row(&["plain", "has,comma", "plain again"]),
"plain,\"has,comma\",plain again\r\n",
);
}
#[test]
fn wants_csv_accept_header_precedence() {
fn pick(accept: &str) -> bool {
for raw in accept.split(',') {
let bare = raw.split(';').next().unwrap_or(raw).trim();
if bare.eq_ignore_ascii_case("text/csv") {
return true;
}
if bare.eq_ignore_ascii_case("text/html")
|| bare.eq_ignore_ascii_case("application/json")
|| bare.eq_ignore_ascii_case("application/*")
{
return false;
}
}
false
}
assert!(pick("text/csv"));
assert!(pick("text/csv, text/html"));
assert!(pick("text/csv;q=0.9, */*;q=0.1"));
assert!(!pick("text/html, text/csv"));
assert!(!pick("application/json, text/csv"));
assert!(!pick("text/html"));
assert!(!pick("*/*"));
}
}