use std::fmt;
use serde::Serialize;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Location {
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub entity: Option<String>,
}
impl Location {
pub fn file(file: impl Into<String>) -> Location {
Location { file: Some(file.into()), ..Location::default() }
}
pub fn entity(entity: impl Into<String>) -> Location {
Location { entity: Some(entity.into()), ..Location::default() }
}
pub fn field(mut self, field: Option<String>) -> Location {
self.field = field;
self
}
}
impl fmt::Display for Location {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut first = true;
if let Some(file) = &self.file {
f.write_str(file)?;
first = false;
}
if let Some(field) = &self.field {
if !first {
f.write_str(" ")?;
}
if self.file.is_some() {
write!(f, "({field})")?;
} else {
f.write_str(field)?;
}
first = false;
}
if let Some(entity) = &self.entity {
if !first {
f.write_str(" ")?;
}
write!(f, "`{entity}`")?;
}
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Detail {
pub where_: Option<Location>,
pub why: Option<String>,
pub fix: Option<String>,
pub docs: Option<&'static str>,
}
impl Detail {
pub fn at(mut self, location: Location) -> Detail {
self.where_ = Some(location);
self
}
pub fn because(mut self, why: impl Into<String>) -> Detail {
self.why = Some(why.into());
self
}
pub fn fix(mut self, fix: impl Into<String>) -> Detail {
self.fix = Some(fix.into());
self
}
pub fn docs(mut self, docs: &'static str) -> Detail {
self.docs = Some(docs);
self
}
pub fn is_empty(&self) -> bool {
*self == Detail::default()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Problem {
pub what: String,
#[serde(rename = "where", skip_serializing_if = "Option::is_none")]
pub where_: Option<Location>,
#[serde(skip_serializing_if = "Option::is_none")]
pub why: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub docs: Option<&'static str>,
}
impl Problem {
pub fn new(what: impl Into<String>) -> Problem {
Problem { what: what.into(), where_: None, why: None, fix: None, docs: None }
}
pub fn with(what: impl Into<String>, detail: Detail) -> Problem {
Problem { what: what.into(), where_: detail.where_, why: detail.why, fix: detail.fix, docs: detail.docs }
}
pub fn detail(&self) -> Detail {
Detail { where_: self.where_.clone(), why: self.why.clone(), fix: self.fix.clone(), docs: self.docs }
}
pub fn line(&self) -> String {
let mut line = self.what.clone();
if let Some(location) = &self.where_ {
line.push_str(" — ");
line.push_str(&location.to_string());
if let Some(why) = &self.why {
line.push_str(": ");
line.push_str(why);
}
} else if let Some(why) = &self.why {
line.push_str(" — ");
line.push_str(why);
}
if let Some(fix) = &self.fix {
if !line.ends_with(['.', '!', '?']) {
line.push('.');
}
line.push_str(" Fix: ");
line.push_str(fix);
}
line
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::to_value(self).expect("a problem is strings and options of strings")
}
}
impl fmt::Display for Problem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.line())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_problem_with_only_a_sentence_reads_as_that_sentence() {
assert_eq!(Problem::new("NOT WRITTEN: read-only file system").line(), "NOT WRITTEN: read-only file system");
assert_eq!(Problem::new("x").to_json(), serde_json::json!({"what": "x"}));
}
#[test]
fn every_part_a_problem_has_is_in_its_line_and_in_its_json() {
let p = Problem::with(
"rigs.ron did not read",
Detail::default()
.at(Location::file("rigs.ron").field(Some("rigs[2]".into())))
.because("1:47: Expected float")
.fix("save a rigs.ron that reads")
.docs("docs/guide/reload-contract.md"),
);
assert_eq!(
p.line(),
"rigs.ron did not read — rigs.ron (rigs[2]): 1:47: Expected float. Fix: save a rigs.ron that reads"
);
assert_eq!(
p.to_json(),
serde_json::json!({
"what": "rigs.ron did not read",
"where": {"file": "rigs.ron", "field": "rigs[2]"},
"why": "1:47: Expected float",
"fix": "save a rigs.ron that reads",
"docs": "docs/guide/reload-contract.md",
})
);
}
#[test]
fn a_location_names_only_what_it_has() {
assert_eq!(Location::file("a.ron").to_string(), "a.ron");
assert_eq!(Location::file("a.ron").field(Some("version".into())).to_string(), "a.ron (version)");
assert_eq!(Location::entity("crate_b").to_string(), "`crate_b`");
let both = Location { entity: Some("crate_b".into()), ..Location::file("maps/y.map.ron") };
assert_eq!(both.to_string(), "maps/y.map.ron `crate_b`");
}
#[test]
fn a_fix_after_a_sentence_without_a_stop_gets_one() {
let p = Problem::with("`crate_b` is not on the map", Detail::default().fix("reopen the map"));
assert_eq!(p.line(), "`crate_b` is not on the map. Fix: reopen the map");
let q = Problem::with("Nothing to undo.", Detail::default().fix("make a change first"));
assert_eq!(q.line(), "Nothing to undo. Fix: make a change first");
}
#[test]
fn detail_round_trips_through_a_problem() {
let d = Detail::default().at(Location::entity("a")).because("b").fix("c");
assert_eq!(Problem::with("w", d.clone()).detail(), d);
}
}