Skip to main content

acorn/analyzer/
check.rs

1//! Check result types and helpers for analyzer output.
2use super::error::{process, ErrorCode, ErrorKind, Location};
3use super::readability::ReadabilityType;
4use super::vale::{Vale, ValeConfig, ValeOutputItem, ValeOutputItemSeverity};
5#[cfg(feature = "analysis")]
6use super::{to_dataframe, StaticAnalyzer, StaticAnalyzerConfig};
7use crate::io::InputOutput;
8#[cfg(feature = "analysis")]
9use crate::prelude::Cursor;
10use crate::prelude::{Arc, HashMap, Path, PathBuf};
11use crate::schema::standard::crosswalk::mapping::{
12    datacite_to_dcat, datacite_to_huwise, datacite_to_invenio, dcat_to_datacite, huwise_to_datacite, invenio_to_datacite,
13};
14use crate::schema::standard::crosswalk::{ConversionWarning, CrosswalkError, FieldMapping, Fields, SchemaExtractor};
15use crate::schema::standard::{datacite, dcat, huwise, invenio};
16use crate::schema::OneOrMany;
17use crate::util::constants::MAX_LENGTH_REPORT_SPAN_PREFIX;
18use crate::util::{Label, MimeType, StringConversion, ToProse};
19use crate::{check, check_err, check_ok};
20use ariadne::{Color, Config, Report, ReportKind, Source};
21use async_trait::async_trait;
22use bon::Builder;
23use color_eyre::owo_colors::OwoColorize;
24use convert_case::{Case, Casing};
25use core::fmt;
26use core::ops::RangeInclusive;
27use derive_more::Display;
28use futures::future::join_all;
29#[cfg(feature = "analysis")]
30use polars::{
31    frame::row::Row,
32    io::csv::write::CsvWriter,
33    prelude::{AnyValue, DataFrame, PolarsResult, SerWriter},
34};
35use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
36use serde_json::{Map, Value};
37use strum::EnumIter;
38use tracing::{debug, error, info};
39use validator::ValidationErrorsKind;
40
41/// Utility type alias for a vector of [`ConversionWarning`]
42pub type ConversionWarnings = Vec<ConversionWarning>;
43/// Trait for adding analysis capabilities
44#[cfg(feature = "analysis")]
45#[async_trait]
46pub trait Analysis {
47    /// Run analysis for a given category
48    async fn check(category: CheckCategory, paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
49        match category {
50            | CheckCategory::Link => Self::check_websites(paths, options).await,
51            | CheckCategory::Prose => Self::check_prose(paths, options).await,
52            | CheckCategory::Quality => Self::check_quality(paths, options).await,
53            | CheckCategory::Readability => Self::check_readability(paths, options).await,
54            | CheckCategory::Schema => Self::check_schema(paths, options).await,
55            | CheckCategory::Crosswalk => Vec::new(),
56        }
57    }
58    /// Perform analysis of prose
59    async fn check_prose(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check>;
60    /// Perform quality checks and verify data consistency
61    async fn check_quality(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check>;
62    /// Calculate readability using a given metric
63    async fn check_readability(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check>;
64    /// Execute validation checks for a given item
65    async fn check_schema(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check>;
66    /// Check if URLs links are valid and readable
67    async fn check_websites(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check>;
68    /// Returns the output path used for Vale analysis artifacts.
69    fn output_path(path: &Path, data: &Self) -> PathBuf;
70    /// Returns the metadata standard represented by the implementing type.
71    fn standard() -> Standard;
72}
73/// Trait for converting to a vector of checks
74pub trait IntoChecks {
75    /// Convert to a vector of checks
76    fn to_checks(&self, uri: Option<String>) -> Vec<Check>;
77}
78/// Trait for converting to a ([Polars]) row
79///
80/// [Polars]: https://docs.rs/polars/latest/polars/
81#[cfg(feature = "analysis")]
82pub trait IntoRow<'a> {
83    /// Convert to a (Polars) row
84    fn to_row<T>(self) -> Row<'a>;
85}
86/// Metadata standard used to gate processing behavior within commands
87#[derive(Clone, Copy, Debug, Default, Display, PartialEq, Eq)]
88pub enum Standard {
89    /// Research Activity Data (RAD)
90    #[default]
91    #[display("research-activity-data")]
92    ResearchActivityData,
93    /// Citation File Format (CFF)
94    #[display("citation-file-format")]
95    CitationFileFormat,
96    /// DataCite
97    #[display("datacite")]
98    Datacite,
99    /// Data Catalog (DCAT)
100    #[display("dcat")]
101    Dcat,
102    /// DOCX-derived text
103    #[display("docx")]
104    Docx,
105    /// Dublin Core
106    #[display("dublin-core")]
107    DublinCore,
108    /// HUBwise
109    #[display("huwise")]
110    Huwise,
111    /// InvenioRDM
112    #[display("invenio")]
113    Invenio,
114    /// Research Activity Identifier metadata (RAiD)
115    #[display("raid")]
116    Raid,
117    /// Plain text
118    #[display("text")]
119    Text,
120}
121/// Various check categories available for validating research activity data
122#[derive(Clone, Debug, Default, Display, EnumIter, PartialEq)]
123pub enum CheckCategory {
124    /// Schema validation check
125    #[default]
126    #[display("schema")]
127    Schema,
128    /// Website availability check
129    #[display("link")]
130    Link,
131    /// Static analysis of prose
132    #[display("prose")]
133    Prose,
134    /// Quality control and data consistency check
135    #[display("quality")]
136    Quality,
137    /// Readability check using one of several metrics
138    #[display("readability")]
139    Readability,
140    /// Metadata crosswalk conversion warning
141    #[display("crosswalk")]
142    Crosswalk,
143}
144/// Severity level of a check result
145#[derive(Clone, Debug, Default, Display, PartialEq)]
146pub enum CheckSeverity {
147    /// Hard error requiring immediate attention
148    #[default]
149    #[display("error")]
150    Error,
151    /// Informational message
152    #[display("info")]
153    Info,
154    /// Advisory suggestion to improve content quality
155    #[display("suggestion")]
156    Suggestion,
157    /// Content issue that should be addressed
158    #[display("warning")]
159    Warning,
160}
161/// Data structure for holding the result of a schema validation check
162#[derive(Builder, Clone, Debug)]
163#[builder(start_fn = init, on(String, into))]
164pub struct Check {
165    /// Position of this issue in rendered output
166    #[builder(default = 0)]
167    pub index: usize,
168    /// Check category
169    pub category: CheckCategory,
170    /// Unified issue location for checks (prose location or schema path)
171    pub locator: Option<String>,
172    /// Textual context of check (e.g., paragraph where prose issues were found)
173    pub context: Option<String>,
174    /// Internal payload used by check-specific renderers (e.g., prose source text)
175    data: Option<String>,
176    /// Whether or not the check was successful
177    #[builder(default = false)]
178    pub success: bool,
179    /// Severity of the check result
180    #[builder(default)]
181    pub severity: CheckSeverity,
182    /// HTTP status code
183    status_code: Option<String>,
184    /// Errors and issues found during check
185    pub errors: Option<ErrorKind>,
186    /// Path of file being validated
187    pub uri: Option<String>,
188    /// Message related to or description of validation issue (e.g., key name of invalid value, result of validation, etc.)
189    #[builder(default = "".to_string())]
190    pub message: String,
191}
192/// Data structure for passing options to check processing functions
193#[derive(Builder, Clone, Debug)]
194#[builder(start_fn = init, on(String, into), on(ReadabilityType, into))]
195#[derive(Default)]
196pub struct CheckOptions {
197    /// Include non-failure results such as suggestions and info messages
198    #[builder(default = false)]
199    pub all: bool,
200    /// Disable website checks that require internet access
201    #[builder(default = false)]
202    pub disable_website_checks: bool,
203    /// Flag used to indicate if a single error should cause the process to exit
204    #[builder(default = false)]
205    pub exit_on_first_error: bool,
206    /// Whether or not to run in offline mode (i.e., skip website checks)
207    #[builder(default = false)]
208    pub offline: bool,
209    /// Suppress stdout/stderr from external commands (e.g., Vale sync)
210    #[builder(default = false)]
211    pub quiet: bool,
212    /// Prevent non-zero exit behavior when failures are present
213    #[builder(default = false)]
214    pub no_fail: bool,
215    /// Categories of checks to skip during processing
216    #[builder(default)]
217    pub skip: Vec<String>,
218    /// Metadata standard used to resolve check processing behavior
219    #[builder(default)]
220    pub standard: Standard,
221    /// Readability metric to use for readability checks
222    pub readability_metric: ReadabilityType,
223    /// Whether or not to skip checksum verification for prose analysis
224    #[builder(default = false)]
225    pub skip_verify_checksum: bool,
226    /// Use compact output format instead of detailed output
227    #[builder(default = false)]
228    pub terse: bool,
229    /// Vale configuration options
230    pub vale_config: Option<ValeConfig>,
231}
232impl Check {
233    /// Recursively count validation errors from a given ValidationErrorsKind
234    pub fn count_validation_errors(kind: &ValidationErrorsKind) -> usize {
235        match kind {
236            | ValidationErrorsKind::Field(_) => 1,
237            | ValidationErrorsKind::List(errors) => errors
238                .clone()
239                .into_values()
240                .map(|error| Self::count_validation_errors(&ValidationErrorsKind::Struct(error)))
241                .sum(),
242            | ValidationErrorsKind::Struct(errors) => errors
243                .clone()
244                .into_errors()
245                .into_values()
246                .map(|nested| Self::count_validation_errors(&nested))
247                .sum(),
248        }
249    }
250    /// Returns whether this check represents a failure (not successful and severity warrants failure)
251    pub fn is_failure(&self) -> bool {
252        !self.success && self.severity.is_failure()
253    }
254    /// Returns a function that filters checks based on the given log level threshold
255    pub fn is_visible_at(level: Option<u8>) -> impl Fn(&Check) -> bool {
256        move |check: &Check| check.severity.is_visible_at(level)
257    }
258    /// Returns the number of errors
259    pub fn issue_count(&self) -> usize {
260        match self.category {
261            | CheckCategory::Link | CheckCategory::Quality | CheckCategory::Readability => 1,
262            | CheckCategory::Prose => {
263                if let Some(kind) = &self.errors {
264                    match kind {
265                        | ErrorKind::Vale(values) => values.len(),
266                        | _ => 1,
267                    }
268                } else if !self.message.is_empty() {
269                    1
270                } else {
271                    0
272                }
273            }
274            | CheckCategory::Schema => {
275                if let Some(kind) = &self.errors {
276                    match kind {
277                        | ErrorKind::Validator(kind) => Self::count_validation_errors(kind),
278                        | _ => 0,
279                    }
280                } else {
281                    0
282                }
283            }
284            | CheckCategory::Crosswalk => {
285                if self.success {
286                    0
287                } else {
288                    1
289                }
290            }
291        }
292    }
293    /// Fancy output in contrast with more compressed output of Display impl
294    pub fn report(&self) {
295        let index = self.index;
296        let Check {
297            category,
298            locator,
299            context,
300            data,
301            errors,
302            severity,
303            status_code,
304            uri,
305            message,
306            ..
307        } = self.clone();
308        match &category {
309            | CheckCategory::Link => {
310                let title = uri.clone().unwrap_or_else(|| "index.json".to_string());
311                let kind: ReportKind<'static> = severity.clone().into();
312                let code = match status_code.as_ref() {
313                    | Some(value) if !value.is_empty() => format!(" ({value})").dimmed().to_string(),
314                    | None | Some(_) => "".to_string(),
315                };
316                let text = match context.as_ref() {
317                    | Some(value) => value.trim().to_string(),
318                    | None => "No URL".to_string(),
319                };
320                let source_text = if text.is_empty() { " ".to_string() } else { text };
321                let source = Source::from(source_text.clone());
322                let span = 0..=source_text.len().saturating_sub(1);
323                let _ = Report::build(kind, (&title, 1..=1))
324                    .with_code(index)
325                    .with_config(Config::default().with_compact(false))
326                    .with_message(locator.unwrap_or("URL".to_string()))
327                    .with_label(
328                        ariadne::Label::new((&title, span))
329                            .with_message(format!("{}{code}", message.italic()))
330                            .with_color(severity.clone().into()),
331                    )
332                    .finish()
333                    .print((&title, source));
334            }
335            | CheckCategory::Prose => match &errors {
336                | Some(ErrorKind::Vale(values)) => {
337                    let title = uri.clone().unwrap_or("index.json".to_string());
338                    if let Some(text) = &data {
339                        values.iter().enumerate().for_each(|(i, item)| {
340                            let ValeOutputItem {
341                                check,
342                                line,
343                                message,
344                                severity,
345                                span,
346                                ..
347                            } = item;
348                            let (source, span) = truncate(highlighted(text, span, *line), text, MAX_LENGTH_REPORT_SPAN_PREFIX);
349                            let _ = Report::build(severity.into(), (&title, 1..=1))
350                                .with_code(index.saturating_add(i))
351                                .with_config(Config::default().with_compact(false))
352                                .with_message(check)
353                                .with_label(
354                                    ariadne::Label::new((&title, span))
355                                        .with_message(message.italic())
356                                        .with_color(severity.into()),
357                                )
358                                .finish()
359                                .print((&title, source));
360                        });
361                    }
362                }
363                | None | Some(_) => {}
364            },
365            | CheckCategory::Quality => {}
366            | CheckCategory::Readability => match &errors {
367                | Some(ErrorKind::Readability((_readability_index, readability_type))) => {
368                    let kind: ReportKind<'static> = severity.clone().into();
369                    let title = uri.as_deref().unwrap_or_default();
370                    let metric = readability_type.to_string().to_uppercase();
371                    let source_text = format!("Prose was analyzed for readability using the {metric} metric ({message})");
372                    let source = Source::from(source_text.clone());
373                    let start = source_text.find(&message).unwrap_or_default();
374                    let end = start.saturating_add(message.len().saturating_sub(1));
375                    let _ = Report::build(kind, (&title, 1..=1))
376                        .with_code(index)
377                        .with_config(Config::default().with_compact(false))
378                        .with_message("Readability")
379                        .with_label(
380                            ariadne::Label::new((&title, start..=end))
381                                .with_message("Simplify language for a general audience".to_string().italic())
382                                .with_color(severity.clone().into()),
383                        )
384                        .finish()
385                        .print((&title, source));
386                }
387                | None | Some(_) => {
388                    let title = uri.as_deref().unwrap_or_default();
389                    if let Some(value) = &data {
390                        info!(
391                            "=> {} {title} has {} {}",
392                            Label::pass(),
393                            "no readability issues".green().bold(),
394                            value.dimmed()
395                        );
396                    } else {
397                        info!("=> {} {title} has {}", Label::pass(), "no readability issues".green().bold(),);
398                    }
399                }
400            },
401            | CheckCategory::Schema => {
402                let title = uri.clone().unwrap_or_default();
403                match &errors {
404                    | Some(ErrorKind::Validator(validator_kind)) => {
405                        let prefix = context.as_deref().or(locator.as_deref()).unwrap_or(&message);
406                        process(prefix, validator_kind).iter().enumerate().for_each(|(issue_index, issue)| {
407                            let locator = issue.locator();
408                            let source_text = issue.source_text();
409                            let full_source = Source::from(source_text.clone());
410                            let full_span = match highlighted_from_params(&full_source, &issue.code, &issue.params) {
411                                | Some(span) => span,
412                                | None => 0..=source_text.len().saturating_sub(1),
413                            };
414                            let (source, span) = truncate(full_span, &source_text, MAX_LENGTH_REPORT_SPAN_PREFIX);
415                            let _ = Report::build(severity.clone().into(), (&title, 1..=1))
416                                .with_code(index.saturating_add(issue_index))
417                                .with_config(Config::default().with_compact(false))
418                                .with_message(locator)
419                                .with_label(
420                                    ariadne::Label::new((&title, span))
421                                        .with_message(issue.message.italic())
422                                        .with_color(severity.clone().into()),
423                                )
424                                .finish()
425                                .print((&title, source));
426                        });
427                    }
428                    | None if !self.success => {
429                        let error_text = context.as_deref().unwrap_or(&message);
430                        let source = Source::from(error_text.to_string());
431                        let span = 0..=error_text.len().saturating_sub(1);
432                        let _ = Report::build(severity.clone().into(), (&title, 1..=1))
433                            .with_code(index)
434                            .with_config(Config::default().with_compact(false))
435                            .with_message("Deserialization error")
436                            .with_label(
437                                ariadne::Label::new((&title, span))
438                                    .with_message(error_text.italic())
439                                    .with_color(severity.clone().into()),
440                            )
441                            .finish()
442                            .print((&title, source));
443                    }
444                    | None | Some(_) => {
445                        info!("=> {} {title} has {}", Label::pass(), "no schema validation issues".green().bold());
446                    }
447                }
448            }
449            | CheckCategory::Crosswalk => {
450                let title = uri.as_deref().unwrap_or("crosswalk");
451                let source_text = context.as_deref().unwrap_or(&message).to_string();
452                let source = Source::from(source_text.clone());
453                let span = 0..=source_text.len().saturating_sub(1);
454                let _ = Report::build(severity.clone().into(), (title, 1..=1))
455                    .with_code(index)
456                    .with_config(Config::default().with_compact(false))
457                    .with_message(locator.unwrap_or("Crosswalk warning".to_string()))
458                    .with_label(
459                        ariadne::Label::new((title, span))
460                            .with_message(message.italic())
461                            .with_color(severity.clone().into()),
462                    )
463                    .finish()
464                    .print((title, source));
465            }
466        }
467    }
468    /// Returns a new LinkCheckResult with the given context
469    pub fn with_context(self, value: String) -> Self {
470        Self {
471            context: Some(value),
472            ..self
473        }
474    }
475    /// Returns a new check with renderer data attached.
476    pub fn with_data(self, value: String) -> Self {
477        Self { data: Some(value), ..self }
478    }
479    /// Returns a new LinkCheckResult with the given index
480    pub fn with_index(self, value: usize) -> Self {
481        Self { index: value, ..self }
482    }
483    /// Returns a new LinkCheckResult with the given locator
484    pub fn with_locator(self, value: Option<String>) -> Self {
485        Self { locator: value, ..self }
486    }
487    /// Returns a new LinkCheckResult with the given URL
488    pub fn with_uri(self, value: Option<String>) -> Self {
489        Self { uri: value, ..self }
490    }
491}
492impl fmt::Display for Check {
493    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
494        const INDENT: &str = " ";
495        const COL_ONE_WIDTH: usize = 28;
496        const COL_TWO_WIDTH: usize = 29;
497        let format_row = |first: String, second: String, third: String| format!("{INDENT}{first:<COL_ONE_WIDTH$} {second:<COL_TWO_WIDTH$} {third}");
498        match self.category {
499            | CheckCategory::Link => {
500                let Check {
501                    severity,
502                    context,
503                    locator,
504                    status_code,
505                    message,
506                    ..
507                } = self;
508                let code = match status_code.clone() {
509                    | Some(value) if !value.is_empty() => format!(" ({value})").dimmed().to_string(),
510                    | None | Some(_) => "".to_string(),
511                };
512                let context = context
513                    .as_ref()
514                    .map(|value| value.to_string().underline().italic().to_string())
515                    .unwrap_or_else(|| "Missing".to_string());
516                let severity = severity.colored();
517                let details = format!("{context} {}{code}", message.dimmed());
518                write!(f, "{}", format_row(locator.clone().unwrap_or_default(), severity, details))
519            }
520            | CheckCategory::Prose => match &self.errors {
521                | Some(ErrorKind::Vale(values)) => {
522                    let last = values.len().saturating_sub(1);
523                    values.iter().enumerate().try_for_each(|(index, item)| {
524                        let ValeOutputItem {
525                            check, message, severity, ..
526                        } = item;
527                        let location = item.locator();
528                        let severity = CheckSeverity::from(severity.clone());
529                        let details = format!("{} {}{}", message, "rule=".dimmed(), check.dimmed());
530                        f.write_fmt(format_args!("{}", format_row(location, severity.colored(), details)))
531                            .and_then(|_| if index < last { writeln!(f) } else { Ok(()) })
532                    })
533                }
534                | None | Some(_) => write!(f, ""),
535            },
536            | CheckCategory::Quality => write!(f, "unimplemented: Quality"),
537            | CheckCategory::Readability => {
538                let Check {
539                    errors, message, severity, ..
540                } = self;
541                match &errors {
542                    | Some(ErrorKind::Readability((_readability_index, _readability_type))) => {
543                        let details = format!("Simplify language for a general audience {}", message.dimmed());
544                        f.write_fmt(format_args!(
545                            "{}",
546                            format_row("Overall reading level".to_string(), severity.colored(), details)
547                        ))
548                    }
549                    | None | Some(_) => write!(f, ""),
550                }
551            }
552            | CheckCategory::Schema => match &self.errors {
553                | Some(ErrorKind::Validator(kind)) => {
554                    let Check {
555                        context,
556                        locator,
557                        message,
558                        severity,
559                        ..
560                    } = self;
561                    let prefix = context.as_deref().or(locator.as_deref()).unwrap_or(message.as_str());
562                    let rows = process(prefix, kind);
563                    let last = rows.len().saturating_sub(1);
564                    rows.iter().enumerate().try_for_each(|(index, issue)| {
565                        let location = issue.locator();
566                        let details = format!("{} {}{}", issue.message, "code=".dimmed(), issue.code.to_string().to_uppercase().dimmed());
567                        f.write_fmt(format_args!("{}", format_row(location, severity.colored(), details)))
568                            .and_then(|_| if index < last { writeln!(f) } else { Ok(()) })
569                    })
570                }
571                | None if !self.success => {
572                    let error_text = self.context.as_deref().unwrap_or(&self.message);
573                    write!(
574                        f,
575                        "{}",
576                        format_row(
577                            "Deserialization error".to_string(),
578                            self.severity.colored(),
579                            error_text.to_string().dimmed().to_string()
580                        )
581                    )
582                }
583                | None | Some(_) => write!(f, ""),
584            },
585            | CheckCategory::Crosswalk => {
586                let details = self.message.dimmed().to_string();
587                f.write_fmt(format_args!(
588                    "{}",
589                    format_row(self.locator.clone().unwrap_or_default(), self.severity.colored(), details)
590                ))
591            }
592        }
593    }
594}
595
596impl CheckCategory {
597    /// Returns `true` if this category appears in the given list of category items
598    pub fn is_in(&self, items: &[String]) -> bool {
599        items.iter().any(|value| Self::from(value.as_str()) == *self)
600    }
601}
602impl From<String> for CheckCategory {
603    fn from(value: String) -> Self {
604        Self::from(value.as_str())
605    }
606}
607impl From<&String> for CheckCategory {
608    fn from(value: &String) -> Self {
609        Self::from(value.as_str())
610    }
611}
612impl From<&str> for CheckCategory {
613    fn from(value: &str) -> Self {
614        match value.trim().to_lowercase().as_str() {
615            | "link" => Self::Link,
616            | "prose" => Self::Prose,
617            | "quality" => Self::Quality,
618            | "readability" => Self::Readability,
619            | "crosswalk" => Self::Crosswalk,
620            | "schema" => Self::Schema,
621            | _ => CheckCategory::default(),
622        }
623    }
624}
625impl From<&CheckCategory> for u8 {
626    fn from(value: &CheckCategory) -> Self {
627        match value {
628            | CheckCategory::Schema | CheckCategory::Link => 0,
629            | CheckCategory::Prose => 1,
630            | CheckCategory::Quality => 2,
631            | CheckCategory::Readability => 3,
632            | CheckCategory::Crosswalk => 4,
633        }
634    }
635}
636impl CheckSeverity {
637    /// Returns a colored string representation of the severity level
638    pub fn colored(&self) -> String {
639        match self {
640            | Self::Error => self.to_string().red().bold().to_string(),
641            | Self::Info => self.to_string().cyan().bold().to_string(),
642            | Self::Suggestion => self.to_string().blue().bold().to_string(),
643            | Self::Warning => self.to_string().yellow().bold().to_string(),
644        }
645    }
646    /// Returns whether this severity should cause check failure.
647    pub fn is_failure(&self) -> bool {
648        matches!(self, Self::Error | Self::Warning)
649    }
650}
651impl IntoChecks for ConversionWarnings {
652    /// Convert crosswalk conversion warnings into Check objects with Warning severity.
653    ///
654    /// Each ConversionWarning from a field-level loss during schema crosswalk produces
655    /// a single Check with the field name as locator and a human-readable message.
656    fn to_checks(&self, uri: Option<String>) -> Vec<Check> {
657        self.iter()
658            .map(|w| {
659                let context = format!("{} → {}", w.from, w.to);
660                check!(
661                    CheckCategory::Crosswalk,
662                    false,
663                    severity: CheckSeverity::Warning,
664                    message: w.to_string(),
665                    context: context,
666                    locator: w.field.clone(),
667                )
668                .with_uri(uri.clone())
669            })
670            .collect()
671    }
672}
673impl CheckSeverity {
674    /// Returns whether this severity level is visible at the given log level.
675    ///
676    /// - [`CheckSeverity::Error`] and [`CheckSeverity::Warning`] are always visible.
677    /// - [`CheckSeverity::Suggestion`] is visible at `warn` level and above.
678    /// - [`CheckSeverity::Info`] is visible at `info` level and above.
679    pub fn is_visible_at(&self, level: Option<u8>) -> bool {
680        match self {
681            | Self::Error | Self::Warning => true,
682            | Self::Suggestion => matches!(level, Some(2..=u8::MAX)),
683            | Self::Info => matches!(level, Some(3..=u8::MAX)),
684        }
685    }
686}
687impl From<&CheckSeverity> for u8 {
688    fn from(value: &CheckSeverity) -> Self {
689        match value {
690            | CheckSeverity::Error => 0,
691            | CheckSeverity::Warning => 1,
692            | CheckSeverity::Suggestion => 2,
693            | CheckSeverity::Info => 3,
694        }
695    }
696}
697impl From<ValeOutputItemSeverity> for CheckSeverity {
698    fn from(value: ValeOutputItemSeverity) -> Self {
699        match value {
700            | ValeOutputItemSeverity::Error => CheckSeverity::Error,
701            | ValeOutputItemSeverity::Suggestion => CheckSeverity::Suggestion,
702            | ValeOutputItemSeverity::Warning => CheckSeverity::Warning,
703        }
704    }
705}
706impl From<CheckSeverity> for Color {
707    fn from(value: CheckSeverity) -> Self {
708        match value {
709            | CheckSeverity::Error => Color::Red,
710            | CheckSeverity::Info => Color::Cyan,
711            | CheckSeverity::Suggestion => Color::Blue,
712            | CheckSeverity::Warning => Color::Yellow,
713        }
714    }
715}
716impl From<CheckSeverity> for ReportKind<'_> {
717    fn from(value: CheckSeverity) -> Self {
718        match value {
719            | CheckSeverity::Error => ReportKind::Error,
720            | CheckSeverity::Info => ReportKind::Custom("Info", Color::Cyan),
721            | CheckSeverity::Suggestion => ReportKind::Custom("Suggestion", Color::Blue),
722            | CheckSeverity::Warning => ReportKind::Warning,
723        }
724    }
725}
726#[cfg(feature = "analysis")]
727impl<'a> IntoRow<'a> for Check {
728    fn to_row<Check>(self) -> Row<'a> {
729        let Self {
730            index,
731            category,
732            severity,
733            message,
734            locator,
735            uri,
736            context,
737            ..
738        } = self;
739        let data = [
740            &index.to_string(),
741            &severity.to_string(),
742            &category.to_string(),
743            &uri.unwrap_or_default(),
744            &locator.unwrap_or_default(),
745            &message,
746            &context.unwrap_or_default(),
747        ];
748        Row::new(data.into_iter().map(|x| AnyValue::String(x).into_static()).collect::<Vec<_>>())
749    }
750}
751impl From<&Map<String, Value>> for Standard {
752    fn from(object: &Map<String, Value>) -> Self {
753        if object.get("@type").and_then(Value::as_str).is_some_and(|value| value.contains("dcat:")) {
754            Standard::Dcat
755        } else if object.contains_key("dataset_id") && object.contains_key("metas") {
756            Standard::Huwise
757        } else if object.contains_key("attributes") && object.contains_key("id") {
758            Standard::Datacite
759        } else if object.contains_key("metadata") || object.contains_key("pids") || object.contains_key("resource_type") {
760            Standard::Invenio
761        } else {
762            Standard::Dcat
763        }
764    }
765}
766impl Standard {
767    /// Convert serialized content from one metadata standard to another.
768    ///
769    /// Parses `content` as the schema identified by `self` (source standard),
770    /// converts each record to the `target` standard using `TryFrom` pipeline,
771    /// and serializes the result as `output_mime` (JSON or YAML).
772    ///
773    /// ### Errors
774    /// Returns [`CrosswalkError`] when:
775    /// - Content cannot be parsed as the source standard
776    /// - A required conversion path is not supported
777    /// - A record fails conversion
778    /// - Output serialization fails
779    pub fn crosswalk(&self, content: &str, mime: MimeType, target: Standard, target_mime: MimeType) -> Result<String, CrosswalkError> {
780        match *self {
781            | Standard::Datacite => OneOrMany::<datacite::Record>::parse(content, mime).and_then(|source| match target {
782                | Standard::Datacite => source.serialize(target_mime),
783                | Standard::Dcat => source.map(dcat::Dataset::try_from).and_then(|b| b.serialize(target_mime)),
784                | Standard::Invenio => source.map(invenio::Record::try_from).and_then(|b| b.serialize(target_mime)),
785                | Standard::Huwise => source.map(huwise::Dataset::try_from).and_then(|b| b.serialize(target_mime)),
786                | _ => Err(self.unsupported_crosswalk(target)),
787            }),
788            | Standard::Dcat => OneOrMany::<dcat::Dataset>::parse(content, mime).and_then(|source| match target {
789                | Standard::Dcat => source.serialize(target_mime),
790                | Standard::Datacite => source.map(datacite::Record::try_from).and_then(|b| b.serialize(target_mime)),
791                | Standard::Invenio => source
792                    .map(|v| datacite::Record::try_from(v).and_then(invenio::Record::try_from))
793                    .and_then(|b| b.serialize(target_mime)),
794                | Standard::Huwise => source
795                    .map(|v| datacite::Record::try_from(v).and_then(huwise::Dataset::try_from))
796                    .and_then(|b| b.serialize(target_mime)),
797                | _ => Err(self.unsupported_crosswalk(target)),
798            }),
799            | Standard::Invenio => OneOrMany::<invenio::Record>::parse(content, mime).and_then(|source| match target {
800                | Standard::Invenio => source.serialize(target_mime),
801                | Standard::Datacite => source.map(datacite::Record::try_from).and_then(|b| b.serialize(target_mime)),
802                | Standard::Dcat => source
803                    .map(|v| datacite::Record::try_from(v).and_then(dcat::Dataset::try_from))
804                    .and_then(|b| b.serialize(target_mime)),
805                | Standard::Huwise => source
806                    .map(|v| datacite::Record::try_from(v).and_then(huwise::Dataset::try_from))
807                    .and_then(|b| b.serialize(target_mime)),
808                | _ => Err(self.unsupported_crosswalk(target)),
809            }),
810            | Standard::Huwise => OneOrMany::<huwise::Dataset>::parse(content, mime).and_then(|source| match target {
811                | Standard::Huwise => source.serialize(target_mime),
812                | Standard::Datacite => source.map(datacite::Record::try_from).and_then(|b| b.serialize(target_mime)),
813                | Standard::Dcat => source
814                    .map(|v| datacite::Record::try_from(v).and_then(dcat::Dataset::try_from))
815                    .and_then(|b| b.serialize(target_mime)),
816                | Standard::Invenio => source
817                    .map(|v| datacite::Record::try_from(v).and_then(invenio::Record::try_from))
818                    .and_then(|b| b.serialize(target_mime)),
819                | _ => Err(self.unsupported_crosswalk(target)),
820            }),
821            | _ => Err(self.unsupported_crosswalk(target)),
822        }
823    }
824    /// Convert serialized content from one metadata standard to another, returning both the converted content and any field-level warnings.
825    ///
826    /// Warnings indicate optional fields in the source that have no equivalent in the target schema, or fields whose semantics differ between schemas.
827    pub fn crosswalk_with_warnings(
828        &self,
829        content: &str,
830        mime: MimeType,
831        target: Standard,
832        target_mime: MimeType,
833    ) -> Result<(String, ConversionWarnings), CrosswalkError> {
834        let warnings = match self.collect_crosswalk_warnings(content, &mime, target) {
835            | Ok(w) => w,
836            | Err(_) => Vec::new(),
837        };
838        self.crosswalk(content, mime, target, target_mime).map(|content| (content, warnings))
839    }
840    /// Run the FieldMapping pipeline to detect field-level losses for a given pair.
841    fn collect_crosswalk_warnings(&self, content: &str, mime: &MimeType, target: Standard) -> Result<ConversionWarnings, CrosswalkError> {
842        match (*self, target) {
843            | (Standard::Datacite, Standard::Dcat) => {
844                process_crosswalk_warnings::<datacite::Record>(content, mime, "datacite", "dcat", datacite_to_dcat())
845            }
846            | (Standard::Datacite, Standard::Invenio) => {
847                process_crosswalk_warnings::<datacite::Record>(content, mime, "datacite", "invenio", datacite_to_invenio())
848            }
849            | (Standard::Datacite, Standard::Huwise) => {
850                process_crosswalk_warnings::<datacite::Record>(content, mime, "datacite", "huwise", datacite_to_huwise())
851            }
852            | (Standard::Dcat, Standard::Datacite) => {
853                process_crosswalk_warnings::<dcat::Dataset>(content, mime, "dcat", "datacite", dcat_to_datacite())
854            }
855            | (Standard::Invenio, Standard::Datacite) => {
856                process_crosswalk_warnings::<invenio::Record>(content, mime, "invenio", "datacite", invenio_to_datacite())
857            }
858            | (Standard::Huwise, Standard::Datacite) => {
859                process_crosswalk_warnings::<huwise::Dataset>(content, mime, "huwise", "datacite", huwise_to_datacite())
860            }
861            | _ => Ok(Vec::new()),
862        }
863    }
864    fn unsupported_crosswalk(&self, target: Standard) -> CrosswalkError {
865        CrosswalkError::BuildFailed(format!(
866            "Unsupported metadata crosswalk pair: {self} -> {target} (supported: datacite, dcat, invenio, huwise)"
867        ))
868    }
869}
870#[cfg(feature = "analysis")]
871pub(crate) async fn check_prose_for<T>(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check>
872where
873    T: Analysis + InputOutput + ToProse,
874{
875    let CheckOptions {
876        offline,
877        quiet,
878        skip_verify_checksum,
879        vale_config,
880        ..
881    } = options.cloned().unwrap_or_default();
882    let config = vale_config.unwrap_or_default().save().await;
883    let vale = Vale::resolve(config, offline, skip_verify_checksum).await;
884    match vale.clone().sync(offline, quiet).await {
885        | Ok(_) => {
886            let vale = Arc::new(vale);
887            let results = paths.iter().map(|path| {
888                let vale = Arc::clone(&vale);
889                let path = path.clone();
890                async move {
891                    match T::read(&path) {
892                        | Ok(data) => {
893                            let output = T::output_path(&path, &data);
894                            let content = data.to_prose();
895                            vale.run(output, content, Some("JSON".into())).await
896                        }
897                        | Err(why) => vec![check_err!(CheckCategory::Prose, context: why.to_string())],
898                    }
899                }
900            });
901            join_all(results).await.into_iter().flatten().collect()
902        }
903        | Err(why) => {
904            error!("=> {} Vale sync — {why}", Label::fail());
905            vec![check_err!(CheckCategory::Prose)]
906        }
907    }
908}
909pub(super) fn check_readability_for<T>(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check>
910where
911    T: InputOutput + ToProse,
912{
913    let CheckOptions { readability_metric, .. } = options.cloned().unwrap_or_default();
914    paths
915        .par_iter()
916        .flat_map(|path| match T::read(path) {
917            | Ok(data) => {
918                let content = data.to_prose();
919                let calculated_index = readability_metric.calculate(&content);
920                let maximum = match readability_metric.maximum_allowed_from_env() {
921                    | Some(value) => {
922                        debug!(value, "=> {} Maximum allowed readability from .env", Label::using());
923                        value
924                    }
925                    | None => readability_metric.maximum_allowed(),
926                };
927                debug!(value = calculated_index, "=> {} Readability index", Label::using());
928                let score = format!("{} = {calculated_index}/{maximum}", readability_metric.to_string().to_uppercase());
929                if calculated_index > maximum {
930                    let errors = ErrorKind::Readability((calculated_index, readability_metric));
931                    vec![check!(
932                        CheckCategory::Readability,
933                        false,
934                        severity: CheckSeverity::Warning,
935                        uri: path.file_name_with_parent(),
936                        errors: errors,
937                        message: score,
938                        data: content
939                    )]
940                } else {
941                    vec![check_ok!(
942                        CheckCategory::Readability,
943                        uri: path.file_name_with_parent(),
944                        message: score,
945                        data: content
946                    )]
947                }
948            }
949            | Err(why) => vec![check_err!(CheckCategory::Readability, context: why.to_string())],
950        })
951        .collect::<Vec<Check>>()
952}
953/// Convert vector of [`Check`] values to a Polars [DataFrame]
954#[cfg(feature = "analysis")]
955pub fn checks_to_dataframe(values: &[Check]) -> PolarsResult<DataFrame> {
956    let names = ["index", "severity", "category", "uri", "locator", "message", "context"];
957    to_dataframe::<Check, _, &str>(
958        values
959            .iter()
960            .enumerate()
961            .map(|(index, check)| check.clone().with_index(index))
962            .collect::<Vec<_>>(),
963        names,
964    )
965}
966/// Convert vector of [`Check`] values to a CSV string
967#[cfg(feature = "analysis")]
968pub fn checks_to_csv(values: &[Check], include_headers: bool) -> PolarsResult<String> {
969    match checks_to_dataframe(values) {
970        | Ok(mut dataframe) => {
971            let mut buf = Cursor::new(Vec::new());
972            match CsvWriter::new(&mut buf).include_header(include_headers).finish(&mut dataframe) {
973                | Ok(_) => Ok(String::from_utf8_lossy(&buf.into_inner()).into_owned()),
974                | Err(why) => Err(why),
975            }
976        }
977        | Err(why) => Err(why),
978    }
979}
980/// Parse source content and apply a FieldMapping to detect missing optional fields.
981fn process_crosswalk_warnings<T>(
982    content: &str,
983    mime: &MimeType,
984    from: &'static str,
985    to: &'static str,
986    mapping: FieldMapping,
987) -> Result<ConversionWarnings, CrosswalkError>
988where
989    T: serde::de::DeserializeOwned + SchemaExtractor,
990{
991    OneOrMany::<T>::parse(content, mime.clone()).map(|batch| {
992        let mapped_fields: Vec<&str> = mapping.rules.iter().map(|r| r.source).collect();
993        let missing_warnings = |fields: &Fields, prefix: &str| -> ConversionWarnings {
994            let mut target_fields = Fields::new();
995            mapping
996                .apply(fields, &mut target_fields)
997                .unwrap_or_default()
998                .into_iter()
999                .map(|field| ConversionWarning::no_equivalent(from, to, format!("{prefix}{field}")))
1000                .collect()
1001        };
1002        let unmapped_warnings = |fields: &Fields, prefix: &str| -> ConversionWarnings {
1003            fields
1004                .keys()
1005                .filter(|key| !mapped_fields.contains(&key.as_str()))
1006                .map(|key| ConversionWarning::no_equivalent(from, to, format!("{prefix}{key}")))
1007                .collect()
1008        };
1009        match batch {
1010            | OneOrMany::One(record) => {
1011                let fields = record.extract_fields();
1012                [missing_warnings(&fields, ""), unmapped_warnings(&fields, "")].concat()
1013            }
1014            | OneOrMany::Many(records) => records
1015                .into_iter()
1016                .enumerate()
1017                .flat_map(|(i, record)| {
1018                    let fields = record.extract_fields();
1019                    let prefix = format!("record[{i}].");
1020                    missing_warnings(&fields, &prefix).into_iter().chain(unmapped_warnings(&fields, &prefix))
1021                })
1022                .collect(),
1023        }
1024    })
1025}
1026/// Calculate a highlighted byte span for a single line in a source report.
1027pub(crate) fn highlighted(text: &str, span: &[u32], line_number: u32) -> RangeInclusive<usize> {
1028    let selected = (line_number as usize).saturating_sub(1);
1029    let source = Source::from(text);
1030    let begin = source
1031        .lines()
1032        .take(selected)
1033        .fold((span.first().copied().unwrap_or(1).saturating_sub(1)) as usize, |acc, line| {
1034            acc.saturating_add(line.len())
1035        });
1036    let character_count = (span.get(1).copied().unwrap_or(0) as usize).saturating_sub(span.first().copied().unwrap_or(0) as usize);
1037    let end = begin.saturating_add(character_count);
1038    begin..=end
1039}
1040/// Calculate a highlighted span for an indexed value in an array-like source.
1041pub(crate) fn highlighted_array(source: &Source, params: &HashMap<String, Value>) -> Option<RangeInclusive<usize>> {
1042    match params.get("index") {
1043        | Some(Value::Number(index)) => match index.as_u64() {
1044            | Some(index) => match params.get("value") {
1045                | Some(Value::Array(value)) => {
1046                    const PADDING: u64 = 2;
1047                    let end = (value.get(index as usize).map(|v| v.to_string().len().saturating_add(1)).unwrap_or(0)) as u32;
1048                    let span = &[4, end];
1049                    let line = (index.saturating_add(PADDING)) as u32;
1050                    Some(highlighted(source.text(), span, line))
1051                }
1052                | Some(_) | None => None,
1053            },
1054            | None => None,
1055        },
1056        | None | Some(_) => None,
1057    }
1058}
1059/// Select a highlighted span based on validator error code and parameters.
1060pub(crate) fn highlighted_from_params(source: &Source, code: &ErrorCode, params: &HashMap<String, Value>) -> Option<RangeInclusive<usize>> {
1061    match code {
1062        | ErrorCode::Latitude => {
1063            // TODO: Implement highlighting for latitude errors
1064            None
1065        }
1066        | ErrorCode::Length => match params.get("max") {
1067            | Some(Value::Number(max)) => match max.as_u64() {
1068                | Some(max) => Some(max as usize..=source.text().len().saturating_sub(2)),
1069                | None => None,
1070            },
1071            | None | Some(_) => None,
1072        },
1073        | ErrorCode::Longitude => {
1074            // TODO: Implement highlighting for longitude errors
1075            None
1076        }
1077        | ErrorCode::Section => highlighted_array(source, params),
1078        | _ if params.get("index").is_some() => highlighted_array(source, params),
1079        | _ => None,
1080    }
1081}
1082pub(crate) fn nearest_space_boundary(value: &str, index: usize) -> Option<usize> {
1083    let index = index.min(value.len());
1084    let boundary = value
1085        .char_indices()
1086        .find(|(i, ch)| *i >= index && ch.is_whitespace())
1087        .map(|(i, ch)| i.saturating_add(ch.len_utf8()));
1088    boundary
1089}
1090/// Create summary data table from given issues
1091pub fn summary(issues: Vec<Check>) -> Vec<Vec<String>> {
1092    [
1093        CheckCategory::Schema,
1094        CheckCategory::Link,
1095        CheckCategory::Prose,
1096        CheckCategory::Readability,
1097        CheckCategory::Crosswalk,
1098    ]
1099    .iter()
1100    .map(|category| {
1101        let count = issues
1102            .iter()
1103            .filter(|issue| issue.category == *category)
1104            .map(|issue| issue.issue_count())
1105            .sum::<usize>()
1106            .to_string();
1107        vec![format!("{} items found", category.to_string().to_case(Case::Title)), count]
1108    })
1109    .collect::<Vec<_>>()
1110}
1111/// Truncate source text with ellipsis when prefix before span exceeds maximum length.
1112pub(crate) fn truncate(span: RangeInclusive<usize>, source_text: &str, max_prefix: usize) -> (Source, RangeInclusive<usize>) {
1113    fn floor_char_boundary(value: &str, index: usize) -> usize {
1114        (0..=index.min(value.len())).rev().find(|&i| value.is_char_boundary(i)).unwrap_or(0)
1115    }
1116    let start = *span.start();
1117    let end = *span.end();
1118    if start <= max_prefix {
1119        let text = source_text.to_string();
1120        (Source::from(text), start..=end)
1121    } else {
1122        let nearest = floor_char_boundary(source_text, start.saturating_sub(max_prefix));
1123        let prefix_start = nearest_space_boundary(source_text, nearest).unwrap_or(nearest);
1124        let sliced = source_text.get(prefix_start..).unwrap_or_default();
1125        let ellipsis = "...";
1126        let adjusted_start = start.saturating_sub(prefix_start).saturating_add(ellipsis.len());
1127        let adjusted_end = end.saturating_sub(prefix_start).saturating_add(ellipsis.len());
1128        let text = format!("{ellipsis}{sliced}");
1129        (Source::from(text), adjusted_start..=adjusted_end)
1130    }
1131}