use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Note,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Note => "note",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
pub severity: Severity,
pub check: &'static str,
pub entity: String,
pub file: Option<String>,
pub path: Option<String>,
pub line: Option<(usize, usize)>,
pub message: String,
pub remedy: Option<String>,
}
impl Diagnostic {
fn new(severity: Severity, check: &'static str, entity: String, message: String) -> Self {
Self {
severity,
check,
entity,
file: None,
path: None,
line: None,
message,
remedy: None,
}
}
pub fn error(
check: &'static str,
entity: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self::new(Severity::Error, check, entity.into(), message.into())
}
pub fn warning(
check: &'static str,
entity: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self::new(Severity::Warning, check, entity.into(), message.into())
}
pub fn note(
check: &'static str,
entity: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self::new(Severity::Note, check, entity.into(), message.into())
}
pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
self.remedy = Some(remedy.into());
self
}
pub fn with_location(
mut self,
file: impl Into<String>,
path: Option<&str>,
line: Option<(usize, usize)>,
) -> Self {
self.file = Some(file.into());
self.path = path.map(str::to_string);
self.line = line;
self
}
pub fn from_field_error(
check: &'static str,
entity: &str,
err: &orion_api::FieldError,
) -> Self {
let mut out = Self::error(check, entity.to_string(), err.message.clone());
if !err.path.is_empty() {
out.path = Some(err.path.clone());
}
out
}
pub fn is_error(&self) -> bool {
self.severity == Severity::Error
}
pub fn is_warning(&self) -> bool {
self.severity == Severity::Warning
}
pub fn render_text(&self) -> String {
let mut out = String::new();
match (&self.file, self.line) {
(Some(file), Some((line, col))) => out.push_str(&format!("{file}:{line}:{col}: ")),
(Some(file), None) => out.push_str(&format!("{file}: ")),
(None, _) => {}
}
out.push_str(&format!(
"{}: [{}] {}",
self.severity.as_str(),
self.check,
self.entity
));
if let Some(path) = &self.path {
out.push_str(&format!(" at {path}"));
}
out.push_str(&format!(": {}", self.message));
if let Some(remedy) = &self.remedy {
out.push_str(&format!("\n fix: {remedy}"));
}
out
}
pub fn render_json(&self) -> Value {
serde_json::json!({
"level": self.severity.as_str(),
"rule": self.check,
"entity": self.entity,
"file": self.file,
"path": self.path,
"line": self.line.map(|(l, _)| l),
"column": self.line.map(|(_, c)| c),
"message": self.message,
"remedy": self.remedy,
})
}
pub fn render_preflight(&self) -> String {
let mut out = format!("[{}] {}\n {}", self.check, self.entity, self.message);
if let Some(remedy) = &self.remedy {
out.push_str(&format!("\n fix: {remedy}"));
}
out
}
}
impl std::fmt::Display for Diagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.render_text())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unlocated_diagnostic_renders_the_line_lint_always_printed() {
let d = Diagnostic::error("closure.connector", "workflow 'a'", "no such connector");
assert_eq!(
d.to_string(),
"error: [closure.connector] workflow 'a': no such connector"
);
}
#[test]
fn a_remedy_is_indented_under_its_line() {
let d = Diagnostic::warning("x.y", "workflow 'a'", "odd").with_remedy("do the other thing");
assert_eq!(
d.to_string(),
"warning: [x.y] workflow 'a': odd\n fix: do the other thing"
);
}
#[test]
fn a_located_diagnostic_carries_file_line_and_path() {
let d = Diagnostic::error("perf.x", "workflow 'a'", "slow").with_location(
"wf.json",
Some("tasks[1]"),
Some((7, 3)),
);
assert_eq!(
d.to_string(),
"wf.json:7:3: error: [perf.x] workflow 'a' at tasks[1]: slow"
);
}
#[test]
fn a_file_without_a_line_still_prefixes_the_file() {
let d = Diagnostic::error("perf.x", "workflow 'a'", "slow")
.with_location("wf.json", None, None);
assert_eq!(d.to_string(), "wf.json: error: [perf.x] workflow 'a': slow");
}
#[test]
fn the_json_shape_keeps_its_published_key_names() {
let d = Diagnostic::error("perf.x", "workflow 'a'", "slow").with_location(
"wf.json",
Some("tasks[1]"),
Some((7, 3)),
);
let json = d.render_json();
assert_eq!(json["rule"], "perf.x");
assert_eq!(json["level"], "error");
assert_eq!(json["line"], 7);
assert_eq!(json["column"], 3);
assert!(json.get("check").is_none(), "the wire name is `rule`");
}
#[test]
fn preflight_renders_its_three_line_form() {
let d = Diagnostic::error("14", "workflow 'w'", "its stored tasks are not valid JSON")
.with_remedy("repair the tasks_json column");
assert_eq!(
d.render_preflight(),
"[14] workflow 'w'\n its stored tasks are not valid JSON\n fix: repair the \
tasks_json column"
);
}
#[test]
fn a_field_error_keeps_its_path() {
let err = orion_api::FieldError::new(
"tasks[0].function.name",
"unknown_value",
"no such function",
);
let d = Diagnostic::from_field_error("schema.workflow", "workflow 'w'", &err);
assert_eq!(d.path.as_deref(), Some("tasks[0].function.name"));
assert_eq!(d.message, "no such function");
}
}