#[cfg(feature = "std")]
use crate::fail;
#[cfg(feature = "std")]
use crate::io::ApiResult;
#[cfg(feature = "std")]
use crate::io::{image_paths, parent, read_file, write_file, FromPath, InputOutput};
#[cfg(feature = "std")]
use crate::prelude::PathBuf;
use crate::prelude::*;
use crate::schema::hardware::Resource;
use crate::schema::namespaces::{bibo, codemeta, dcat, dcterms, schema_org, DEFAULT_RAID_SCHEMA_URI, DEFAULT_ROR_SCHEMA_URI};
use crate::schema::pid::PersistentIdentifierConvert;
use crate::schema::standard::cff::{Agent, Cff, Identifier, IdentifierType, Person};
use crate::schema::validate::{is_doi, is_kebabcase, is_ror, is_urls};
use crate::schema::{ClassificationLevel, ContactPoint, ImageObject, Keyword, MediaObject, OrganizationType, Other, Status, Website};
use crate::util::constants::{DEFAULT_GRAPHIC_CAPTION, DEFAULT_GRAPHIC_HREF, MAX_LENGTH_SUBTITLE, MAX_LENGTH_TITLE};
use crate::util::constants::{
MAX_COUNT_APPROACH, MAX_COUNT_CAPABILITIES, MAX_COUNT_IMPACT, MAX_COUNT_RESEARCH_AREAS, MAX_LENGTH_RESEARCH_FOCUS, MAX_LENGTH_SECTION_CHALLENGE,
MAX_LENGTH_SECTION_MISSION,
};
use crate::util::constants::{MAX_LENGTH_APPROACH, MAX_LENGTH_CAPABILIY, MAX_LENGTH_IMPACT, MAX_LENGTH_RESEARCH_AREA};
#[cfg(feature = "std")]
use crate::util::frontmatter_and_body;
#[cfg(feature = "std")]
use crate::util::{Constant, Label};
use crate::util::{LinkedData, ToMarkdown, ToProse};
#[cfg(feature = "std")]
use crate::util::{MimeType, StringConversion};
use bon::Builder;
#[cfg(feature = "std")]
use color_eyre::eyre::eyre;
use convert_case::{Case, Casing};
use core::hash::{Hash, Hasher};
use derive_more::Display;
#[cfg(feature = "std")]
use fancy_regex::Regex;
#[cfg(feature = "std")]
use nucleo_matcher::{
pattern::{CaseMatching, Normalization, Pattern},
Config, Matcher,
};
#[cfg(feature = "std")]
use owo_colors::OwoColorize;
#[cfg(feature = "std")]
use schemars::schema_for;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_trim::{option_string_trim, string_trim, vec_string_trim};
use serde_with::skip_serializing_none;
#[cfg(feature = "std")]
use tracing::{debug, error, trace};
use validator::{Validate, ValidationError};
pub mod aspect;
pub mod s3a;
use aspect::AspectFramework;
type ValidationResult = core::result::Result<(), ValidationError>;
#[derive(Debug, Display)]
enum Vocabulary {
#[display("keywords")]
Keywords,
#[display("partners")]
Partners,
#[display("sponsors")]
Sponsors,
#[display("technology")]
Technology,
}
#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
#[builder(start_fn = init)]
#[serde(deny_unknown_fields)]
pub struct Research {
#[validate(length(
min = 10,
max = "MAX_LENGTH_RESEARCH_FOCUS",
message = "Focus is too long, reduce the length below 150 characters."
))]
#[builder(default = "Focus of the research".to_string())]
#[serde(deserialize_with = "string_trim")]
pub focus: String,
#[validate(length(min = 1, max = "MAX_COUNT_RESEARCH_AREAS"), custom(function = "is_attribute_areas"))]
#[builder(default = vec!["Areas of research".to_string()])]
#[serde(deserialize_with = "vec_string_trim")]
pub areas: Vec<String>,
}
#[skip_serializing_none]
#[derive(Builder, Clone, Debug, Display, eserde::Deserialize, Serialize, JsonSchema, Validate)]
#[builder(start_fn = init)]
#[display("Research Activity ({title})")]
#[serde(deny_unknown_fields)]
pub struct ResearchActivity {
#[serde(rename = "@context")]
#[eserde(compat)]
pub context: Option<ResearchActivityContext>,
#[serde(rename = "@type")]
pub research_activity_type: Option<String>,
#[validate(nested)]
#[builder(default)]
pub meta: ResearchActivityMetadata,
#[eserde(compat)]
pub aspect: Option<AspectFramework>,
#[validate(length(min = 4, max = "MAX_LENGTH_TITLE"))]
#[builder(default = "Research Activity Title".to_string())]
#[serde(deserialize_with = "string_trim")]
pub title: String,
#[validate(length(max = "MAX_LENGTH_SUBTITLE", message = "Subtitle is too long, reduce the length below 75 characters."))]
#[serde(default, deserialize_with = "option_string_trim")]
pub subtitle: Option<String>,
#[validate(nested)]
#[builder(default)]
#[eserde(compat)]
pub sections: Sections,
#[validate(nested)]
#[builder(default)]
#[eserde(compat)]
pub contact: ContactPoint,
#[eserde(compat)]
pub notes: Option<Other>,
}
#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[builder(start_fn = init, on(String, into))]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ResearchActivityContext {
pub meta: String,
pub title: String,
pub subtitle: String,
pub sections: String,
pub contact: String,
pub notes: String,
}
#[skip_serializing_none]
#[derive(Builder, Clone, Debug, Serialize, eserde::Deserialize, JsonSchema, Validate)]
#[builder(start_fn = init)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ResearchActivityMetadata {
#[serde(rename = "@context")]
#[eserde(compat)]
pub context: Option<ResearchActivityMetadataContext>,
#[serde(rename = "@type")]
pub metadata_type: Option<String>,
#[eserde(compat)]
pub classification: Option<ClassificationLevel>,
#[builder(default = false)]
pub archive: bool,
#[builder(default = true)]
pub draft: bool,
#[builder(default = Status::Active)]
#[serde(default)]
#[eserde(compat)]
pub status: Status,
#[validate(custom(function = "is_kebabcase"))]
#[builder(default = "some-research-project".to_string())]
#[serde(alias = "id", rename = "identifier", deserialize_with = "string_trim")]
pub identifier: String,
#[validate(custom(function = "is_attribute_doi_list"))]
#[serde(default)]
pub doi: Option<Vec<String>>,
#[validate(custom(function = "is_attribute_books"))]
#[serde(default)]
pub books: Option<Vec<String>>,
#[serde(default)]
pub patents: Option<Vec<String>>,
#[validate(custom(function = "is_urls"))]
#[serde(default)]
pub publications: Option<Vec<String>>,
#[validate(custom(function = "is_attribute_doi_list"))]
#[serde(default)]
pub raid: Option<Vec<String>>,
#[validate(custom(function = "is_attribute_ror_list"))]
#[serde(default)]
pub ror: Option<Vec<String>>,
#[eserde(compat)]
pub additional_type: Option<OrganizationType>,
#[validate(nested)]
#[serde(alias = "graphics")]
#[eserde(compat)]
pub media: Option<Vec<MediaObject>>,
#[validate(nested)]
#[eserde(compat)]
pub websites: Option<Vec<Website>>,
#[builder(default = Vec::<String>::new())]
pub keywords: Vec<Keyword>,
#[builder(default = Vec::<String>::new())]
#[serde(deserialize_with = "vec_string_trim")]
pub technology: Vec<String>,
pub sponsors: Option<Vec<String>>,
pub partners: Option<Vec<String>>,
pub related: Option<Vec<String>>,
#[eserde(compat)]
pub resources: Option<Vec<Resource>>,
}
#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[builder(start_fn = init, on(String, into))]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ResearchActivityMetadataContext {
pub classification: String,
pub archive: String,
pub draft: String,
pub status: String,
pub identifier: String,
pub doi: String,
pub raid: String,
pub ror: String,
pub additional_type: String,
pub media: String,
pub websites: String,
pub keywords: String,
pub technology: String,
pub sponsors: String,
pub partners: String,
pub related: String,
pub resources: String,
}
#[skip_serializing_none]
#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
#[builder(start_fn = init)]
#[serde(deny_unknown_fields)]
pub struct Sections {
#[validate(length(
min = 10,
max = "MAX_LENGTH_SECTION_MISSION",
message = "Mission is too long, reduce the length below 250 characters."
))]
#[builder(default = "Purpose of the research".to_string())]
#[serde(alias = "introduction", deserialize_with = "string_trim")]
pub mission: String,
#[validate(length(
min = 10,
max = "MAX_LENGTH_SECTION_CHALLENGE",
message = "Challenge is too long, reduce the length below 500 characters."
))]
#[builder(default = "Reason for the research".to_string())]
#[serde(deserialize_with = "string_trim")]
pub challenge: String,
#[validate(
length(min = 1, max = "MAX_COUNT_APPROACH", message = "Limit the number of approaches to 6"),
custom(function = "is_attribute_approach")
)]
#[builder(default = vec!["List of actions taken to perform the research".to_string()])]
#[serde(deserialize_with = "vec_string_trim")]
pub approach: Vec<String>,
#[validate(length(min = 1, max = "MAX_COUNT_IMPACT"), custom(function = "is_attribute_impact"))]
#[builder(default = vec!["List of tangible proof that validates the research approach".to_string()])]
#[serde(alias = "outcomes", deserialize_with = "vec_string_trim")]
pub impact: Vec<String>,
#[validate(length(min = 1, max = 4, message = "Limit the number of achievements to 4"))]
pub achievement: Option<Vec<String>>,
#[validate(length(min = 1, max = "MAX_COUNT_CAPABILITIES"), custom(function = "is_attribute_capabilities"))]
pub capabilities: Option<Vec<String>>,
#[validate(nested)]
#[builder(default = Research::init().build())]
pub research: Research,
}
impl Default for ResearchActivity {
fn default() -> Self {
ResearchActivity::init().build()
}
}
impl Default for ResearchActivityContext {
fn default() -> Self {
ResearchActivityContext::init()
.meta(schema_org("CreativeWork"))
.title(dcterms("title"))
.subtitle(schema_org("alternativeHeadline"))
.sections(schema_org("CreativeWork"))
.contact(dcat("contactPoint"))
.notes(schema_org("Text"))
.build()
}
}
impl Default for ResearchActivityMetadata {
fn default() -> Self {
ResearchActivityMetadata::init().build()
}
}
impl Default for ResearchActivityMetadataContext {
fn default() -> Self {
ResearchActivityMetadataContext::init()
.classification(schema_org("DefinedTerm"))
.archive(schema_org("Boolean"))
.draft(schema_org("Boolean"))
.status(schema_org("DefinedTerm"))
.identifier(codemeta("identifier"))
.doi(bibo("doi"))
.raid(DEFAULT_RAID_SCHEMA_URI)
.ror(DEFAULT_ROR_SCHEMA_URI)
.additional_type(schema_org("additionalType"))
.media(schema_org("MediaObject"))
.websites(schema_org("WebSite"))
.keywords(dcat("keyword"))
.technology(schema_org("DefinedTerm"))
.sponsors(codemeta("sponsor"))
.partners(schema_org("Text"))
.related(schema_org("Text"))
.resources(schema_org("DefinedTerm"))
.build()
}
}
impl Hash for ResearchActivity {
fn hash<H: Hasher>(&self, state: &mut H) {
self.meta.identifier.hash(state);
}
}
impl LinkedData for ResearchActivity {
fn with_context(&self) -> Self {
Self {
context: Some(ResearchActivityContext::default()),
research_activity_type: None,
meta: self.meta.with_context(),
contact: self.contact.with_context(),
..self.clone().copy()
}
}
}
impl LinkedData for ResearchActivityMetadata {
fn with_context(&self) -> Self {
Self {
context: Some(ResearchActivityMetadataContext::default()),
metadata_type: None,
..self.clone()
}
}
}
#[cfg(feature = "std")]
impl InputOutput for ResearchActivity {
fn read(path: impl Into<PathBuf>) -> ApiResult<ResearchActivity> {
let source = path.into().clone();
let result = match MimeType::from_path(&source) {
| MimeType::Json => ResearchActivity::read_json(source.clone()),
| MimeType::Yaml => ResearchActivity::read_yaml(source.clone()),
| _ => Err(eyre!("Unsupported research activity data file extension")),
};
if let Ok(data) = &result {
debug!(path = source.to_string_lossy().to_string(), "=> {}", Label::using());
debug!("=> {} Research activity data = {:#?}", Label::using(), data.dimmed().cyan());
}
result
}
fn read_json(path: PathBuf) -> ApiResult<ResearchActivity> {
read_file(path.clone()).and_then(|content| {
eserde::json::from_str::<ResearchActivity>(&content).map_err(|errors| {
let details: Vec<String> = errors
.iter()
.map(|e| format!("{}: {}", e.path().map_or("root".into(), |p| p.to_string()), e.message()))
.collect();
eyre!("{}", details.join("\n"))
})
})
}
fn read_markdown(path: PathBuf) -> Option<ResearchActivity> {
let content = match read_file(path.clone()) {
| Ok(value) => value,
| Err(_) => {
error!(path = path.to_string_lossy().to_string(), "=> {} RAD content is not valid", Label::fail());
"{}".to_owned()
}
};
let (frontmatter, _body) = frontmatter_and_body(&content);
let _meta: ResearchActivityMetadata = match frontmatter {
| Some(value) => match serde_norway::from_str(&value) {
| Ok(data) => data,
| Err(why) => {
fail!("Parse RAD frontmatter - {}", why.red());
ResearchActivityMetadata::default()
}
},
| None => ResearchActivityMetadata::default(),
};
None
}
fn read_yaml(path: PathBuf) -> ApiResult<ResearchActivity> {
read_file(path.clone()).and_then(|content| serde_norway::from_str(&content).map_err(|why| eyre!("Failed to parse YAML RAD — {why}")))
}
fn write(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
let output = path.into();
let mime = MimeType::from_path(&output);
match mime {
| MimeType::Cff => self.write_cff(output),
| MimeType::Json => self.write_json(output),
| MimeType::Markdown => self.write_markdown(output),
| MimeType::Yaml => self.write_yaml(output),
| _ => Err(eyre!("Unsupported research activity data file extension for writing")),
}
}
fn write_cff(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
let output = path.into().with_extension("cff");
let data: Cff = self.into();
serde_norway::to_string(&data)
.map_err(|why| eyre!("Failed to serialize RAD CITATION.cff — {why}"))
.and_then(|content| write_file(output, content))
}
fn write_json(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
let output = path.into().with_extension("json");
serde_json::to_string_pretty(&self)
.map_err(|why| eyre!("Failed to serialize JSON RAD — {why}"))
.and_then(|content| write_file(output, content))
}
fn write_markdown(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
let output = path.into().with_extension("md");
let content = self.to_markdown();
write_file(output, content)
}
fn write_yaml(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
let output = path.into().with_extension("yaml");
serde_json::to_value(self)
.map_err(|why| eyre!("Failed to convert RAD to value for YAML serialization — {why}"))
.and_then(|value| serde_norway::to_string(&value).map_err(|why| eyre!("Failed to serialize YAML RAD — {why}")))
.and_then(|content| write_file(output, content))
}
}
impl ResearchActivity {
pub fn new() -> Self {
ResearchActivity::default()
}
#[cfg(feature = "std")]
pub fn serialize_as(&self, mime: &MimeType) -> ApiResult<String> {
match mime {
| MimeType::Json => serde_json::to_string_pretty(self).map_err(|why| eyre!("Failed to serialize JSON RAD — {why}")),
| MimeType::Yaml => serde_json::to_value(self)
.map_err(|why| eyre!("Failed to convert RAD to value for YAML serialization — {why}"))
.and_then(|value| serde_norway::to_string(&value).map_err(|why| eyre!("Failed to serialize YAML RAD — {why}"))),
| _ => Err(eyre!("Unsupported mime type for research activity serialization: {mime:?}")),
}
}
#[cfg(feature = "std")]
pub fn to_schema() {
let schema = schema_for!(ResearchActivity);
match serde_json::to_string_pretty(&schema) {
| Ok(json) => println!("{}", json),
| Err(why) => eprintln!("Failed to serialize schema: {}", why),
}
}
pub fn copy(&self) -> ResearchActivity {
let ResearchActivity {
meta,
title,
subtitle,
sections,
contact,
notes,
..
} = self.clone();
ResearchActivity::init()
.meta(meta)
.title(title)
.maybe_subtitle(subtitle)
.sections(sections)
.contact(contact)
.maybe_notes(notes)
.build()
}
pub fn format(self) -> ResearchActivity {
let Self { meta, contact, .. } = self.clone();
Self {
meta: meta.format(),
contact: contact.format(),
..self
}
}
#[cfg(feature = "std")]
pub fn format_with(self, context: Option<PathBuf>) -> ResearchActivity {
let Self { meta, contact, .. } = self.clone();
Self {
meta: meta.format_with(context.clone()),
contact: contact.format_with(context.clone()),
..self
}
.format()
}
}
impl From<&ResearchActivity> for Cff {
fn from(rad: &ResearchActivity) -> Self {
let sections = rad.sections.clone();
let contact = rad.contact.clone();
let meta = rad.meta.clone();
let title = rad.title.clone();
let ContactPoint {
given_name,
family_name,
identifier: orcid,
email,
organization,
affiliation,
..
} = contact;
let person_affiliation = affiliation.or(if organization.is_empty() { None } else { Some(organization) });
let person = Agent::Person(Person {
given_names: Some(given_name),
family_names: Some(family_name),
orcid,
email: Some(email),
affiliation: person_affiliation,
address: None,
alias: None,
city: None,
country: None,
fax: None,
name_particle: None,
name_suffix: None,
postal_code: None,
region: None,
tel: None,
website: None,
});
let keywords = if meta.keywords.is_empty() { None } else { Some(meta.keywords) };
let (doi, identifiers) = match meta.doi {
| Some(dois) => match dois.as_slice() {
| [single] => (Some(single.clone()), None),
| [] => (None, None),
| _ => (
None,
Some(
dois.into_iter()
.map(|value| Identifier {
description: None,
kind: IdentifierType::Doi,
value,
})
.collect(),
),
),
},
| None => (None, None),
};
Cff {
abstract_text: Some(sections.mission),
authors: vec![person.clone()],
contact: Some(vec![person]),
keywords,
doi,
identifiers,
title,
..Cff::default()
}
}
}
impl From<ResearchActivity> for Cff {
fn from(rad: ResearchActivity) -> Self {
Cff::from(&rad)
}
}
impl ToMarkdown for ResearchActivity {
fn to_markdown(&self) -> String {
let Self {
meta,
title,
subtitle,
sections,
..
} = self;
let classification = meta.clone().classification.unwrap_or_default();
let frontmatter = serde_norway::to_string(&meta).unwrap_or_default();
let sections = sections.to_markdown();
let contact = self.contact.to_markdown();
match &subtitle {
| Some(subtitle) => format!(
r#"---
classification: {classification}
{frontmatter}---
# {title}
> {subtitle}
{sections}
{contact}"#
),
| None => format!(
r#"---
classification: {classification}
{frontmatter}---
# {title}
{sections}
{contact}"#
),
}
}
}
impl ToProse for ResearchActivity {
fn to_prose(&self) -> String {
let websites = self
.meta
.websites
.clone()
.unwrap_or_default()
.into_iter()
.map(|website| website.to_markdown())
.collect::<Vec<String>>()
.join("\n");
let sections = self.sections.to_markdown();
match &self.subtitle {
| Some(subtitle) => format!(
r#"{title}
{subtitle}
{sections}
{websites}"#,
title = self.title
),
| None => format!(
r#"{title}
{sections}
{websites}"#,
title = self.title
),
}
}
}
impl ResearchActivityMetadata {
pub(crate) fn first_image(self) -> Option<MediaObject> {
match self.media {
| Some(values) => values.into_iter().filter(|x| x.clone().is_image()).collect::<Vec<_>>().first().cloned(),
| None => None,
}
}
pub fn first_image_content_url(self) -> String {
match self.first_image() {
| Some(media) => match media {
| MediaObject::Image(ImageObject { content_url, .. }) => match content_url {
| Some(value) if !value.is_empty() => value.clone().trim().to_string(),
| Some(_) | None => DEFAULT_GRAPHIC_HREF.to_string(),
},
| _ => DEFAULT_GRAPHIC_HREF.to_string(),
},
| None => DEFAULT_GRAPHIC_HREF.to_string(),
}
}
pub fn first_image_caption(self) -> String {
match self.first_image() {
| Some(MediaObject::Image(ImageObject { caption, .. })) => match caption.clone() {
| value if !value.is_empty() => value.clone(),
| _ => DEFAULT_GRAPHIC_CAPTION.to_string(),
},
| Some(_) | None => DEFAULT_GRAPHIC_CAPTION.to_string(),
}
}
pub fn format(self) -> Self {
let keywords = Vocabulary::Keywords.resolve(Some(self.keywords.clone()));
let technology = Vocabulary::Technology.resolve(Some(self.technology.clone()));
let partners = match Vocabulary::Partners.resolve(self.partners.clone()) {
| values if !values.is_empty() => Some(values),
| _ => None,
};
let sponsors = match Vocabulary::Sponsors.resolve(self.sponsors.clone()) {
| values if !values.is_empty() => Some(values),
| _ => None,
};
Self {
keywords,
technology,
partners,
sponsors,
..self
}
}
#[cfg(feature = "std")]
pub fn format_with(self, path: Option<PathBuf>) -> Self {
let path_parent = match path {
| Some(value) => parent(value),
| None => PathBuf::from("."),
};
debug!(path = path_parent.to_absolute_string(), "=> {} Parent directory", Label::using());
let name = match image_paths(&path_parent) {
| value if !value.is_empty() => value.first().and_then(|v| v.file_name().map(|f| f.to_string_lossy().to_string())),
| _ => None,
};
let media = match name {
| Some(value) => {
debug!(value, "=> {} First image", Label::using());
let first_graphic = match self.media.clone() {
| Some(values) if !values.is_empty() => {
let caption = self.clone().first_image_caption();
let image_data = ImageObject::init().caption(caption.to_string()).content_url(value.clone()).build();
MediaObject::Image(image_data)
}
| Some(_) | None => {
let image_data = ImageObject::init().caption("".to_string()).content_url(value.clone()).build();
MediaObject::Image(image_data)
}
};
let rest = match self.clone().media {
| Some(values) if !values.is_empty() => values.into_iter().skip(1).collect::<Vec<_>>(),
| Some(_) | None => vec![],
};
Some([vec![first_graphic], rest].concat())
}
| None => self.media.clone(),
};
Self { media, ..self }.format()
}
}
impl Vocabulary {
fn resolve(self, values: Option<Vec<String>>) -> Vec<String> {
match values {
| Some(items) => {
let mut data: Vec<_> = items.into_iter().flat_map(|x| resolve_from_csv_asset(self.to_string(), x)).collect();
data.sort();
data.dedup();
data
}
| None => vec![],
}
}
}
impl Default for Sections {
fn default() -> Self {
Sections::init().build()
}
}
impl ToMarkdown for Research {
fn to_markdown(&self) -> String {
let Research { focus, areas } = self;
format!(
r#"
## Focus
{focus}
## Areas{}"#,
areas.to_markdown(),
)
}
}
impl ToMarkdown for Sections {
fn to_markdown(&self) -> String {
let Sections {
mission,
challenge,
approach,
impact,
research,
..
} = self;
format!(
r#"
## Mission
{}
## Challenge
{}
## Approach{}
## Impact{}
{}"#,
mission,
challenge,
approach.to_markdown(),
impact.to_markdown(),
research.to_markdown(),
)
}
}
#[cfg(feature = "std")]
pub(crate) fn resolve_from_csv_asset(name: String, value: String) -> Option<String> {
let data = Constant::csv(&name);
resolve_from_list_of_lists(value, data, name)
}
#[cfg(not(feature = "std"))]
pub(crate) fn resolve_from_csv_asset(_name: String, value: String) -> Option<String> {
Some(value)
}
#[cfg(feature = "std")]
pub(crate) fn resolve_from_list_of_lists<I: IntoIterator<Item = Vec<String>>>(value: String, data: I, name: String) -> Option<String> {
fn match_list<I: IntoIterator<Item = String> + Clone>(value: String, values: I) -> Vec<(String, u32)> {
let pattern = Pattern::parse(&value, CaseMatching::Ignore, Normalization::Smart);
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
pattern.match_list(values.clone(), &mut matcher)
}
fn print_resolution(output: Option<String>, value: String, name: String) {
let label = name.to_case(Case::Title);
match output {
| Some(resolved) => {
if resolved.eq(&value.to_string()) {
trace!("=> {} {} = \"{}\"", Label::using(), label, value.clone());
} else {
debug!(input = value.clone(), resolved, "=> {} {}", Label::found(), label);
}
}
| None => {
debug!(value = value.clone(), "=> {} {}", Label::not_found(), label);
}
};
}
fn sanitize(value: String) -> String {
match Regex::new(r"[-_.,]") {
| Ok(re) => re.replace_all(&value, "").replace("&", "and").trim().to_string(),
| Err(err) => err.to_string(),
}
}
let output = data
.into_iter()
.flat_map(|values| {
let sanitized = sanitize(value.clone());
let matched = match_list(sanitized, values.clone().into_iter().take(4));
trace!("{} => {:?}", value.clone(), matched.clone());
if matched.clone().is_empty() {
None
} else {
match values.first() {
| Some(x) => {
if value.eq(x) {
Some((x.into(), 10000))
} else {
let score = matched.into_iter().map(|(_, score)| score).max();
match score {
| Some(value) if value > 0 => Some((x.to_string(), value)),
| Some(_) | None => None,
}
}
}
| None => None,
}
}
})
.max_by_key(|(_, score)| *score)
.map(|(x, _)| x.to_string());
print_resolution(output.clone(), value, name);
output
}
#[cfg(not(feature = "std"))]
#[allow(dead_code)]
pub(crate) fn resolve_from_list_of_lists<I: IntoIterator<Item = Vec<String>>>(value: String, _data: I, _name: String) -> Option<String> {
Some(value)
}
fn validation_error_with_index(code: &'static str, message: String, index: usize) -> ValidationError {
let mut err = ValidationError::new(code).with_message(message.into());
err.add_param("index".into(), &index);
err
}
fn validation_error_with_index_and_length(code: &'static str, message: String, index: usize, length: usize) -> ValidationError {
let mut err = validation_error_with_index(code, message, index);
err.add_param("length".into(), &length);
err
}
pub(crate) fn is_attribute_approach(value: &[String]) -> ValidationResult {
const CODE: &str = "sections.approach";
const MAX_LENGTH: usize = MAX_LENGTH_APPROACH;
value
.iter()
.enumerate()
.find(|(_, x)| x.len() > MAX_LENGTH)
.map(|(index, x)| {
let length = x.len();
validation_error_with_index_and_length(
CODE,
format!("Each approach statement should be less than {MAX_LENGTH} characters"),
index,
length,
)
})
.map_or(Ok(()), Err)
}
pub(crate) fn is_attribute_areas(value: &[String]) -> ValidationResult {
const CODE: &str = "sections.areas";
const MAX_LENGTH: usize = MAX_LENGTH_RESEARCH_AREA;
value
.iter()
.enumerate()
.find(|(_, x)| x.len() > MAX_LENGTH)
.map(|(index, x)| {
let length = x.len();
validation_error_with_index_and_length(CODE, format!("Each area should be less than {MAX_LENGTH} characters"), index, length)
})
.map_or(Ok(()), Err)
}
pub(crate) fn is_attribute_books(value: &[String]) -> ValidationResult {
const CODE: &str = "meta.books";
value
.iter()
.position(|x| !x.is_isbn())
.map(|index| validation_error_with_index(CODE, "Every book should be a valid ISBN".to_string(), index))
.map_or(Ok(()), Err)
}
pub(crate) fn is_attribute_capabilities(value: &[String]) -> ValidationResult {
const CODE: &str = "sections.capabilities";
const MAX_LENGTH: usize = MAX_LENGTH_CAPABILIY;
value
.iter()
.enumerate()
.find(|(_, x)| x.len() > MAX_LENGTH)
.map(|(index, x)| {
let length = x.len();
validation_error_with_index_and_length(
CODE,
format!("Each capability should be less than {MAX_LENGTH} characters"),
index,
length,
)
})
.map_or(Ok(()), Err)
}
pub(crate) fn is_attribute_doi_list(value: &[String]) -> ValidationResult {
const CODE: &str = "meta.doi";
value
.iter()
.position(|x| is_doi(x).is_err())
.map(|index| validation_error_with_index(CODE, "Every DOI should be valid".to_string(), index))
.map_or(Ok(()), Err)
}
pub(crate) fn is_attribute_ror_list(value: &[String]) -> ValidationResult {
const CODE: &str = "meta.ror";
value
.iter()
.position(|x| is_ror(x).is_err())
.map(|index| validation_error_with_index(CODE, "Every ROR should be valid".to_string(), index))
.map_or(Ok(()), Err)
}
pub(crate) fn is_attribute_impact(value: &[String]) -> ValidationResult {
const CODE: &str = "sections.impact";
const MAX_LENGTH: usize = MAX_LENGTH_IMPACT;
value
.iter()
.enumerate()
.find(|(_, x)| x.len() > MAX_LENGTH)
.map(|(index, x)| {
let length = x.len();
validation_error_with_index_and_length(
CODE,
format!("Each impact statement should be less than {MAX_LENGTH} characters"),
index,
length,
)
})
.or_else(|| {
value
.first()
.and_then(|first| {
let ends_with_period = first.trim().ends_with(".");
value.iter().position(|x| x.trim().ends_with(".") != ends_with_period)
})
.map(|index| {
validation_error_with_index(
CODE,
"Impact statements should be all sentences with periods or all phrases without periods".to_string(),
index,
)
})
})
.or_else(|| {
value
.iter()
.position(|x| {
x.trim().chars().find(|c| c.is_alphabetic()).is_none_or(|letter| {
let actual = letter.to_string();
actual != actual.to_case(Case::Upper)
})
})
.map(|index| validation_error_with_index(CODE, "Impact statements should begin with a capital letter".to_string(), index))
})
.map_or(Ok(()), Err)
}
#[cfg(test)]
mod tests;