#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::fmt;
pub mod codes {
pub const LIMIT: &str = "constela.limit";
pub const SYNTAX: &str = "constela.syntax";
pub const VERSION: &str = "constela.version";
pub const STATE_TYPE: &str = "constela.state.type";
pub const UNKNOWN_REF: &str = "constela.unknown_ref";
pub const DUPLICATE: &str = "constela.duplicate";
pub const TAG_NOT_ALLOWED: &str = "constela.tag_not_allowed";
pub const ATTR_NOT_ALLOWED: &str = "constela.attr_not_allowed";
pub const URL_SCHEME: &str = "constela.url_scheme";
pub const MISPLACED: &str = "constela.misplaced";
pub const CYCLE: &str = "constela.cycle";
pub const STEP_SHAPE: &str = "constela.step_shape";
pub const FEATURE_REQUIRED: &str = "constela.feature_required";
pub const EVAL: &str = "constela.eval";
pub const RENDER_LIMIT: &str = "constela.render_limit";
}
pub const MAX_DIAGNOSTIC_FIELD_BYTES: usize = 4096;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
pub path: String,
pub code: &'static str,
pub message: String,
}
impl Diagnostic {
pub fn new(path: impl Into<String>, code: &'static str, message: impl Into<String>) -> Self {
Self {
path: clip(path.into()),
code,
message: clip(message.into()),
}
}
}
pub fn excerpt(text: &str) -> &str {
const MAX: usize = 256;
let mut end = text.len().min(MAX);
while !text.is_char_boundary(end) {
end = end.saturating_sub(1);
}
text.get(..end).unwrap_or_default()
}
fn clip(mut text: String) -> String {
const MARK: &str = "…";
if text.len() <= MAX_DIAGNOSTIC_FIELD_BYTES {
return text;
}
let mut end = MAX_DIAGNOSTIC_FIELD_BYTES.saturating_sub(MARK.len());
while !text.is_char_boundary(end) {
end = end.saturating_sub(1);
}
text.truncate(end);
text.push_str(MARK);
text.shrink_to_fit();
text
}
impl fmt::Display for Diagnostic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {} [{}]", self.path, self.message, self.code)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConstelaError {
Limit(Diagnostic),
Syntax(Diagnostic),
Invalid(Vec<Diagnostic>),
Render(Diagnostic),
}
impl ConstelaError {
#[must_use]
pub fn diagnostics(&self) -> &[Diagnostic] {
match self {
Self::Limit(d) | Self::Syntax(d) | Self::Render(d) => std::slice::from_ref(d),
Self::Invalid(ds) => ds,
}
}
#[must_use]
pub fn to_json(&self) -> serde_json::Value {
serde_json::Value::Array(
self.diagnostics()
.iter()
.map(|d| {
serde_json::json!({
"path": d.path,
"code": d.code,
"message": d.message,
})
})
.collect(),
)
}
}
impl fmt::Display for ConstelaError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let diagnostics = self.diagnostics();
let label = match self {
Self::Limit(_) => "constela document rejected",
Self::Syntax(_) => "constela document could not be parsed",
Self::Invalid(_) => "constela document is invalid",
Self::Render(_) => "constela document could not be rendered",
};
write!(f, "{label} ({} problem", diagnostics.len())?;
if diagnostics.len() != 1 {
f.write_str("s")?;
}
f.write_str(")")?;
for diagnostic in diagnostics {
write!(f, "\n - {diagnostic}")?;
}
Ok(())
}
}
impl std::error::Error for ConstelaError {}
impl ConstelaError {
#[must_use]
pub const fn http_status(&self) -> http::StatusCode {
http::StatusCode::UNPROCESSABLE_ENTITY
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_short_field_is_kept_whole() {
let d = Diagnostic::new("view", codes::LIMIT, "short");
assert_eq!(d.path, "view");
assert_eq!(d.message, "short");
}
#[test]
fn a_long_field_is_cut_and_marked() {
let d = Diagnostic::new("p".repeat(10_000), codes::LIMIT, "m".repeat(10_000));
assert!(d.path.len() <= MAX_DIAGNOSTIC_FIELD_BYTES);
assert!(d.message.len() <= MAX_DIAGNOSTIC_FIELD_BYTES);
assert!(d.path.ends_with('…'));
assert!(d.message.starts_with("mmm"));
}
#[test]
fn an_excerpt_is_short_and_whole_characters() {
assert_eq!(excerpt("short"), "short");
let long = "€".repeat(200);
let cut = excerpt(&long);
assert!(cut.len() <= 256);
assert!(cut.chars().all(|c| c == '€'));
}
#[test]
fn a_cut_never_splits_a_character() {
let d = Diagnostic::new("", codes::LIMIT, "€".repeat(5_000));
assert!(d.message.len() <= MAX_DIAGNOSTIC_FIELD_BYTES);
assert!(d.message.trim_end_matches('…').chars().all(|c| c == '€'));
}
}