use std::fmt;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Code {
UnknownField,
RemovedField,
MissingField,
InvalidValue,
Conflict,
UnknownReference,
SensitiveValue,
Refused,
}
impl Code {
#[must_use]
pub fn id(self) -> &'static str {
match self {
Code::UnknownField => "unknown-field",
Code::RemovedField => "removed-field",
Code::MissingField => "missing-field",
Code::InvalidValue => "invalid-value",
Code::Conflict => "conflict",
Code::UnknownReference => "unknown-reference",
Code::SensitiveValue => "sensitive-value",
Code::Refused => "refused",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct Span {
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize)]
#[serde(transparent)]
pub struct FieldPath(String);
impl FieldPath {
#[must_use]
pub fn new(path: impl Into<String>) -> FieldPath {
FieldPath(path.into())
}
#[must_use]
pub fn key(&self, key: &str) -> FieldPath {
if self.0.is_empty() {
FieldPath(key.to_owned())
} else {
FieldPath(format!("{}.{key}", self.0))
}
}
#[must_use]
pub fn index(&self, i: usize) -> FieldPath {
FieldPath(format!("{}[{i}]", self.0))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn parent(&self) -> Option<FieldPath> {
let cut = self.0.rfind(['.', '['])?;
self.0.get(..cut).map(|parent| FieldPath(parent.to_owned()))
}
#[must_use]
pub fn within(&self, other: &FieldPath) -> bool {
self.0 == other.0 || self.0.strip_prefix(other.0.as_str()).is_some_and(|rest| rest.starts_with(['.', '[']))
}
}
impl fmt::Display for FieldPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Problem {
pub code: Code,
pub path: FieldPath,
#[serde(flatten, skip_serializing_if = "Option::is_none")]
pub span: Option<Span>,
pub reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub fix: Option<String>,
}
impl Problem {
#[must_use]
pub fn new(code: Code, path: FieldPath, reason: impl Into<String>) -> Problem {
Problem {
code,
path,
span: None,
reason: reason.into(),
fix: None,
}
}
#[must_use]
pub fn fix(mut self, fix: impl Into<String>) -> Problem {
self.fix = Some(fix.into());
self
}
#[must_use]
pub fn render(&self, file: &str) -> String {
let head = format!("error[{}] {}", self.code.id(), self.path);
lines(&head, self.span, file, &self.reason, self.fix.as_deref())
}
#[must_use]
pub fn line(&self) -> String {
format!("{}: {}", self.path, self.reason)
}
#[must_use]
pub fn section(&self) -> &str {
self.path.as_str().split(['.', '[']).next().unwrap_or_default()
}
}
impl fmt::Display for Problem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.render("isoloom.yml"))
}
}
pub type Problems = Vec<Problem>;
#[must_use]
pub fn closest<'a>(word: &str, known: &[&'a str]) -> Option<&'a str> {
known
.iter()
.map(|k| (distance(word, k), *k))
.filter(|(d, k)| *d <= 2.max(k.len() / 4))
.min_by_key(|(d, _)| *d)
.map(|(_, k)| k)
}
fn distance(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut row: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.chars().enumerate() {
let mut next = Vec::with_capacity(row.len());
next.push(i + 1);
for ((cb, diagonal), above) in b.iter().zip(&row).zip(row.iter().skip(1)) {
let left = next.last().copied().unwrap_or_default();
next.push((diagonal + usize::from(ca != *cb)).min(above + 1).min(left + 1));
}
row = next;
}
row.last().copied().unwrap_or_default()
}
pub(crate) fn lines(head: &str, span: Option<Span>, file: &str, reason: &str, fix: Option<&str>) -> String {
let place = span.map(|s| format!(" ({file}:{}:{})", s.line, s.column)).unwrap_or_default();
let mut out = format!("{head}{place}\n {reason}");
if let Some(fix) = fix {
out.push_str("\n fix: ");
out.push_str(fix);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_problem_reads_on_one_line_as_its_path_and_reason() {
let p = Problem::new(Code::Refused, FieldPath::new("machines.web.app.build"), "a Dockerfile builds a container only");
assert_eq!(p.line(), "machines.web.app.build: a Dockerfile builds a container only");
assert_eq!(p.to_string(), p.render("isoloom.yml"));
assert_eq!(p.to_string(), "error[refused] machines.web.app.build\n a Dockerfile builds a container only");
}
#[test]
fn a_problem_reads_as_three_lines() {
let mut p = Problem::new(Code::RemovedField, FieldPath::new("machines.web.docker"), "`docker:` is gone").fix("`app: { image: <ref> }`");
assert_eq!(
p.render("isoloom.yml"),
"error[removed-field] machines.web.docker\n `docker:` is gone\n fix: `app: { image: <ref> }`"
);
p.span = Some(Span { line: 12, column: 5 });
assert!(
p.render("isoloom.yml")
.starts_with("error[removed-field] machines.web.docker (isoloom.yml:12:5)\n")
);
let json = serde_json::to_value(&p).unwrap();
assert_eq!(json["code"], "removed-field");
assert_eq!((json["line"].as_u64(), json["column"].as_u64()), (Some(12), Some(5)));
assert_eq!(p.section(), "machines");
}
#[test]
fn a_typo_finds_the_name_it_meant() {
assert_eq!(closest("nested-virtualisatio", &["nested_virtualization"]), Some("nested_virtualization"));
assert_eq!((distance("a", "b"), distance("on", "no"), distance("flaw", "lawn")), (1, 2, 2));
assert_eq!(closest("netwrks", &["networks", "ports"]), Some("networks"));
assert_eq!(closest("dc1", &["dc01", "ws01"]), Some("dc01"));
assert_eq!(closest("zzz", &["networks"]), None);
assert_eq!(distance("kitten", "sitting"), 3);
assert_eq!((distance("", "abc"), distance("abc", "")), (3, 3));
}
#[test]
fn paths_nest_and_contain() {
let web = FieldPath::new("machines").key("web");
let file = web.key("files").index(0);
assert_eq!(file.as_str(), "machines.web.files[0]");
assert!(file.within(&web) && !web.within(&file) && !FieldPath::new("machines.webs").within(&web));
assert_eq!(file.parent(), Some(web.key("files")));
}
}