// Reports: what a caller gets back after validating documents.
//
// Two renderings are supported:
// - text: one line per error, easy to read in a terminal or a CI log
// - JSON: a machine-readable document for CI, dashboards and scripts
//
// Both renderings are pure functions over a `RunReport`, so they are unit
// tested without any I/O.
///|
/// Output format of a validation report.
pub enum ReportFormat {
/// One line per error (default).
Text
/// A JSON document, for tools.
Json
} derive(Eq, @debug.Debug)
///|
/// The text report format.
///
/// A constructor function, so callers in other packages do not have to spell
/// out the variant.
pub fn ReportFormat::text() -> ReportFormat {
Text
}
///|
/// The JSON report format.
pub fn ReportFormat::json() -> ReportFormat {
Json
}
///|
/// The validation outcome for one data document.
pub struct FileResult {
/// Name of the document, usually the path that was read.
name : String
/// Errors found; empty means the document is valid.
errors : Array[ValidationError]
} derive(Eq, @debug.Debug)
///|
/// Create a `FileResult`.
pub fn FileResult::new(
name : String,
errors : Array[ValidationError],
) -> FileResult {
{ name, errors, }
}
///|
/// The result of validating one schema against one or more data documents.
pub struct RunReport {
/// Name of the schema document.
schema : String
/// Per-document results, in input order.
results : Array[FileResult]
} derive(Eq, @debug.Debug)
///|
/// Create a `RunReport`.
pub fn RunReport::new(
schema : String,
results : Array[FileResult],
) -> RunReport {
{ schema, results, }
}
///|
/// Number of documents that failed validation.
pub fn RunReport::failed(report : RunReport) -> Int {
let mut count = 0
for result in report.results {
if !result.errors.is_empty() {
count = count + 1
}
}
return count
}
///|
/// Total number of errors across all documents.
pub fn RunReport::total_errors(report : RunReport) -> Int {
let mut count = 0
for result in report.results {
count = count + result.errors.length()
}
return count
}
///|
/// `true` when every document is valid.
pub fn RunReport::is_ok(report : RunReport) -> Bool {
report.total_errors() == 0
}
///|
/// Render the report as text.
///
/// With a single data document the output is just the error lines, which is
/// the friendly case for one-off checks. With several documents each line is
/// prefixed by the document name and a summary line is appended, which is the
/// useful case for CI.
pub fn render_text(report : RunReport, quiet : Bool) -> String {
let lines : Array[String] = []
let multiple = report.results.length() > 1
for result in report.results {
if result.errors.is_empty() {
if !quiet && multiple {
lines.push("ok: \{result.name}")
}
} else {
for error in result.errors {
if multiple {
lines.push("\{result.name}: \{to_string(error)}")
} else {
lines.push(to_string(error))
}
}
}
}
if multiple && !quiet {
let ok = report.results.length() - report.failed()
lines.push(
"checked \{report.results.length()} document(s): \{ok} ok, \{report.failed()} failed, \{report.total_errors()} error(s)",
)
}
return join_lines(lines)
}
///|
/// Render the report as a JSON document.
///
/// Shape:
/// ```json
/// {
/// "schema": "schema.json",
/// "ok": false,
/// "summary": { "checked": 2, "failed": 1, "errors": 2 },
/// "documents": [
/// { "path": "a.json", "ok": true, "errors": [] },
/// { "path": "b.json", "ok": false,
/// "errors": [ { "path": "$.age", "kind": "type_mismatch", "message": "..." } ] }
/// ]
/// }
/// ```
pub fn render_json(report : RunReport) -> String {
let documents : Array[Json] = []
for result in report.results {
let errors : Array[Json] = []
for error in result.errors {
let entry : Map[String, Json] = Map([])
entry.set("path", Json::string(error.path))
entry.set("kind", Json::string(error_kind_name(error.kind)))
entry.set("message", Json::string(error.message))
errors.push(Json::object(entry))
}
let document : Map[String, Json] = Map([])
document.set("path", Json::string(result.name))
document.set("ok", Json::boolean(result.errors.is_empty()))
document.set("errors", Json::array(errors))
documents.push(Json::object(document))
}
let summary : Map[String, Json] = Map([])
summary.set("checked", Json::number(report.results.length().to_double()))
summary.set("failed", Json::number(report.failed().to_double()))
summary.set("errors", Json::number(report.total_errors().to_double()))
let root : Map[String, Json] = Map([])
root.set("schema", Json::string(report.schema))
root.set("ok", Json::boolean(report.is_ok()))
root.set("summary", Json::object(summary))
root.set("documents", Json::array(documents))
return Json::object(root).stringify(indent=2)
}
///|
/// Render the report in the requested format.
///
/// `quiet` only affects the text format; the JSON format is already
/// machine-oriented and always contains everything.
pub fn render(
report : RunReport,
format : ReportFormat,
quiet : Bool,
) -> String {
match format {
Json => render_json(report)
Text => render_text(report, quiet)
}
}
///|
fn join_lines(lines : Array[String]) -> String {
let mut out = ""
for line in lines {
out = if out == "" { line } else { "\{out}\n\{line}" }
}
return out
}