use std::sync::OnceLock;
use webui::{Diagnostic, ParserError, WebUIError};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum OutputFormat {
#[default]
Human,
Json,
}
static FORMAT: OnceLock<OutputFormat> = OnceLock::new();
pub fn set_format(format: OutputFormat) {
let _ = FORMAT.set(format);
}
#[must_use]
pub fn format() -> OutputFormat {
FORMAT.get().copied().unwrap_or_default()
}
#[must_use]
fn is_json() -> bool {
matches!(format(), OutputFormat::Json)
}
pub fn header(title: &str) {
if is_json() {
return;
}
eprintln!(
"\n {} {}\n",
console::style("⚡").cyan().bold(),
console::style(title).cyan().bold()
);
}
pub fn field(label: &str, value: &dyn std::fmt::Display) {
if is_json() {
return;
}
eprintln!(
" {} {}",
console::style(format!("▸ {label:<10}")).dim(),
console::style(value).bold()
);
}
pub fn success(message: &str) {
if is_json() {
return;
}
eprintln!(" {} {message}", console::style("✔").green());
}
pub fn finish(message: &str) {
if is_json() {
return;
}
eprintln!("\n {} {message}\n", console::style("✨").green());
}
fn template_diagnostic(err: &anyhow::Error) -> Option<&Diagnostic> {
err.chain()
.find_map(|cause| match cause.downcast_ref::<WebUIError>() {
Some(WebUIError::Parse {
source: ParserError::Template(diag),
..
}) => Some(&**diag),
_ => None,
})
}
#[must_use]
fn error_json(err: &anyhow::Error) -> serde_json::Value {
use serde_json::Value;
let chain: Vec<Value> = err.chain().map(|c| Value::from(c.to_string())).collect();
let str_or_null = |value: Option<&str>| value.map_or(Value::Null, Value::from);
let mut map = serde_json::Map::new();
match template_diagnostic(err) {
Some(diag) => {
let (line, column) = match diag.position_line_column() {
Some((line, column)) => (Value::from(line), Value::from(column)),
None => (Value::Null, Value::Null),
};
map.insert("severity".into(), Value::from(diag.severity().label()));
map.insert("code".into(), str_or_null(diag.error_code()));
map.insert("message".into(), Value::from(diag.title()));
map.insert("file".into(), str_or_null(diag.component_name()));
map.insert("line".into(), line);
map.insert("column".into(), column);
map.insert("snippet".into(), str_or_null(diag.snippet_text()));
map.insert("help".into(), str_or_null(diag.help_text()));
}
None => {
map.insert("severity".into(), Value::from("error"));
map.insert("code".into(), Value::Null);
map.insert("message".into(), Value::from(err.to_string()));
map.insert("file".into(), Value::Null);
map.insert("line".into(), Value::Null);
map.insert("column".into(), Value::Null);
map.insert("snippet".into(), Value::Null);
map.insert("help".into(), Value::Null);
}
}
map.insert("chain".into(), Value::Array(chain));
Value::Object(map)
}
pub fn error(err: &anyhow::Error) {
if is_json() {
println!("{}", error_json(err));
return;
}
if let Some(diag) = template_diagnostic(err) {
diagnostic(diag);
return;
}
eprintln!(
"\n {} {}",
console::style("✘").red().bold(),
console::style(err).red().bold()
);
for cause in err.chain().skip(1) {
eprintln!(" {} {cause}", console::style("caused by:").dim());
}
}
#[must_use]
pub fn build_error_renderings(err: &anyhow::Error) -> (String, String) {
match template_diagnostic(err) {
Some(diag) => (styled_diagnostic_body(diag), diag.body()),
None => {
let flat = format!("{err:#}");
(flat.clone(), flat)
}
}
}
fn styled_diagnostic_body(diag: &Diagnostic) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(128);
let _ = write!(out, "{}", console::style(diag.title()).red().bold());
if let Some(code) = diag.error_code() {
let _ = write!(out, " {}", console::style(format!("[{code}]")).dim());
}
if let Some(location) = diag.location() {
let _ = write!(out, "\n {}", console::style(location).dim());
}
if let Some(snippet) = diag.snippet_text() {
let _ = write!(out, "\n {}", console::style(snippet).yellow());
}
if let Some(help) = diag.help_text() {
let _ = write!(out, "\n {} {help}", console::style("help:").cyan().bold());
}
out
}
pub fn diagnostic(diag: &Diagnostic) {
let code = diag
.error_code()
.map(|c| format!(" {}", console::style(format!("[{c}]")).dim()))
.unwrap_or_default();
eprintln!(
"\n {} {}{}",
console::style("✘").red().bold(),
console::style(format!("{}: {}", diag.severity().label(), diag.title()))
.red()
.bold(),
code
);
if let Some(location) = diag.location() {
eprintln!(" {}", console::style(location).dim());
}
if let Some(snippet) = diag.snippet_text() {
eprintln!(" {}", console::style(snippet).yellow());
}
if let Some(help) = diag.help_text() {
eprintln!(" {} {help}", console::style("help:").cyan().bold());
}
}
pub fn hint(message: &str) {
if is_json() {
return;
}
eprintln!("\n {} {message}", console::style("hint:").dim());
}
#[cfg(test)]
mod tests {
use super::*;
fn template_error() -> anyhow::Error {
let diag = Diagnostic::error("invalid <for> each expression")
.code("invalid-for-each")
.component("index.html")
.position(67, 5)
.snippet(r#"each="person inpeople""#)
.help(r#"use the form each="item in collection", e.g. each="todo in todos""#);
WebUIError::Parse {
context: "Failed to parse index.html".to_owned(),
source: ParserError::Template(Box::new(diag)),
}
.into()
}
#[test]
fn build_error_message_is_plain_for_browser() {
let prev = console::colors_enabled();
console::set_colors_enabled(true);
let (_display, message) = build_error_renderings(&template_error());
console::set_colors_enabled(prev);
assert!(
!message.contains('\u{1b}'),
"browser message must not contain ANSI escapes: {message:?}"
);
assert!(message.contains("invalid <for> each expression"));
assert!(message.contains("--> index.html:67:5"));
assert!(message.contains(r#"each="person inpeople""#));
assert!(message.contains("help:"));
}
#[test]
fn build_error_display_is_colorized_for_terminal() {
let prev = console::colors_enabled();
console::set_colors_enabled(true);
let (display, _message) = build_error_renderings(&template_error());
console::set_colors_enabled(prev);
assert!(
display.contains('\u{1b}'),
"terminal display should be colorized when color is enabled: {display:?}"
);
for line in display.lines() {
if line.contains('\u{1b}') {
assert!(
line.contains("\u{1b}[0m"),
"colored line must reset within itself: {line:?}"
);
}
}
assert!(console::strip_ansi_codes(&display).contains("invalid <for> each expression"));
assert!(console::strip_ansi_codes(&display).contains("--> index.html:67:5"));
}
#[test]
fn build_error_falls_back_to_flat_chain_without_diagnostic() {
let err: anyhow::Error = WebUIError::Serialization("bad state json".to_owned()).into();
let (display, message) = build_error_renderings(&err);
assert_eq!(display, message);
assert!(message.contains("bad state json"));
assert!(!message.contains('\u{1b}'));
}
#[test]
fn error_json_for_diagnostic_has_structured_fields() {
let value = error_json(&template_error());
assert_eq!(value["severity"], "error");
assert_eq!(value["code"], "invalid-for-each");
assert_eq!(value["message"], "invalid <for> each expression");
assert_eq!(value["file"], "index.html");
assert_eq!(value["line"], 67);
assert_eq!(value["column"], 5);
assert_eq!(value["snippet"], "each=\"person inpeople\"");
assert!(value["help"]
.as_str()
.is_some_and(|h| h.contains("item in collection")));
assert!(value["chain"].is_array());
assert!(!value.to_string().contains('\u{1b}'));
}
#[test]
fn error_json_without_diagnostic_has_consistent_shape() {
let err: anyhow::Error = WebUIError::Serialization("bad state json".to_owned()).into();
let value = error_json(&err);
assert_eq!(value["severity"], "error");
assert!(value["code"].is_null());
assert!(value["message"]
.as_str()
.is_some_and(|m| m.contains("bad state json")));
assert!(value["chain"].is_array());
for key in ["file", "line", "column", "snippet", "help"] {
assert!(
value.get(key).is_some_and(serde_json::Value::is_null),
"expected `{key}` to be present and null"
);
}
}
}