1use 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
51pub type ConversionWarnings = Vec<ConversionWarning>;
53pub type Checks = Vec<Check>;
55pub trait Render {
57 fn render(&self, options: &CheckOptions);
59}
60#[cfg(feature = "analysis")]
62#[async_trait]
63pub trait Analysis {
64 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 async fn check_prose(_paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
77 Vec::new()
78 }
79 async fn check_quality(_paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
81 Vec::new()
82 }
83 async fn check_readability(_paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
85 Vec::new()
86 }
87 async fn check_schema(_paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
89 Vec::new()
90 }
91 async fn check_websites(_paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
93 Vec::new()
94 }
95 fn output_path(path: &Path, data: &Self) -> PathBuf;
97 fn standard() -> Standard;
99 #[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 #[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}
115pub trait IntoChecks {
117 fn to_checks(&self, uri: Option<String>) -> Vec<Check>;
119}
120#[cfg(feature = "analysis")]
124pub trait IntoRow<'a> {
125 fn to_row<T>(self) -> Row<'a>;
127}
128#[derive(Clone, Copy, Debug, Default, Display, PartialEq, Eq)]
130pub enum Standard {
131 #[default]
133 #[display("research-activity-data")]
134 ResearchActivityData,
135 #[display("citation-file-format")]
137 CitationFileFormat,
138 #[display("datacite")]
140 Datacite,
141 #[display("dcat")]
143 Dcat,
144 #[display("docx")]
146 Docx,
147 #[display("dublin-core")]
149 DublinCore,
150 #[display("huwise")]
152 Huwise,
153 #[display("invenio")]
155 Invenio,
156 #[display("raid")]
158 Raid,
159 #[display("text")]
161 Text,
162}
163#[derive(Clone, Debug, Default, Deserialize, Display, EnumIter, PartialEq, Serialize, VariantNames)]
165#[serde(rename_all = "lowercase")]
166#[strum(serialize_all = "lowercase")]
167pub enum CheckCategory {
168 #[default]
170 #[display("schema")]
171 Schema,
172 #[display("link")]
174 Link,
175 #[display("prose")]
177 Prose,
178 #[display("quality")]
180 Quality,
181 #[display("readability")]
183 Readability,
184 #[display("crosswalk")]
186 Crosswalk,
187}
188#[derive(Clone, Debug, Default, Deserialize, Display, PartialEq, Serialize)]
190#[serde(rename_all = "lowercase")]
191pub enum CheckSeverity {
192 #[default]
194 #[display("error")]
195 Error,
196 #[display("info")]
198 Info,
199 #[display("suggestion")]
201 Suggestion,
202 #[display("warning")]
204 Warning,
205}
206#[derive(Clone, Copy, Debug, Default, EnumIs, Eq, PartialEq)]
207pub enum CheckKind {
209 #[default]
211 Standard,
212 Lifecycle,
214}
215#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
217pub enum OutputFormat {
218 #[default]
220 Json,
221 Console,
223 Markdown,
225 Raw,
227 Yaml,
229}
230#[derive(Builder, Clone, Debug)]
232#[builder(start_fn = init, on(String, into))]
233pub struct Check {
234 #[builder(default = 0)]
236 pub index: usize,
237 pub category: CheckCategory,
239 pub locator: Option<String>,
241 pub context: Option<String>,
243 data: Option<String>,
245 document: Option<Arc<DocumentIndex>>,
247 #[builder(default)]
248 kind: CheckKind,
249 #[builder(default = false)]
251 pub success: bool,
252 #[builder(default)]
254 pub severity: CheckSeverity,
255 status_code: Option<String>,
257 pub errors: Option<ErrorKind>,
259 pub uri: Option<String>,
261 #[builder(default = "".to_string())]
263 pub message: String,
264}
265#[derive(Builder, Clone, Debug)]
267#[builder(start_fn = init, on(String, into), on(ReadabilityType, into))]
268#[derive(Default)]
269pub struct CheckOptions {
270 #[builder(default = false)]
272 pub all: bool,
273 #[builder(default = false)]
275 pub disable_website_checks: bool,
276 #[builder(default = false)]
278 pub exit_on_first_error: bool,
279 #[builder(default = false)]
281 pub offline: bool,
282 #[builder(default = false)]
284 pub quiet: bool,
285 #[builder(default = false)]
287 pub no_fail: bool,
288 #[builder(default = false)]
290 pub ignore_sync_failure: bool,
291 #[builder(default)]
293 pub skip: Vec<String>,
294 #[builder(default)]
296 pub standard: Standard,
297 #[builder(default)]
299 pub readability_metric: ReadabilityType,
300 #[builder(default = false)]
302 pub skip_verify_checksum: bool,
303 #[builder(default = false)]
305 pub skip_sync: bool,
306 #[builder(default = SignedDuration::from_secs(1))]
308 pub sync_retry_interval: SignedDuration,
309 #[builder(default = false)]
311 pub terse: bool,
312 #[builder(default = false)]
314 pub filter_by_verbosity: bool,
315 #[builder(default)]
317 pub format: OutputFormat,
318 pub verbosity: Option<u8>,
320 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 #[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 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 pub fn is_failure(&self) -> bool {
395 !self.success && self.severity.is_failure()
396 }
397 pub fn is_visible_at(level: Option<u8>) -> impl Fn(&Check) -> bool {
399 move |check: &Check| check.severity.is_visible_at(level)
400 }
401 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 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 pub fn with_context(self, value: String) -> Self {
634 Self {
635 context: Some(value),
636 ..self
637 }
638 }
639 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 pub fn with_index(self, value: usize) -> Self {
651 Self { index: value, ..self }
652 }
653 pub fn with_locator(self, value: Option<String>) -> Self {
655 Self { locator: value, ..self }
656 }
657 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 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 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 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 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 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 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 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 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 #[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#[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#[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#[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}
1238fn 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}
1284pub 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}