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