Skip to main content

acorn/analyzer/
error.rs

1//! Error module for handling check errors
2//!
3use super::readability::ReadabilityType;
4use crate::analyzer::vale::ValeOutputItem;
5use crate::io::document::{DocumentPath, DocumentQuery};
6use crate::prelude::HashMap;
7use crate::util::MarkdownSupport;
8use bon::Builder;
9use core::fmt;
10use derive_more::Display;
11use serde_json::Value;
12use validator::{ValidationError, ValidationErrorsKind};
13
14/// Trait for findings that can identify their target within a source document.
15pub trait DocumentTarget {
16    /// Return a string indicating where the issue is located, for use in check titles, summaries, etc.
17    fn locator(&self) -> String;
18    /// Return source text
19    fn source_text(&self) -> String;
20    /// Return format-neutral criteria for resolving the finding in its original document.
21    fn query(&self) -> DocumentQuery;
22}
23/// Augmented list of validator crate error codes, with custom codes for ACORN-specific validation errors
24#[derive(Clone, Debug, Display)]
25#[display(rename_all = "lowercase")]
26pub enum ErrorCode {
27    /// Invalid ARK identifier
28    Ark,
29    /// Invalid ISO 8601 date
30    Date,
31    /// Invalid DOI
32    Doi,
33    /// Invalid email address
34    Email,
35    /// Invalid Unix epoch timestamp
36    Epoch,
37    /// Unsupported image file extension
38    Image,
39    /// Invalid IPv6 address
40    Ip6,
41    /// Invalid ISBN
42    Isbn,
43    /// Invalid kebab-case string
44    KebabCase,
45    /// Invalid latitude
46    Latitude,
47    /// Invalid longitude
48    Longitude,
49    /// Value exceeds allowed length
50    Length,
51    /// Invalid ORCiD
52    Orcid,
53    /// Other
54    Other,
55    /// Invalid patent identifier
56    Patent,
57    /// Invalid phone number
58    Phone,
59    /// Invalid polygon
60    Polygon,
61    /// Invalid RAiD identifier
62    Raid,
63    /// Value outside allowed range
64    Range,
65    /// Invalid ROR identifier
66    Ror,
67    /// Research activity prose section (see [`schema::research_activity::Sections`])
68    Section,
69    /// Invalid URL
70    Url,
71    /// Invalid ISO 8601 year
72    Year,
73}
74/// Error kind
75#[derive(Clone, Debug)]
76pub enum ErrorKind {
77    /// Readability issue where calculated index exceeds threshold of associated metric
78    Readability((f64, ReadabilityType)),
79    /// Prose issue found by Vale
80    Vale(Vec<ValeOutputItem>),
81    /// Schema validation issue found by [validator crate]
82    ///
83    /// [validator crate]: https://crates.io/crates/validator
84    Validator(ValidationErrorsKind),
85}
86/// Flattened schema validation issue.
87#[derive(Builder, Clone, Debug)]
88#[builder(start_fn = init, on(String, into))]
89pub struct ValidatorIssue {
90    pub(crate) code: ErrorCode,
91    pub(crate) path: Option<String>,
92    pub(crate) message: String,
93    pub(crate) params: HashMap<String, Value>,
94}
95impl fmt::Display for ErrorKind {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        write!(f, "{:#?}", self)
98    }
99}
100impl From<&str> for ErrorCode {
101    fn from(code: &str) -> Self {
102        match code.to_lowercase().as_str() {
103            | "ark" => Self::Ark,
104            | "iso 8601 date" | "date" => Self::Date,
105            | "doi" => Self::Doi,
106            | "email" => Self::Email,
107            | "epoch" | "unix epoch" => Self::Epoch,
108            | "image" => Self::Image,
109            | "ip6" => Self::Ip6,
110            | "isbn" => Self::Isbn,
111            | "kebabcase" => Self::KebabCase,
112            | "latitude" => Self::Latitude,
113            | "longitude" => Self::Longitude,
114            | "length" => Self::Length,
115            | "orcid" => Self::Orcid,
116            | "patent" => Self::Patent,
117            | "phone" => Self::Phone,
118            | "polygon" => Self::Polygon,
119            | "raid" | "raìd" => Self::Raid,
120            | "range" => Self::Range,
121            | "ror" => Self::Ror,
122            | "section" | "sections.approach" | "sections.areas" | "sections.capabilities" | "sections.impact" => Self::Section,
123            | "url" | "urls" => Self::Url,
124            | "year" => Self::Year,
125            | _ => Self::Other,
126        }
127    }
128}
129impl ErrorKind {
130    /// Process errors to a list of `(code, path, message, params)` tuples.
131    pub fn process(self, prefix: &str) -> Vec<ValidatorIssue> {
132        match self {
133            | ErrorKind::Validator(kind) => process(prefix, &kind),
134            | _ => vec![],
135        }
136    }
137}
138impl DocumentTarget for ValidatorIssue {
139    /// Return title-friendly locator — "where to find the issue"
140    fn locator(&self) -> String {
141        let value = match self.path.as_deref() {
142            | Some(value) => match self.params.get("index") {
143                | Some(Value::Number(index)) => format!("{}[{}]", value, index),
144                | None | Some(_) => value.to_string(),
145            },
146            | None => self.code.to_string().to_uppercase(),
147        };
148        value.replace("r#", "")
149    }
150    /// Return issue source text used by schema highlighting
151    fn source_text(&self) -> String {
152        match self.params.get("value") {
153            | Some(Value::Array(items)) => items.iter().map(|item| item.to_string()).collect::<Vec<String>>().to_markdown(),
154            | Some(value) => value.to_string(),
155            | None => " ".to_string(),
156        }
157    }
158    fn query(&self) -> DocumentQuery {
159        let locator = self.locator();
160        let query = DocumentQuery::new().with_path(DocumentPath::parse(&locator));
161        let value = match self.params.get("value") {
162            | Some(Value::Array(values)) => self
163                .params
164                .get("index")
165                .and_then(Value::as_u64)
166                .and_then(|index| values.get(index as usize)),
167            | value => value,
168        };
169
170        match value {
171            | Some(Value::String(value)) => query.with_value(value),
172            | Some(value) if !value.is_array() && !value.is_object() => query.with_value(value.to_string()),
173            | None | Some(_) => query,
174        }
175    }
176}
177/// Process validator errors into a more manageable form
178pub fn process(path: &str, kind: &ValidationErrorsKind) -> Vec<ValidatorIssue> {
179    let is_wrapper_field = |value: &str| matches!(value, "license" | "month" | "year" | "postal_code");
180    match kind {
181        | ValidationErrorsKind::Field(errors) => {
182            let result = errors
183                .iter()
184                .map(|error| {
185                    let ValidationError { code, message, params } = error.clone();
186                    let path = if path.is_empty() { None } else { Some(path.to_string()) };
187                    let message = message.map(|m| m.to_string()).unwrap_or_else(|| code.to_string());
188                    let params = params
189                        .into_iter()
190                        .map(|(key, value)| (key.to_string(), value))
191                        .collect::<HashMap<String, Value>>();
192                    ValidatorIssue::init()
193                        .code(ErrorCode::from(code.as_ref()))
194                        .maybe_path(path)
195                        .message(message)
196                        .params(params)
197                        .build()
198                })
199                .collect::<Vec<ValidatorIssue>>();
200            result
201        }
202        | ValidationErrorsKind::Struct(errors) => {
203            let result: Vec<ValidatorIssue> = errors
204                .clone()
205                .into_errors()
206                .into_iter()
207                .flat_map(|(field, kind)| {
208                    let is_wrapper_recursion = matches!(kind, ValidationErrorsKind::Field(_)) && path == field && is_wrapper_field(field.as_ref());
209                    let next_path = match (path.is_empty(), field.is_empty(), is_wrapper_recursion) {
210                        | (true, false, _) => field.to_string(),
211                        | (_, true, _) | (false, false, true) => path.to_string(),
212                        | (false, false, false) => format!("{path}.{field}"),
213                    };
214                    process(&next_path, &kind)
215                })
216                .collect();
217            result
218        }
219        | ValidationErrorsKind::List(errors) => {
220            let result: Vec<ValidatorIssue> = errors
221                .iter()
222                .flat_map(|(index, error)| {
223                    let prefix = if path.is_empty() { "".to_string() } else { path.to_string() };
224                    let kind = ValidationErrorsKind::Struct(error.clone());
225                    process(&format!("{prefix}[{index}]"), &kind)
226                })
227                .collect();
228            result
229        }
230    }
231}