use std::path::Path;
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use quick_xml::writer::Writer;
use std::io::Cursor;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrettifyMode {
Off,
Json,
Yaml,
Toml,
Xml,
Html,
Csv,
}
impl PrettifyMode {
pub fn label(self) -> &'static str {
match self {
Self::Off => "",
Self::Json => "json",
Self::Yaml => "yaml",
Self::Toml => "toml",
Self::Xml => "xml",
Self::Html => "html",
Self::Csv => "csv",
}
}
pub fn is_active(self) -> bool {
!matches!(self, Self::Off)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedType {
Mode(PrettifyMode),
Undetected,
}
pub fn parse_content_type(name: &str) -> Result<Option<PrettifyMode>, String> {
let lc = name.trim().to_ascii_lowercase();
let mode = match lc.as_str() {
"auto" => return Ok(None),
"raw" | "off" | "none" => PrettifyMode::Off,
"json" => PrettifyMode::Json,
"yaml" | "yml" => PrettifyMode::Yaml,
"toml" => PrettifyMode::Toml,
"xml" => PrettifyMode::Xml,
"html" | "htm" => PrettifyMode::Html,
"csv" => PrettifyMode::Csv,
other => {
return Err(format!(
"unknown content type `{other}` (try one of: \
auto, raw, json, yaml, toml, xml, html, csv)"
));
}
};
Ok(Some(mode))
}
pub fn detect_from_path(path: &Path) -> Option<PrettifyMode> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"json" => PrettifyMode::Json,
"yaml" | "yml" => PrettifyMode::Yaml,
"toml" => PrettifyMode::Toml,
"xml" => PrettifyMode::Xml,
"html" | "htm" => PrettifyMode::Html,
"csv" => PrettifyMode::Csv,
_ => return None,
})
}
pub fn detect_from_bytes(bytes: &[u8]) -> Option<PrettifyMode> {
let head_len = bytes.len().min(512);
let head = &bytes[..head_len];
let trimmed_start = head.iter().position(|b| !b.is_ascii_whitespace())?;
let trimmed = &head[trimmed_start..];
if trimmed.is_empty() {
return None;
}
if trimmed.starts_with(b"<?xml") {
return Some(PrettifyMode::Xml);
}
let head_lc: Vec<u8> = trimmed.iter().take(200).map(|b| b.to_ascii_lowercase()).collect();
if head_lc.starts_with(b"<!doctype html") || head_lc.starts_with(b"<html") {
return Some(PrettifyMode::Html);
}
if trimmed[0] == b'<' {
return Some(PrettifyMode::Xml);
}
if trimmed[0] == b'{' || trimmed[0] == b'[' {
return Some(PrettifyMode::Json);
}
if trimmed.starts_with(b"---") {
let rest = &trimmed[3..];
if rest.is_empty() || rest[0] == b'\n' || rest[0] == b'\r' {
return Some(PrettifyMode::Yaml);
}
}
None
}
pub fn resolve(
explicit: Option<PrettifyMode>,
path: Option<&Path>,
bytes: &[u8],
) -> ResolvedType {
if let Some(m) = explicit {
return ResolvedType::Mode(m);
}
if let Some(p) = path {
if let Some(m) = detect_from_path(p) {
return ResolvedType::Mode(m);
}
}
if let Some(m) = detect_from_bytes(bytes) {
return ResolvedType::Mode(m);
}
ResolvedType::Undetected
}
pub fn prettify(mode: PrettifyMode, input: &[u8]) -> Result<Vec<u8>, String> {
match mode {
PrettifyMode::Off => Ok(input.to_vec()),
PrettifyMode::Json => prettify_json(input),
PrettifyMode::Yaml => prettify_yaml(input),
PrettifyMode::Toml => prettify_toml(input),
PrettifyMode::Xml => prettify_xml(input, false),
PrettifyMode::Html => prettify_xml(input, true),
PrettifyMode::Csv => prettify_csv(input),
}
}
fn prettify_json(input: &[u8]) -> Result<Vec<u8>, String> {
let value: serde_json::Value =
serde_json::from_slice(input).map_err(|e| format!("json parse: {e}"))?;
let mut out = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?;
if !out.ends_with(b"\n") {
out.push(b'\n');
}
Ok(out)
}
fn prettify_yaml(input: &[u8]) -> Result<Vec<u8>, String> {
let s = std::str::from_utf8(input).map_err(|e| format!("yaml: utf-8: {e}"))?;
let value: serde_yml::Value =
serde_yml::from_str(s).map_err(|e| format!("yaml parse: {e}"))?;
serde_yml::to_string(&value)
.map(|s| s.into_bytes())
.map_err(|e| format!("yaml emit: {e}"))
}
fn prettify_toml(input: &[u8]) -> Result<Vec<u8>, String> {
let s = std::str::from_utf8(input).map_err(|e| format!("toml: utf-8: {e}"))?;
let value: toml::Value = s.parse().map_err(|e: toml::de::Error| format!("toml parse: {e}"))?;
toml::to_string_pretty(&value)
.map(|s| s.into_bytes())
.map_err(|e| format!("toml emit: {e}"))
}
fn prettify_xml(input: &[u8], lenient: bool) -> Result<Vec<u8>, String> {
let mut reader = Reader::from_reader(input);
let cfg = reader.config_mut();
cfg.trim_text(true);
if lenient {
cfg.check_end_names = false;
}
let mut writer = Writer::new_with_indent(Cursor::new(Vec::new()), b' ', 2);
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Eof) => break,
Ok(e) => writer
.write_event(e)
.map_err(|e| format!("xml emit: {e}"))?,
Err(e) => return Err(format!("xml parse: {e}")),
}
buf.clear();
}
let mut out = writer.into_inner().into_inner();
if !out.ends_with(b"\n") {
out.push(b'\n');
}
Ok(out)
}
fn prettify_csv(input: &[u8]) -> Result<Vec<u8>, String> {
const COL_CAP: usize = 60;
let mut rdr = csv::ReaderBuilder::new()
.has_headers(false)
.flexible(true)
.from_reader(input);
let records: Vec<csv::StringRecord> = rdr
.records()
.collect::<Result<_, _>>()
.map_err(|e| format!("csv parse: {e}"))?;
if records.is_empty() {
return Ok(Vec::new());
}
let cols = records.iter().map(|r| r.len()).max().unwrap_or(0);
let mut widths = vec![0usize; cols];
for r in &records {
for (i, cell) in r.iter().enumerate() {
let w = cell.chars().count().min(COL_CAP);
if w > widths[i] {
widths[i] = w;
}
}
}
let mut out = String::new();
for r in &records {
let mut parts: Vec<String> = Vec::with_capacity(cols);
for (i, width) in widths.iter().enumerate().take(cols) {
let cell = r.get(i).unwrap_or("");
let truncated: String = if cell.chars().count() > COL_CAP {
let mut s: String = cell.chars().take(COL_CAP - 1).collect();
s.push('…');
s
} else {
cell.to_string()
};
let pad = width.saturating_sub(truncated.chars().count());
parts.push(format!("{truncated}{}", " ".repeat(pad)));
}
out.push_str(&parts.join(" | "));
out.push('\n');
}
Ok(out.into_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_content_type_recognizes_aliases() {
assert_eq!(parse_content_type("auto").unwrap(), None);
assert_eq!(parse_content_type("raw").unwrap(), Some(PrettifyMode::Off));
assert_eq!(parse_content_type("JSON").unwrap(), Some(PrettifyMode::Json));
assert_eq!(parse_content_type(" yml ").unwrap(), Some(PrettifyMode::Yaml));
assert_eq!(parse_content_type("htm").unwrap(), Some(PrettifyMode::Html));
assert!(parse_content_type("nonsense").is_err());
}
#[test]
fn detect_from_path_recognizes_known_extensions() {
assert_eq!(detect_from_path(Path::new("a.json")), Some(PrettifyMode::Json));
assert_eq!(detect_from_path(Path::new("a.YAML")), Some(PrettifyMode::Yaml));
assert_eq!(detect_from_path(Path::new("a.yml")), Some(PrettifyMode::Yaml));
assert_eq!(detect_from_path(Path::new("a.toml")), Some(PrettifyMode::Toml));
assert_eq!(detect_from_path(Path::new("page.HTML")), Some(PrettifyMode::Html));
assert_eq!(detect_from_path(Path::new("data.csv")), Some(PrettifyMode::Csv));
assert_eq!(detect_from_path(Path::new("README")), None);
assert_eq!(detect_from_path(Path::new("a.txt")), None);
}
#[test]
fn detect_from_bytes_sniffs_json() {
assert_eq!(detect_from_bytes(b"{\"a\":1}"), Some(PrettifyMode::Json));
assert_eq!(detect_from_bytes(b" [1,2,3]"), Some(PrettifyMode::Json));
}
#[test]
fn detect_from_bytes_sniffs_xml_declaration() {
assert_eq!(detect_from_bytes(b"<?xml version=\"1.0\"?>"), Some(PrettifyMode::Xml));
}
#[test]
fn detect_from_bytes_sniffs_html_doctype_case_insensitive() {
assert_eq!(detect_from_bytes(b"<!DOCTYPE html>"), Some(PrettifyMode::Html));
assert_eq!(detect_from_bytes(b"<html><body>"), Some(PrettifyMode::Html));
}
#[test]
fn detect_from_bytes_sniffs_yaml_doc_marker() {
assert_eq!(detect_from_bytes(b"---\nkey: value\n"), Some(PrettifyMode::Yaml));
assert_eq!(detect_from_bytes(b"---changelog"), None);
}
#[test]
fn detect_from_bytes_falls_back_to_none() {
assert_eq!(detect_from_bytes(b"plain text"), None);
assert_eq!(detect_from_bytes(b""), None);
assert_eq!(detect_from_bytes(b" \n\t "), None);
}
#[test]
fn prettify_json_indents_compact_input() {
let out = prettify(PrettifyMode::Json, b"{\"a\":1,\"b\":[2,3]}").unwrap();
let s = String::from_utf8(out).unwrap();
assert!(s.contains("\"a\": 1"));
assert!(s.contains("\"b\":"));
assert!(s.matches('\n').count() >= 4);
}
#[test]
fn prettify_json_returns_error_on_bad_input() {
assert!(prettify(PrettifyMode::Json, b"{not json").is_err());
}
#[test]
fn prettify_yaml_round_trips() {
let out = prettify(PrettifyMode::Yaml, b"a: 1\nb:\n - 2\n - 3\n").unwrap();
let s = String::from_utf8(out).unwrap();
assert!(s.contains("a:"));
assert!(s.contains("b:"));
}
#[test]
fn prettify_toml_indents_compact_input() {
let out = prettify(PrettifyMode::Toml, b"a=1\nb=2\n[s]\nc=3\n").unwrap();
let s = String::from_utf8(out).unwrap();
assert!(s.contains("a = 1"));
assert!(s.contains("[s]"));
}
#[test]
fn prettify_xml_indents_with_text_preservation() {
let out = prettify(PrettifyMode::Xml, b"<root><a>x</a><b/></root>").unwrap();
let s = String::from_utf8(out).unwrap();
assert!(s.contains("<root>"));
assert!(s.contains("<a>x</a>"));
assert!(s.contains("\n "), "expected indented child, got: {s}");
}
#[test]
fn prettify_html_handles_unclosed_void_tags() {
let html = b"<html><body><br><img src=\"x\"></body></html>";
let out = prettify(PrettifyMode::Html, html).unwrap();
let s = String::from_utf8(out).unwrap();
assert!(s.contains("<html>"));
assert!(s.contains("<br"));
}
#[test]
fn prettify_csv_aligns_columns() {
let out = prettify(PrettifyMode::Csv, b"name,age\nalice,30\nbob,4\n").unwrap();
let s = String::from_utf8(out).unwrap();
let lines: Vec<&str> = s.lines().collect();
assert_eq!(lines.len(), 3);
let first_pipe: Vec<usize> = lines.iter().map(|l| l.find(" | ").unwrap()).collect();
assert!(first_pipe.windows(2).all(|w| w[0] == w[1]),
"expected aligned columns, got: {lines:?}");
}
#[test]
fn prettify_csv_truncates_long_cells() {
let big = "x".repeat(200);
let input = format!("a,{big}\n1,2\n");
let out = prettify(PrettifyMode::Csv, input.as_bytes()).unwrap();
let s = String::from_utf8(out).unwrap();
assert!(s.contains('…'), "expected ellipsis truncation, got: {s}");
}
#[test]
fn prettify_off_passes_through() {
let raw = b"arbitrary bytes\nwith newlines\n";
let out = prettify(PrettifyMode::Off, raw).unwrap();
assert_eq!(&out, raw);
}
#[test]
fn resolve_prefers_explicit_then_path_then_sniff() {
assert_eq!(
resolve(Some(PrettifyMode::Yaml), Some(Path::new("a.json")), b"{\"x\":1}"),
ResolvedType::Mode(PrettifyMode::Yaml)
);
assert_eq!(
resolve(None, Some(Path::new("a.json")), b"plain text"),
ResolvedType::Mode(PrettifyMode::Json)
);
assert_eq!(
resolve(None, None, b"<?xml version=\"1.0\"?><r/>"),
ResolvedType::Mode(PrettifyMode::Xml)
);
assert_eq!(resolve(None, None, b"plain text"), ResolvedType::Undetected);
}
}