Skip to main content

acorn/analyzer/
check.rs

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