use crate::doc_generator::{DocRegistry, renderer::Renderer, types::ErrorDoc};
use crate::traits::Role;
use std::format;
#[cfg(feature = "runtime-hash")]
use std::fs::File;
#[cfg(feature = "runtime-hash")]
use std::io::Write;
use std::path::Path;
use std::string::String;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CatalogFormat {
Full,
Compact,
Minimal,
}
pub struct CatalogRenderer {
format: CatalogFormat,
include_hints: bool,
include_tags: bool,
include_docs_url: bool,
}
impl CatalogRenderer {
pub fn new() -> Self {
Self {
format: CatalogFormat::Full,
include_hints: true,
include_tags: true,
include_docs_url: true,
}
}
pub fn compact() -> Self {
Self {
format: CatalogFormat::Compact,
include_hints: true,
include_tags: false,
include_docs_url: false,
}
}
pub fn minimal() -> Self {
Self {
format: CatalogFormat::Minimal,
include_hints: false,
include_tags: false,
include_docs_url: false,
}
}
pub fn with_format(mut self, format: CatalogFormat) -> Self {
self.format = format;
self
}
pub fn with_hints(mut self, include: bool) -> Self {
self.include_hints = include;
self
}
pub fn with_tags(mut self, include: bool) -> Self {
self.include_tags = include;
self
}
pub fn with_docs_url(mut self, include: bool) -> Self {
self.include_docs_url = include;
self
}
#[allow(dead_code, unused_variables, unused_mut)]
fn render_full(
&self,
registry: &DocRegistry,
errors: &[&ErrorDoc],
filter_role: Option<Role>,
) -> String {
let mut output = String::from("{\n");
output.push_str(&format!(" \"version\": \"{}\",\n", registry.version()));
let timestamp = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
Ok(dur) => dur.as_secs(),
Err(_) => 0,
};
output.push_str(&format!(" \"generated\": \"{}\",\n", timestamp));
#[cfg(feature = "runtime-hash")]
if let Some(first_error) = registry.first_error()
&& let Some(ns) = &first_error.namespace
{
output.push_str(&format!(" \"namespace\": \"{}\",\n", escape_json(ns)));
if let Some(ns_hash) = &first_error.namespace_hash {
output.push_str(&format!(" \"namespace_hash\": \"{}\",\n", ns_hash));
}
}
if let Some(role) = filter_role {
output.push_str(&format!(" \"role\": \"{:?}\",\n", role));
}
output.push_str(" \"diags\": {\n");
let mut first = true;
for error in errors {
#[cfg(feature = "runtime-hash")]
{
if let Some(key) = error.combined_id() {
if !first {
output.push_str(",\n");
}
first = false;
output.push_str(&format!(" \"{}\": {{\n", key));
output.push_str(&format!(" \"code\": \"{}\",\n", error.code));
output.push_str(&format!(" \"severity\": \"{}\",\n", error.severity));
let message_template = convert_to_wdp_placeholders(&error.message);
output.push_str(&format!(
" \"message\": \"{}\",\n",
escape_json(&message_template)
));
if !error.description.is_empty() {
output.push_str(&format!(
" \"description\": \"{}\",\n",
escape_json(&error.description)
));
}
if self.include_hints {
let hints = filter_role
.map(|r| error.hints_for_role(r))
.unwrap_or_default();
output.push_str(" \"hints\": [");
for (i, hint) in hints.iter().enumerate() {
if i > 0 {
output.push_str(", ");
}
output.push_str(&format!("\"{}\"", escape_json(hint)));
}
output.push_str("],\n");
}
if self.include_tags {
let tags = filter_role
.map(|r| error.tags_for_role(r))
.unwrap_or_default();
output.push_str(" \"tags\": [");
for (i, tag) in tags.iter().enumerate() {
if i > 0 {
output.push_str(", ");
}
output.push_str(&format!("\"{}\"", escape_json(tag)));
}
output.push_str("],\n");
}
if !error.fields.is_empty() {
output.push_str(" \"fields\": [");
for (i, field) in error.fields.iter().enumerate() {
if i > 0 {
output.push_str(", ");
}
output.push_str(&format!("\"{}\"", escape_json(field)));
}
output.push_str("],\n");
}
if self.include_docs_url
&& let Some(url) = &error.docs_url
{
output.push_str(&format!(" \"docs_url\": \"{}\",\n", url));
}
if output.ends_with(",\n") {
output.truncate(output.len() - 2);
output.push('\n');
}
output.push_str(" }");
}
}
#[cfg(not(feature = "runtime-hash"))]
{
let _ = error;
}
}
output.push_str("\n }\n");
output.push_str("}\n");
output
}
#[allow(dead_code, unused_variables, unused_mut)]
fn render_compact(
&self,
registry: &DocRegistry,
errors: &[&ErrorDoc],
filter_role: Option<Role>,
) -> String {
let mut output = String::from("{\"v\":\"");
output.push_str(registry.version());
output.push('"');
#[cfg(feature = "runtime-hash")]
if let Some(first_error) = registry.first_error()
&& let Some(ns) = &first_error.namespace
{
output.push_str(",\"ns\":\"");
output.push_str(&escape_json(ns));
output.push('"');
if let Some(ns_hash) = &first_error.namespace_hash {
output.push_str(",\"nsh\":\"");
output.push_str(ns_hash);
output.push('"');
}
}
output.push_str(",\"wd\":{");
let mut first = true;
for error in errors {
#[cfg(feature = "runtime-hash")]
{
if let Some(key) = error.combined_id() {
if !first {
output.push(',');
}
first = false;
output.push('"');
output.push_str(&key);
output.push_str("\":{\"c\":\"");
output.push_str(&error.code);
output.push_str("\",\"s\":\"");
output.push_str(&error.severity);
output.push_str("\",\"m\":\"");
let message_template = convert_to_wdp_placeholders(&error.message);
output.push_str(&escape_json(&message_template));
output.push('"');
if !error.description.is_empty() {
output.push_str(",\"d\":\"");
output.push_str(&escape_json(&error.description));
output.push('"');
}
if self.include_hints {
let hints = filter_role
.map(|r| error.hints_for_role(r))
.unwrap_or_default();
if !hints.is_empty() {
output.push_str(",\"h\":[");
for (i, hint) in hints.iter().enumerate() {
if i > 0 {
output.push(',');
}
output.push('"');
output.push_str(&escape_json(hint));
output.push('"');
}
output.push(']');
}
}
if self.include_tags {
let tags = filter_role
.map(|r| error.tags_for_role(r))
.unwrap_or_default();
if !tags.is_empty() {
output.push_str(",\"t\":[");
for (i, tag) in tags.iter().enumerate() {
if i > 0 {
output.push(',');
}
output.push('"');
output.push_str(&escape_json(tag));
output.push('"');
}
output.push(']');
}
}
if !error.fields.is_empty() {
output.push_str(",\"f\":[");
for (i, field) in error.fields.iter().enumerate() {
if i > 0 {
output.push(',');
}
output.push('"');
output.push_str(&escape_json(field));
output.push('"');
}
output.push(']');
}
output.push('}');
}
}
#[cfg(not(feature = "runtime-hash"))]
{
let _ = error;
}
}
output.push_str("}}");
output
}
#[allow(dead_code, unused_variables, unused_mut)]
fn render_minimal(&self, errors: &[&ErrorDoc], _filter_role: Option<Role>) -> String {
let mut output = String::from("{");
let mut first = true;
for error in errors {
#[cfg(feature = "runtime-hash")]
{
if let Some(key) = error.combined_id() {
if !first {
output.push(',');
}
first = false;
output.push('"');
output.push_str(&key);
output.push_str("\":[\"");
output.push_str(&error.code);
output.push_str("\",\"");
let message_template = convert_to_wdp_placeholders(&error.message);
output.push_str(&escape_json(&message_template));
output.push_str("\"]");
}
}
#[cfg(not(feature = "runtime-hash"))]
{
let _ = error;
}
}
output.push('}');
output
}
}
impl Default for CatalogRenderer {
fn default() -> Self {
Self::new()
}
}
impl Renderer for CatalogRenderer {
fn format_name(&self) -> &str {
match self.format {
CatalogFormat::Full => "catalog",
CatalogFormat::Compact => "catalog-compact",
CatalogFormat::Minimal => "catalog-min",
}
}
#[allow(unused_variables)]
fn render(
&self,
registry: &DocRegistry,
errors: &[&ErrorDoc],
output_path: &Path,
filter_role: Option<Role>,
) -> std::io::Result<()> {
#[cfg(not(feature = "runtime-hash"))]
{
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"Catalog renderer requires the 'hash' feature to be enabled",
))
}
#[cfg(feature = "runtime-hash")]
{
let content = match self.format {
CatalogFormat::Full => self.render_full(registry, errors, filter_role),
CatalogFormat::Compact => self.render_compact(registry, errors, filter_role),
CatalogFormat::Minimal => self.render_minimal(errors, filter_role),
};
let mut file = File::create(output_path)?;
file.write_all(content.as_bytes())?;
Ok(())
}
}
}
#[allow(dead_code)]
fn escape_json(s: &str) -> String {
let mut result = String::with_capacity(s.len() + 16);
for c in s.chars() {
match c {
'"' => result.push_str("\\\""),
'\\' => result.push_str("\\\\"),
'/' => result.push_str("\\/"), '\u{0008}' => result.push_str("\\b"), '\u{0009}' => result.push_str("\\t"), '\u{000A}' => result.push_str("\\n"), '\u{000C}' => result.push_str("\\f"), '\u{000D}' => result.push_str("\\r"), c if c < '\u{0020}' => {
result.push_str(&format!("\\u{:04x}", c as u32));
}
c => result.push(c),
}
}
result
}
#[allow(dead_code)]
fn convert_to_wdp_placeholders(message: &str) -> String {
let mut result = String::with_capacity(message.len() * 2);
let mut chars = message.chars().peekable();
while let Some(c) = chars.next() {
if c == '{' {
if chars.peek() == Some(&'{') {
result.push('{');
result.push(chars.next().unwrap());
} else {
result.push_str("{{");
}
} else if c == '}' {
if chars.peek() == Some(&'}') {
result.push('}');
result.push(chars.next().unwrap());
} else {
result.push_str("}}");
}
} else {
result.push(c);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_escape_json() {
assert_eq!(escape_json("hello"), "hello");
assert_eq!(escape_json("hello\"world"), "hello\\\"world");
assert_eq!(escape_json("back\\slash"), "back\\\\slash");
assert_eq!(escape_json("line1\nline2"), "line1\\nline2");
assert_eq!(escape_json("tab\there"), "tab\\there");
assert_eq!(escape_json("return\rhere"), "return\\rhere");
assert_eq!(escape_json("form\x0Cfeed"), "form\\ffeed");
assert_eq!(escape_json("back\x08space"), "back\\bspace");
assert_eq!(escape_json("null\x00char"), "null\\u0000char");
assert_eq!(escape_json("bell\x07ring"), "bell\\u0007ring");
assert_eq!(escape_json("escape\x1Bseq"), "escape\\u001bseq");
assert_eq!(
escape_json("quote\"tab\tnull\x00end"),
"quote\\\"tab\\tnull\\u0000end"
);
assert_eq!(escape_json("日本語"), "日本語");
assert_eq!(escape_json("emoji 🦆"), "emoji 🦆");
}
#[test]
fn test_convert_to_wdp_placeholders() {
assert_eq!(
convert_to_wdp_placeholders("Token expired at {timestamp}"),
"Token expired at {{timestamp}}"
);
assert_eq!(
convert_to_wdp_placeholders("User {user_id} has {count} items"),
"User {{user_id}} has {{count}} items"
);
assert_eq!(
convert_to_wdp_placeholders("Already {{formatted}}"),
"Already {{formatted}}"
);
assert_eq!(
convert_to_wdp_placeholders("No placeholders here"),
"No placeholders here"
);
}
#[test]
fn test_catalog_format_types() {
let full = CatalogRenderer::new();
assert_eq!(full.format_name(), "catalog");
let compact = CatalogRenderer::compact();
assert_eq!(compact.format_name(), "catalog-compact");
let minimal = CatalogRenderer::minimal();
assert_eq!(minimal.format_name(), "catalog-min");
}
#[test]
fn test_catalog_builder() {
let renderer = CatalogRenderer::new()
.with_format(CatalogFormat::Compact)
.with_hints(false)
.with_tags(false);
assert_eq!(renderer.format, CatalogFormat::Compact);
assert!(!renderer.include_hints);
assert!(!renderer.include_tags);
}
}