use std::{fmt, io::IsTerminal};
use serde::{Deserialize, Serialize};
pub const DOCS_BASE: &str = "https://docs.sysg.dev/errors";
const RED: &str = "\x1b[1;31m";
const YELLOW: &str = "\x1b[1;33m";
const CYAN: &str = "\x1b[36m";
const GREEN: &str = "\x1b[32m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const UNDERLINE: &str = "\x1b[4;34m";
const RESET: &str = "\x1b[0m";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Severity {
Error,
Warning,
Note,
}
impl Severity {
fn label(self) -> &'static str {
match self {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Note => "note",
}
}
fn color(self) -> &'static str {
match self {
Severity::Error => RED,
Severity::Warning => YELLOW,
Severity::Note => CYAN,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Origin {
pub file: String,
pub line: Option<usize>,
pub key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Evidence {
pub label: String,
pub lines: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Help {
Command {
label: String,
cmd: String,
},
Link {
url: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Diagnostic {
pub severity: Severity,
pub code: String,
pub title: String,
pub origin: Option<Origin>,
pub notes: Vec<String>,
pub evidence: Vec<Evidence>,
pub help: Vec<Help>,
}
impl Diagnostic {
pub fn error(code: &str, title: impl Into<String>) -> Self {
Self {
severity: Severity::Error,
code: code.to_string(),
title: title.into(),
origin: None,
notes: Vec::new(),
evidence: Vec::new(),
help: Vec::new(),
}
}
pub fn origin(
mut self,
file: impl Into<String>,
line: Option<usize>,
key: Option<String>,
) -> Self {
self.origin = Some(Origin {
file: file.into(),
line,
key,
});
self
}
pub fn note(mut self, note: impl Into<String>) -> Self {
self.notes.push(note.into());
self
}
pub fn evidence(mut self, label: impl Into<String>, lines: Vec<String>) -> Self {
if !lines.is_empty() {
self.evidence.push(Evidence {
label: label.into(),
lines,
});
}
self
}
pub fn help_cmd(mut self, label: impl Into<String>, cmd: impl Into<String>) -> Self {
self.help.push(Help::Command {
label: label.into(),
cmd: cmd.into(),
});
self
}
pub fn help_docs(mut self) -> Self {
let url = format!("{DOCS_BASE}/{}", self.code);
self.help.push(Help::Link { url });
self
}
pub fn render_for_terminal(&self) -> String {
let color =
std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
self.render(color)
}
pub fn render(&self, color: bool) -> String {
let paint = |code: &'static str| if color { code } else { "" };
let reset = paint(RESET);
let mut out = String::new();
out.push_str(&format!(
"{}{}[{}]{}{}: {}{}\n",
paint(self.severity.color()),
self.severity.label(),
self.code,
reset,
paint(BOLD),
self.title,
reset,
));
if let Some(origin) = &self.origin {
let mut place = origin.file.clone();
if let Some(line) = origin.line {
place.push_str(&format!(":{line}"));
}
if let Some(key) = &origin.key {
place.push_str(&format!(" ({key})"));
}
out.push_str(&format!(" {}-->{} {}\n", paint(CYAN), reset, place));
}
if !self.notes.is_empty() {
out.push('\n');
for note in &self.notes {
out.push_str(&format!(" {note}\n"));
}
}
for block in &self.evidence {
out.push('\n');
out.push_str(&format!(" {}{}:{}\n", paint(DIM), block.label, reset));
for line in &block.lines {
out.push_str(&format!(" {}\u{2502}{} {}\n", paint(DIM), reset, line));
}
}
if !self.help.is_empty() {
out.push('\n');
out.push_str(&format!(" {}help:{}\n", paint(GREEN), reset));
let width = self
.help
.iter()
.map(|h| match h {
Help::Command { label, .. } => label.len(),
Help::Link { .. } => 4,
})
.max()
.unwrap_or(0);
for help in &self.help {
match help {
Help::Command { label, cmd } => {
out.push_str(&format!(
" {label:<width$} {}{}{}\n",
paint(BOLD),
cmd,
reset,
));
}
Help::Link { url } => {
out.push_str(&format!(
" {:<width$} {}{}{}\n",
"docs",
paint(UNDERLINE),
url,
reset,
));
}
}
}
}
out
}
}
impl fmt::Display for Diagnostic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.render(false))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Diagnostic {
Diagnostic::error("SG0104", "service `api` failed to become healthy")
.origin(
"sysg.yaml",
Some(31),
Some("services.api.health_check".into()),
)
.note("5 health checks failed over 45s")
.evidence(
"last output",
vec!["password authentication failed".to_string()],
)
.help_cmd("view logs", "sysg logs -s api")
.help_docs()
}
#[test]
fn plain_render_has_code_title_evidence_and_help() {
let text = sample().render(false);
assert!(text.contains("error[SG0104]"));
assert!(text.contains("failed to become healthy"));
assert!(text.contains("--> sysg.yaml:31 (services.api.health_check)"));
assert!(text.contains("password authentication failed"));
assert!(text.contains("sysg logs -s api"));
assert!(text.contains(&format!("{DOCS_BASE}/SG0104")));
assert!(!text.contains('\x1b'));
}
#[test]
fn colored_render_uses_ansi_and_survives_roundtrip() {
let text = sample().render(true);
assert!(text.contains(RED));
let json = serde_json::to_string(&sample()).unwrap();
let back: Diagnostic = serde_json::from_str(&json).unwrap();
assert_eq!(back.code, "SG0104");
assert_eq!(back.render(false), sample().render(false));
}
}