Skip to main content

acorn/schema/research_activity/
mod.rs

1//! # Research activity schema
2//!
3//! Module that defines the research activity schema
4//!
5#[cfg(feature = "std")]
6use crate::error::ApiResult;
7#[cfg(feature = "std")]
8use crate::io::{image_paths, jsonc_parse_value, parent, read_file, write_file, FromPath, InputOutput};
9#[cfg(feature = "std")]
10use crate::prelude::PathBuf;
11use crate::prelude::*;
12use crate::schema::hardware::Resource;
13use crate::schema::namespaces::{bibo, codemeta, dcat, dcterms, schema_org, DEFAULT_RAID_SCHEMA_URI, DEFAULT_ROR_SCHEMA_URI};
14use crate::schema::pid::{PersistentIdentifierConvert, PublicationIdentifierType};
15use crate::schema::standard::cff::{Agent, Cff, Identifier, IdentifierType, Person};
16use crate::schema::standard::text::Text;
17use crate::schema::validate::{is_doi, is_kebabcase, is_ror, is_urls};
18use crate::schema::{
19    ClassificationLevel, ContactPoint, ControlledVocabulary, ImageObject, Keyword, MediaObject, OrganizationType, Other, Status, Website,
20};
21use crate::util::constants::{DEFAULT_GRAPHIC_CAPTION, DEFAULT_GRAPHIC_HREF, MAX_LENGTH_SUBTITLE, MAX_LENGTH_TITLE};
22use crate::util::constants::{
23    MAX_COUNT_APPROACH, MAX_COUNT_CAPABILITIES, MAX_COUNT_IMPACT, MAX_COUNT_RESEARCH_AREAS, MAX_LENGTH_RESEARCH_FOCUS, MAX_LENGTH_SECTION_CHALLENGE,
24    MAX_LENGTH_SECTION_MISSION,
25};
26use crate::util::constants::{MAX_LENGTH_APPROACH, MAX_LENGTH_CAPABILIY, MAX_LENGTH_IMPACT, MAX_LENGTH_RESEARCH_AREA};
27#[cfg(feature = "std")]
28use crate::util::{Constant, Label};
29use crate::util::{LinkedData, MarkdownSupport, ToProse, Unstructured};
30#[cfg(feature = "std")]
31use crate::util::{MimeType, StringConversion};
32use bon::Builder;
33#[cfg(feature = "std")]
34use color_eyre::eyre::eyre;
35use convert_case::{Case, Casing};
36use core::hash::{Hash, Hasher};
37use derive_more::Display;
38#[cfg(feature = "std")]
39use nucleo_matcher::{
40    pattern::{CaseMatching, Normalization, Pattern},
41    Config, Matcher,
42};
43#[cfg(feature = "std")]
44use owo_colors::OwoColorize;
45#[cfg(feature = "std")]
46use schemars::schema_for;
47use schemars::JsonSchema;
48use serde::{Deserialize, Serialize};
49use serde_trim::{option_string_trim, string_trim, vec_string_trim};
50use serde_with::skip_serializing_none;
51#[cfg(feature = "std")]
52use tracing::{debug, trace};
53use validator::{Validate, ValidationError};
54
55pub mod aspect;
56mod markdown;
57pub mod s3a;
58use aspect::AspectFramework;
59use markdown::Document;
60pub(crate) use markdown::MarkdownParser;
61
62type ValidationResult = core::result::Result<(), ValidationError>;
63#[derive(Debug, Display)]
64enum Vocabulary {
65    #[display("keywords")]
66    Keywords,
67    #[display("partners")]
68    Partners,
69    #[display("sponsors")]
70    Sponsors,
71    #[display("technology")]
72    Technology,
73}
74/// Overview of research focus and areas
75#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
76#[builder(start_fn = init)]
77#[serde(deny_unknown_fields)]
78pub struct Research {
79    /// Brief overview of the project or organization's research
80    #[validate(length(
81        min = 10,
82        max = "MAX_LENGTH_RESEARCH_FOCUS",
83        message = "Focus is too long, reduce the length below 150 characters."
84    ))]
85    #[builder(default = "Focus of the research".to_string())]
86    #[serde(deserialize_with = "string_trim")]
87    pub focus: String,
88    /// Topics related to and encapsulated within the project or organization
89    #[validate(length(min = 1, max = "MAX_COUNT_RESEARCH_AREAS"), custom(function = "is_attribute_areas"))]
90    #[builder(default = vec!["Areas of research".to_string()])]
91    #[serde(deserialize_with = "vec_string_trim")]
92    pub areas: Vec<String>,
93}
94/// # Research Activity
95/// Identifiable package of work involving organized, systematic investigation
96#[skip_serializing_none]
97#[derive(Builder, Clone, Debug, Display, eserde::Deserialize, Serialize, JsonSchema, Validate)]
98#[builder(start_fn = init)]
99#[display("Research Activity ({title})")]
100#[serde(deny_unknown_fields)]
101pub struct ResearchActivity {
102    /// Linked data (e.g., JSON-LD) context for research activity
103    #[serde(rename = "@context")]
104    #[eserde(compat)]
105    pub context: Option<ResearchActivityContext>,
106    /// Linked data (e.g., JSON-LD) type for research activity
107    #[serde(rename = "@type")]
108    pub research_activity_type: Option<String>,
109    /// Associated metadata
110    #[validate(nested)]
111    #[builder(default)]
112    pub meta: ResearchActivityMetadata,
113    /// Technology ASPECT of associated research activity - describes the data, compute, and algorithms used in the associated research activity
114    #[eserde(compat)]
115    pub aspect: Option<AspectFramework>,
116    /// Heading that identifies and describes the associated research activity
117    #[validate(length(min = 4, max = "MAX_LENGTH_TITLE"))]
118    #[builder(default = "Research Activity Title".to_string())]
119    #[serde(deserialize_with = "string_trim")]
120    pub title: String,
121    /// Short description that augments the title of the associated research activity
122    #[validate(length(max = "MAX_LENGTH_SUBTITLE", message = "Subtitle is too long, reduce the length below 75 characters."))]
123    #[serde(default, deserialize_with = "option_string_trim")]
124    pub subtitle: Option<String>,
125    /// Prose components of associated research activity
126    #[validate(nested)]
127    #[builder(default)]
128    #[eserde(compat)]
129    pub sections: Sections,
130    /// Contact point (i.e. point of contact) for research activity
131    #[validate(nested)]
132    #[builder(default)]
133    #[eserde(compat)]
134    pub contact: ContactPoint,
135    /// Other information related to the associated research activity not easily captured in structured areas of the schema
136    #[eserde(compat)]
137    pub notes: Option<Other>,
138}
139/// Linked data (e.g., JSON-LD) context for research activity
140///
141/// See <https://www.w3.org/TR/json-ld11/#the-context> for more information
142#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
143#[builder(start_fn = init, on(String, into))]
144#[serde(deny_unknown_fields, rename_all = "camelCase")]
145pub struct ResearchActivityContext {
146    /// Associated metadata
147    pub meta: String,
148    /// Research activity title
149    pub title: String,
150    /// Research activity subtitle
151    pub subtitle: String,
152    /// Research activity sections of prose
153    pub sections: String,
154    /// Research activity contact point
155    pub contact: String,
156    /// Research activity notes
157    pub notes: String,
158}
159/// ## Research Activity Metadata
160#[skip_serializing_none]
161#[derive(Builder, Clone, Debug, Serialize, eserde::Deserialize, JsonSchema, Validate)]
162#[builder(start_fn = init)]
163#[serde(deny_unknown_fields, rename_all = "camelCase")]
164pub struct ResearchActivityMetadata {
165    /// Linked data (e.g., JSON-LD) context for contact point
166    #[serde(rename = "@context")]
167    #[eserde(compat)]
168    pub context: Option<ResearchActivityMetadataContext>,
169    /// Linked data (e.g., JSON-LD) type for contact point
170    #[serde(rename = "@type")]
171    pub metadata_type: Option<String>,
172    /// Classification level of associated research activity data
173    #[eserde(compat)]
174    pub classification: Option<ClassificationLevel>,
175    /// Describes the active status of the associated research activity data
176    ///
177    /// <div class="warning">Archived content typically will be omitted from public artifacts such as <a href="https://research.ornl.gov">the ORNL research activity index</a></div>
178    #[builder(default = false)]
179    pub archive: bool,
180    /// Describes the draft status of the associated research activity data
181    ///
182    /// <div class="warning">Draft content typically will be omitted from public artifacts such as <a href="https://research.ornl.gov">the ORNL research activity index</a></div>
183    #[builder(default = true)]
184    pub draft: bool,
185    /// Describes the status of the associated research activity data
186    #[builder(default = Status::Active)]
187    #[serde(default)]
188    #[eserde(compat)]
189    pub status: Status,
190    /// Identifier for associated research activity data
191    /// ### Example
192    /// > `my-research-project`
193    ///
194    /// <div class="warning">Should be <a href="https://developer.mozilla.org/en-US/docs/Glossary/Kebab_case">lower-kebab-case</a></div>
195    ///
196    #[validate(custom(function = "is_kebabcase"))]
197    #[builder(default = "some-research-project".to_string())]
198    #[serde(alias = "id", rename = "identifier", deserialize_with = "string_trim")]
199    pub identifier: String,
200    /// DOI or arXiv identifier(s) related to the associated research activity data
201    ///
202    /// See <https://www.doi.org/> for more information
203    #[validate(custom(function = "is_attribute_publication_identifier_list"))]
204    #[serde(default)]
205    pub doi: Option<Vec<String>>,
206    /// ISBN(s) of books related to the associated research activity data
207    #[validate(custom(function = "is_attribute_books"))]
208    #[serde(default)]
209    pub books: Option<Vec<String>>,
210    /// Patent(s) related to the associated research activity data
211    #[serde(default)]
212    pub patents: Option<Vec<String>>,
213    /// URL(s) of internet location where associated publication(s) can be found
214    #[validate(custom(function = "is_urls"))]
215    #[serde(default)]
216    pub publications: Option<Vec<String>>,
217    /// Research Activity Identifier (RAiD)
218    #[validate(custom(function = "is_attribute_doi_list"))]
219    #[serde(default)]
220    pub raid: Option<Vec<String>>,
221    /// Research Organization Registry
222    ///
223    /// See <https://www.ror.org/> for more information
224    #[validate(custom(function = "is_attribute_ror_list"))]
225    #[serde(default)]
226    pub ror: Option<Vec<String>>,
227    /// Type of associated research activity data when directly associated with an organization
228    #[eserde(compat)]
229    pub additional_type: Option<OrganizationType>,
230    /// Images, videos, and other media related to the associated research activity data
231    #[validate(nested)]
232    #[serde(alias = "graphics")]
233    #[eserde(compat)]
234    pub media: Option<Vec<MediaObject>>,
235    /// Websites related to the associated research activity data
236    #[validate(nested)]
237    #[eserde(compat)]
238    pub websites: Option<Vec<Website>>,
239    /// Keywords related to the associated research activity data
240    ///
241    /// See [Keyword]
242    #[builder(default = Vec::<String>::new())]
243    pub keywords: Vec<Keyword>,
244    /// Software, programmings languages, and digital resources (e.g., tools, libraries, frameworks, data) related to the associated research activity data
245    /// ### Examples
246    /// - Rust
247    /// - Polars
248    /// - gdal
249    /// - matplotlib
250    /// - LaTeX
251    ///
252    /// <div class="warning"><a href="https://code.ornl.gov/research-enablement/acorn/-/blob/main/crates/acorn-lib/assets/constants/technology.csv">Full list of technologies</a></div>
253    #[builder(default = Vec::<String>::new())]
254    #[serde(deserialize_with = "vec_string_trim")]
255    pub technology: Vec<String>,
256    /// Organization(s) responsible for funding associated research activity data
257    ///
258    /// Includes any office within a US cabinet-level department that has leadership appointed by the president and confirmed by the Senate, e.g., NNSA or Office of Science.
259    ///
260    /// <div class="warning"><a href="https://code.ornl.gov/research-enablement/acorn/-/blob/main/crates/acorn-lib/assets/constants/sponsors.csv">Full list of sponsors</a></div>
261    pub sponsors: Option<Vec<String>>,
262    /// Organization(s) related to the associated research activity data
263    /// ### Examples
264    /// - Los Alamos National Laboratory
265    /// - University of Tennessee
266    /// - IBM
267    /// <div class="warning"><a href="https://code.ornl.gov/research-enablement/acorn/-/blob/main/crates/acorn-lib/assets/constants/partners.csv">Full list of partners</a></div>
268    pub partners: Option<Vec<String>>,
269    /// Related resarch activity data identifiers of related research activity data
270    pub related: Option<Vec<String>>,
271    /// Hardware/compute resource requirements
272    /// ### Note
273    /// Selected components should reflect the minimum hardware/compute resources required to perform the research activity
274    ///
275    /// ### Example
276    /// For an AI/ML workflow that requires at least one GPU to perform training that cannot be executed on a CPU alone,
277    /// the resources attribute should include GPU, but need not include CPU
278    #[eserde(compat)]
279    pub resources: Option<Vec<Resource>>,
280}
281/// Linked data (e.g., JSON-LD) context for metadata
282///
283/// See <https://www.w3.org/TR/json-ld11/#the-context> for more information
284#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
285#[builder(start_fn = init, on(String, into))]
286#[serde(deny_unknown_fields, rename_all = "camelCase")]
287pub struct ResearchActivityMetadataContext {
288    /// Classification level
289    pub classification: String,
290    /// Archive status
291    pub archive: String,
292    /// Draft status
293    pub draft: String,
294    /// Research activity status
295    pub status: String,
296    /// Local CURIE research activity identifier
297    pub identifier: String,
298    /// Associated DOIs (also accepts arXiv identifiers)
299    pub doi: String,
300    /// Research Activity Identifier
301    pub raid: String,
302    /// Research Organization Registry
303    pub ror: String,
304    /// Additional type (for organizations)
305    pub additional_type: String,
306    /// Images, videos, and other media
307    pub media: String,
308    /// Websites
309    pub websites: String,
310    /// Keywords
311    pub keywords: String,
312    /// Software, programmings languages, and digital resources used by research activity
313    pub technology: String,
314    /// Sponsors
315    pub sponsors: String,
316    /// Partners
317    pub partners: String,
318    /// Related research activity data
319    pub related: String,
320    /// Hardware/compute resource requirements
321    pub resources: String,
322}
323/// Research activity prose components that describe the activity using natural language
324#[skip_serializing_none]
325#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
326#[builder(start_fn = init)]
327#[serde(deny_unknown_fields)]
328pub struct Sections {
329    /// The reason for the research or research organization to exist
330    /// ### Example
331    /// > "Develop the first atomic bombs in the world to assist the Allied forces and bring an end to WWII"
332    #[validate(length(
333        min = 10,
334        max = "MAX_LENGTH_SECTION_MISSION",
335        message = "Mission is too long, reduce the length below 250 characters."
336    ))]
337    #[builder(default = "Purpose of the research".to_string())]
338    #[serde(alias = "introduction", deserialize_with = "string_trim")]
339    pub mission: String,
340    /// A problem or situation within a research field requiring scientific effort, resources, and/or innovation to overcome
341    /// ### Example
342    /// > "During WWII, there was a fear that Germany was researching and developing nuclear weapons, giving them a decisive advantage over Allied forces, including the United States, Great Britain, and Canada."
343    #[validate(length(
344        min = 10,
345        max = "MAX_LENGTH_SECTION_CHALLENGE",
346        message = "Challenge is too long, reduce the length below 500 characters."
347    ))]
348    #[builder(default = "Reason for the research".to_string())]
349    #[serde(deserialize_with = "string_trim")]
350    pub challenge: String,
351    /// The plan, resources and actions taken to perform the research in a given project or organization
352    /// ### Examples
353    /// - "Production across four different sites in the United States, each with a different focus, for security and safety purposes"
354    /// - "Research into new fields including nuclear fission, isotope separation methods, uranium enrichment, plutonium development, and weapons design"
355    /// - "Military coordination for project construction and security management as well as defense communications to national leaders"
356    #[validate(
357        length(min = 1, max = "MAX_COUNT_APPROACH", message = "Limit the number of approaches to 6"),
358        custom(function = "is_attribute_approach")
359    )]
360    #[builder(default = vec!["List of actions taken to perform the research".to_string()])]
361    #[serde(deserialize_with = "vec_string_trim")]
362    pub approach: Vec<String>,
363    /// Tangible effects the research approach has on areas outside academia, such as industry, society, the surrounding environment, or culture
364    /// ### Examples
365    /// - "Development of the world's first atomic weapons"
366    /// - "Introduction of the nuclear age, including advancements in nuclear science, engineering and a new source of energy"
367    /// - "The end of WWII, along with many ethical and moral considerations related to use of atomic weapons"
368    #[validate(length(min = 1, max = "MAX_COUNT_IMPACT"), custom(function = "is_attribute_impact"))]
369    #[builder(default = vec!["List of tangible proof that validates the research approach".to_string()])]
370    #[serde(deserialize_with = "vec_string_trim")]
371    pub impact: Vec<String>,
372    /// Notable recognition or awards given to the research team, organization, or research products
373    /// ### Examples
374    /// - "At least six Nobel Prizes awarded to Manhattan Project researchers in the years following the end of the project"
375    /// - "Creation of the Atomic Energy Commission in 1946, later becoming the Department of Energy and Nuclear Regulatory Commission"
376    #[validate(length(min = 1, max = 4, message = "Limit the number of achievements to 4"))]
377    pub achievement: Option<Vec<String>>,
378    /// Expertise as applied to technology in a given mission space
379    /// ### Examples
380    /// - "Gaseous diffusion and electromagnetic separation to create fissionable materials"
381    /// - "Mechanisms for achieving supercritical mass for nuclear detonation"
382    /// - "Nuclear reactor development, which paved the way for nuclear power"
383    /// - "Radiochemistry for nuclear detonation analysis and advanced medical research with radioisotopes"
384    /// - "Large-scale multidisciplinary scientific collaboration"
385    #[validate(length(min = 1, max = "MAX_COUNT_CAPABILITIES"), custom(function = "is_attribute_capabilities"))]
386    pub capabilities: Option<Vec<String>>,
387    /// Overview of research focus and areas
388    /// ### Example Focus
389    /// > "Developing fissionable materials for nuclear reactions to develop the world's first atomic weapons"
390    /// ### Example Areas
391    /// - "Nuclear fission"
392    /// - "Radiochemistry"
393    /// - "Uranium enrichment"
394    /// - "Electromagnetic separation"
395    /// - "Weapon design"
396    #[validate(nested)]
397    #[builder(default = Research::init().build())]
398    pub research: Research,
399}
400impl Default for ResearchActivity {
401    fn default() -> Self {
402        ResearchActivity::init().build()
403    }
404}
405impl Default for ResearchActivityContext {
406    fn default() -> Self {
407        ResearchActivityContext::init()
408            .meta(schema_org("CreativeWork"))
409            .title(dcterms("title"))
410            .subtitle(schema_org("alternativeHeadline"))
411            .sections(schema_org("CreativeWork"))
412            .contact(dcat("contactPoint"))
413            .notes(schema_org("Text"))
414            .build()
415    }
416}
417impl Default for ResearchActivityMetadata {
418    fn default() -> Self {
419        ResearchActivityMetadata::init().build()
420    }
421}
422impl Default for ResearchActivityMetadataContext {
423    fn default() -> Self {
424        ResearchActivityMetadataContext::init()
425            .classification(schema_org("DefinedTerm"))
426            .archive(schema_org("Boolean"))
427            .draft(schema_org("Boolean"))
428            .status(schema_org("DefinedTerm"))
429            .identifier(codemeta("identifier"))
430            .doi(bibo("doi"))
431            .raid(DEFAULT_RAID_SCHEMA_URI)
432            .ror(DEFAULT_ROR_SCHEMA_URI)
433            .additional_type(schema_org("additionalType"))
434            .media(schema_org("MediaObject"))
435            .websites(schema_org("WebSite"))
436            .keywords(dcat("keyword"))
437            .technology(schema_org("DefinedTerm"))
438            .sponsors(codemeta("sponsor"))
439            .partners(schema_org("Text"))
440            .related(schema_org("Text"))
441            .resources(schema_org("DefinedTerm"))
442            .build()
443    }
444}
445impl Default for Sections {
446    fn default() -> Self {
447        Sections::init().build()
448    }
449}
450impl From<&ResearchActivity> for Cff {
451    fn from(rad: &ResearchActivity) -> Self {
452        let sections = rad.sections.clone();
453        let contact = rad.contact.clone();
454        let meta = rad.meta.clone();
455        let title = rad.title.clone();
456        let ContactPoint {
457            given_name,
458            family_name,
459            identifier: orcid,
460            email,
461            organization,
462            affiliation,
463            ..
464        } = contact;
465        let person_affiliation = affiliation.or(if organization.is_empty() { None } else { Some(organization) });
466        let person = Agent::Person(Person {
467            given_names: Some(given_name),
468            family_names: Some(family_name),
469            orcid,
470            email: Some(email),
471            affiliation: person_affiliation,
472            address: None,
473            alias: None,
474            city: None,
475            country: None,
476            fax: None,
477            name_particle: None,
478            name_suffix: None,
479            postal_code: None,
480            region: None,
481            tel: None,
482            website: None,
483        });
484        let keywords = if meta.keywords.is_empty() { None } else { Some(meta.keywords) };
485        let publication_identifiers = meta.doi.unwrap_or_default();
486        let doi = match publication_identifiers.as_slice() {
487            | [single] if matches!(PublicationIdentifierType::from(single.as_str()), PublicationIdentifierType::Doi(_)) => Some(single.clone()),
488            | _ => None,
489        };
490        let identifiers = doi.is_none().then(|| {
491            publication_identifiers
492                .into_iter()
493                .map(|value| match PublicationIdentifierType::from(value.as_str()) {
494                    | PublicationIdentifierType::Doi(_) => Identifier {
495                        description: None,
496                        kind: IdentifierType::Doi,
497                        value,
498                    },
499                    | _ => Identifier {
500                        description: Some("arXiv identifier".to_string()),
501                        kind: IdentifierType::Other,
502                        value,
503                    },
504                })
505                .collect::<Vec<_>>()
506        });
507        let identifiers = identifiers.filter(|values| !values.is_empty());
508        Cff {
509            abstract_text: Some(sections.mission),
510            authors: vec![person.clone()],
511            contact: Some(vec![person]),
512            keywords,
513            doi,
514            identifiers,
515            title,
516            ..Cff::default()
517        }
518    }
519}
520impl From<ResearchActivity> for Cff {
521    fn from(rad: ResearchActivity) -> Self {
522        Cff::from(&rad)
523    }
524}
525impl Hash for ResearchActivity {
526    fn hash<H: Hasher>(&self, state: &mut H) {
527        self.meta.identifier.hash(state);
528    }
529}
530#[cfg(feature = "std")]
531impl InputOutput for ResearchActivity {
532    fn read(path: impl Into<PathBuf>) -> ApiResult<Self> {
533        let source = path.into().clone();
534        let result = match MimeType::from_path(&source) {
535            | MimeType::Json => Self::read_json(source.clone()),
536            | MimeType::Jsonc => Self::read_jsonc(source.clone()),
537            | MimeType::Markdown => Self::read_markdown(source.clone()),
538            | MimeType::Yaml => Self::read_yaml(source.clone()),
539            | _ => Err(eyre!("Unsupported research activity data file extension")),
540        };
541        if let Ok(data) = &result {
542            debug!(path = source.to_string_lossy().to_string(), "=> {}", Label::using());
543            debug!("=> {} Research activity data = {:#?}", Label::using(), data.dimmed().cyan());
544        }
545        result
546    }
547    fn read_json(path: PathBuf) -> ApiResult<Self> {
548        read_file(path.clone()).and_then(|content| {
549            eserde::json::from_str::<Self>(&content).map_err(|errors| {
550                let details: Vec<String> = errors
551                    .iter()
552                    .map(|e| format!("{}: {}", e.path().map_or("root".into(), |p| p.to_string()), e.message()))
553                    .collect();
554                eyre!("{}", details.join("\n"))
555            })
556        })
557    }
558    fn read_jsonc(path: PathBuf) -> ApiResult<Self> {
559        read_file(path.clone()).and_then(|content| {
560            jsonc_parse_value(&content).and_then(|value| {
561                serde_json::to_string(&value)
562                    .map_err(|why| eyre!("JSONC conversion error — {why}"))
563                    .and_then(|json| {
564                        eserde::json::from_str::<Self>(&json).map_err(|errors| {
565                            let details: Vec<String> = errors
566                                .iter()
567                                .map(|e| format!("{}: {}", e.path().map_or("root".into(), |p| p.to_string()), e.message()))
568                                .collect();
569                            eyre!("{}", details.join("\n"))
570                        })
571                    })
572            })
573        })
574    }
575    fn read_markdown(path: PathBuf) -> ApiResult<Self> {
576        read_file(path).and_then(|content| Self::from_markdown(&content).map_err(|why| eyre!("{why}")))
577    }
578    fn read_yaml(path: PathBuf) -> ApiResult<Self> {
579        read_file(path.clone()).and_then(|content| serde_norway::from_str(&content).map_err(|why| eyre!("Failed to parse YAML RAD — {why}")))
580    }
581    fn write(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
582        let output = path.into();
583        let mime = MimeType::from_path(&output);
584        match mime {
585            | MimeType::Cff => self.write_cff(output),
586            | MimeType::Json | MimeType::Jsonc => self.write_json(output),
587            | MimeType::Markdown => self.write_markdown(output),
588            | MimeType::Yaml => self.write_yaml(output),
589            | _ => Err(eyre!("Unsupported research activity data file extension for writing")),
590        }
591    }
592    fn write_cff(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
593        let output = path.into().with_extension("cff");
594        let data: Cff = self.into();
595        serde_norway::to_string(&data)
596            .map_err(|why| eyre!("Failed to serialize RAD CITATION.cff — {why}"))
597            .and_then(|content| write_file(output, content))
598    }
599    fn write_json(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
600        let output = path.into();
601        serde_json::to_string_pretty(&self)
602            .map_err(|why| eyre!("Failed to serialize JSON RAD — {why}"))
603            .and_then(|content| write_file(output, content))
604    }
605    fn write_markdown(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
606        let output = path.into().with_extension("md");
607        let content = self.to_markdown();
608        write_file(output, content)
609    }
610    fn write_yaml(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
611        let output = path.into().with_extension("yaml");
612        serde_json::to_value(self)
613            .map_err(|why| eyre!("Failed to convert RAD to value for YAML serialization — {why}"))
614            .and_then(|value| serde_norway::to_string(&value).map_err(|why| eyre!("Failed to serialize YAML RAD — {why}")))
615            .and_then(|content| write_file(output, content))
616    }
617}
618impl LinkedData for ResearchActivity {
619    fn with_context(&self) -> Self {
620        Self {
621            context: Some(ResearchActivityContext::default()),
622            research_activity_type: None,
623            meta: self.meta.with_context(),
624            contact: self.contact.with_context(),
625            ..self.clone().copy()
626        }
627    }
628}
629impl LinkedData for ResearchActivityMetadata {
630    fn with_context(&self) -> Self {
631        Self {
632            context: Some(ResearchActivityMetadataContext::default()),
633            metadata_type: None,
634            ..self.clone()
635        }
636    }
637}
638impl MarkdownSupport for Research {
639    fn to_markdown(&self) -> String {
640        let Research { focus, areas } = self;
641        let focus = focus.to_markdown_text();
642        let areas = areas.iter().map(MarkdownSupport::to_markdown_text).collect::<Vec<_>>();
643        format!(
644            r#"
645## Focus
646{focus}
647
648## Areas{}"#,
649            areas.to_markdown(),
650        )
651    }
652}
653impl MarkdownSupport for ResearchActivity {
654    fn to_markdown(&self) -> String {
655        Document::from(self).to_markdown()
656    }
657    #[cfg(feature = "std")]
658    fn from_markdown(content: &str) -> Result<Self, String> {
659        Document::try_from(content).and_then(Self::try_from).map_err(|why| why.to_string())
660    }
661    #[cfg(not(feature = "std"))]
662    fn from_markdown(_content: &str) -> Result<Self, String> {
663        Err("Markdown parsing requires the std feature".to_string())
664    }
665}
666impl MarkdownSupport for Sections {
667    fn to_markdown(&self) -> String {
668        let Sections {
669            mission,
670            challenge,
671            approach,
672            impact,
673            achievement,
674            capabilities,
675            research,
676        } = self;
677        let mission = mission.to_markdown_text();
678        let challenge = challenge.to_markdown_text();
679        let approach = approach.iter().map(MarkdownSupport::to_markdown_text).collect::<Vec<_>>();
680        let impact = impact.iter().map(MarkdownSupport::to_markdown_text).collect::<Vec<_>>();
681        let achievement = achievement
682            .as_ref()
683            .map(|values| values.iter().map(MarkdownSupport::to_markdown_text).collect::<Vec<_>>())
684            .map(|values| format!("\n\n## Achievement{}", values.to_markdown()))
685            .unwrap_or_default();
686        let capabilities = capabilities
687            .as_ref()
688            .map(|values| values.iter().map(MarkdownSupport::to_markdown_text).collect::<Vec<_>>())
689            .map(|values| format!("\n\n## Capabilities{}", values.to_markdown()))
690            .unwrap_or_default();
691        format!(
692            r#"
693## Mission
694{}
695
696## Challenge
697{}
698
699## Approach{}
700
701## Impact{}{}{}
702{}"#,
703            mission,
704            challenge,
705            approach.to_markdown(),
706            impact.to_markdown(),
707            achievement,
708            capabilities,
709            research.to_markdown(),
710        )
711    }
712}
713impl ResearchActivity {
714    /// Creates a new `ResearchActivity`
715    pub fn new() -> Self {
716        ResearchActivity::default()
717    }
718    /// Serializes a research activity into a string for a supported mime type
719    #[cfg(feature = "std")]
720    pub fn serialize_as(&self, mime: &MimeType) -> ApiResult<String> {
721        match mime {
722            | MimeType::Json | MimeType::Jsonc => serde_json::to_string_pretty(self).map_err(|why| eyre!("Failed to serialize JSON RAD — {why}")),
723            | MimeType::Markdown => Ok(self.to_markdown()),
724            | MimeType::Yaml => serde_json::to_value(self)
725                .map_err(|why| eyre!("Failed to convert RAD to value for YAML serialization — {why}"))
726                .and_then(|value| serde_norway::to_string(&value).map_err(|why| eyre!("Failed to serialize YAML RAD — {why}"))),
727            | _ => Err(eyre!("Unsupported mime type for research activity serialization: {mime:?}")),
728        }
729    }
730    /// Return whether Markdown content has an ACORN research activity shape.
731    pub fn is_markdown<T>(source: T) -> bool
732    where
733        Text: TryFrom<T>,
734    {
735        Text::try_from(source).ok().is_some_and(|text| Document::recognizes(text.content()))
736    }
737    /// Print research activity schema as JSON or YAML schema
738    #[cfg(feature = "std")]
739    pub fn to_schema(format: &str) {
740        let schema = schema_for!(ResearchActivity);
741        let output = match format.to_lowercase().as_str() {
742            | "yaml" | "yml" => serde_norway::to_string(&schema).unwrap_or_default(),
743            | _ => serde_json::to_string_pretty(&schema).unwrap_or_default(),
744        };
745        println!("{output}");
746    }
747    /// Creates a copy of a `ResearchActivity`
748    pub fn copy(&self) -> ResearchActivity {
749        let ResearchActivity {
750            meta,
751            title,
752            subtitle,
753            sections,
754            contact,
755            notes,
756            ..
757        } = self.clone();
758        ResearchActivity::init()
759            .meta(meta)
760            .title(title)
761            .maybe_subtitle(subtitle)
762            .sections(sections)
763            .contact(contact)
764            .maybe_notes(notes)
765            .build()
766    }
767    /// Formats research activity data
768    /// ### Actions
769    /// - Resolves keywords, technology, organization, partners, sponsors, and affiliation using fuzzy matching against controlled vocabularies
770    /// - Formats contact telephone number
771    pub fn format(self) -> ResearchActivity {
772        let Self { meta, contact, .. } = self.clone();
773        Self {
774            meta: meta.format(),
775            contact: contact.format(),
776            ..self
777        }
778    }
779    /// Formats research activity data with context of filesystem and/or remote resources
780    /// ### Actions
781    /// - Resolves URL of first media object (if found) and add empty caption
782    /// - Resolves ORCiD identifier from contact first name, last name, and email (if found)
783    #[cfg(feature = "std")]
784    pub fn format_with(self, context: Option<PathBuf>) -> ResearchActivity {
785        let Self { meta, contact, .. } = self.clone();
786        Self {
787            meta: meta.format_with(context.clone()),
788            contact: contact.format_with(context.clone()),
789            ..self
790        }
791        .format()
792    }
793}
794impl ResearchActivityMetadata {
795    pub(crate) fn first_image(self) -> Option<MediaObject> {
796        match self.media {
797            | Some(values) => values.into_iter().filter(|x| x.clone().is_image()).collect::<Vec<_>>().first().cloned(),
798            | None => None,
799        }
800    }
801    /// Returns the content URL of the first image in the list of media objects, or a default value if none are present.
802    pub fn first_image_content_url(self) -> String {
803        match self.first_image() {
804            | Some(media) => match media {
805                | MediaObject::Image(ImageObject { content_url, .. }) => match content_url {
806                    | Some(value) if !value.is_empty() => value.clone().trim().to_string(),
807                    | Some(_) | None => DEFAULT_GRAPHIC_HREF.to_string(),
808                },
809                | _ => DEFAULT_GRAPHIC_HREF.to_string(),
810            },
811            | None => DEFAULT_GRAPHIC_HREF.to_string(),
812        }
813    }
814    /// Returns the caption of the first image in the list of media objects, or a default value if none are present.
815    pub fn first_image_caption(self) -> String {
816        match self.first_image() {
817            | Some(MediaObject::Image(ImageObject { caption, .. })) => match caption.clone() {
818                | value if !value.is_empty() => value.clone(),
819                | _ => DEFAULT_GRAPHIC_CAPTION.to_string(),
820            },
821            | Some(_) | None => DEFAULT_GRAPHIC_CAPTION.to_string(),
822        }
823    }
824    /// Fix, resolve, and augment research activity metadata
825    pub fn format(self) -> Self {
826        let keywords = Vocabulary::Keywords.resolve(Some(self.keywords.clone()));
827        let technology = Vocabulary::Technology.resolve(Some(self.technology.clone()));
828        let partners = match Vocabulary::Partners.resolve(self.partners.clone()) {
829            | values if !values.is_empty() => Some(values),
830            | _ => None,
831        };
832        let sponsors = match Vocabulary::Sponsors.resolve(self.sponsors.clone()) {
833            | values if !values.is_empty() => Some(values),
834            | _ => None,
835        };
836        Self {
837            keywords,
838            technology,
839            partners,
840            sponsors,
841            ..self
842        }
843    }
844    /// Fix, resolve, and augment research activity metadata with access to filesystem and/or remote resources
845    #[cfg(feature = "std")]
846    pub fn format_with(self, path: Option<PathBuf>) -> Self {
847        let path_parent = match path {
848            | Some(value) => parent(value),
849            | None => PathBuf::from("."),
850        };
851        debug!(path = path_parent.to_absolute_path(), "=> {} Parent directory", Label::using());
852        let name = match image_paths(&path_parent) {
853            | value if !value.is_empty() => value.first().and_then(|v| v.file_name().map(|f| f.to_string_lossy().to_string())),
854            | _ => None,
855        };
856        let media = match name {
857            | Some(value) => {
858                debug!(value, "=> {} First image", Label::using());
859                let first_graphic = match self.media.clone() {
860                    | Some(values) if !values.is_empty() => {
861                        let caption = self.clone().first_image_caption();
862                        let image_data = ImageObject::init().caption(caption.to_string()).content_url(value.clone()).build();
863                        MediaObject::Image(image_data)
864                    }
865                    | Some(_) | None => {
866                        let image_data = ImageObject::init().caption("".to_string()).content_url(value.clone()).build();
867                        MediaObject::Image(image_data)
868                    }
869                };
870                let rest = match self.clone().media {
871                    | Some(values) if !values.is_empty() => values.into_iter().skip(1).collect::<Vec<_>>(),
872                    | Some(_) | None => vec![],
873                };
874                Some([vec![first_graphic], rest].concat())
875            }
876            | None => self.media.clone(),
877        };
878        Self { media, ..self }.format()
879    }
880}
881impl ToProse for ResearchActivity {
882    fn to_prose(&self) -> String {
883        let websites = self
884            .meta
885            .websites
886            .clone()
887            .unwrap_or_default()
888            .into_iter()
889            .map(|website| website.to_markdown())
890            .collect::<Vec<String>>()
891            .join("\n");
892        let sections = self.sections.to_markdown();
893        match &self.subtitle {
894            | Some(subtitle) => format!(
895                r#"{title}
896{subtitle}
897{sections}
898{websites}"#,
899                title = self.title
900            ),
901            | None => format!(
902                r#"{title}
903{sections}
904{websites}"#,
905                title = self.title
906            ),
907        }
908    }
909}
910impl Vocabulary {
911    /// Resolve values to intended values according to associated controlled vocabulary
912    fn resolve(self, values: Option<Vec<String>>) -> Vec<String> {
913        ControlledVocabulary::normalize(&self.to_string(), values.unwrap_or_default()).into_values()
914    }
915}
916#[cfg(feature = "std")]
917pub(crate) fn resolve_from_csv_asset(name: String, value: String) -> Option<String> {
918    let data = Constant::csv(&name);
919    resolve_from_list_of_lists(value, data, name)
920}
921#[cfg(not(feature = "std"))]
922pub(crate) fn resolve_from_csv_asset(_name: String, value: String) -> Option<String> {
923    Some(value)
924}
925#[cfg(feature = "std")]
926pub(crate) fn resolve_from_list_of_lists<I: IntoIterator<Item = Vec<String>>>(value: String, data: I, name: String) -> Option<String> {
927    fn match_list<I: IntoIterator<Item = String> + Clone>(value: String, values: I) -> Vec<(String, u32)> {
928        let pattern = Pattern::parse(&value, CaseMatching::Ignore, Normalization::Smart);
929        let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
930        pattern.match_list(values.clone(), &mut matcher)
931    }
932    fn print_resolution(output: Option<String>, value: String, name: String) {
933        let label = name.to_case(Case::Title);
934        match output {
935            | Some(resolved) => {
936                if resolved.eq(&value.to_string()) {
937                    trace!("=> {} {} = \"{}\"", Label::using(), label, value.clone());
938                } else {
939                    debug!(input = value.clone(), resolved, "=> {} {}", Label::found(), label);
940                }
941            }
942            | None => {
943                debug!(value = value.clone(), "=> {} {}", Label::not_found(), label);
944            }
945        };
946    }
947    let output = data
948        .into_iter()
949        .flat_map(|values| {
950            let sanitized = value.normalize();
951            let matched = match_list(sanitized, values.clone().into_iter().take(4));
952            trace!("{} => {:?}", value.clone(), matched.clone());
953            if matched.clone().is_empty() {
954                None
955            } else {
956                match values.first() {
957                    | Some(x) => {
958                        if value.eq(x) {
959                            Some((x.into(), 10000))
960                        } else {
961                            let score = matched.into_iter().map(|(_, score)| score).max();
962                            match score {
963                                | Some(value) if value > 0 => Some((x.to_string(), value)),
964                                | Some(_) | None => None,
965                            }
966                        }
967                    }
968                    | None => None,
969                }
970            }
971        })
972        .max_by_key(|(_, score)| *score)
973        .map(|(x, _)| x.to_string());
974    print_resolution(output.clone(), value, name);
975    output
976}
977#[cfg(not(feature = "std"))]
978#[allow(dead_code)]
979pub(crate) fn resolve_from_list_of_lists<I: IntoIterator<Item = Vec<String>>>(value: String, _data: I, _name: String) -> Option<String> {
980    Some(value)
981}
982fn validation_error_with_index(code: &'static str, message: String, index: usize) -> ValidationError {
983    let mut err = ValidationError::new(code).with_message(message.into());
984    err.add_param("index".into(), &index);
985    err
986}
987fn validation_error_with_index_and_length(code: &'static str, message: String, index: usize, length: usize) -> ValidationError {
988    let mut err = validation_error_with_index(code, message, index);
989    err.add_param("length".into(), &length);
990    err
991}
992/// Custom validator function for [approach](Sections::approach)
993pub(crate) fn is_attribute_approach(value: &[String]) -> ValidationResult {
994    const CODE: &str = "sections.approach";
995    const MAX_LENGTH: usize = MAX_LENGTH_APPROACH;
996    value
997        .iter()
998        .enumerate()
999        .find(|(_, x)| x.len() > MAX_LENGTH)
1000        .map(|(index, x)| {
1001            let length = x.len();
1002            validation_error_with_index_and_length(
1003                CODE,
1004                format!("Each approach statement should be less than {MAX_LENGTH} characters"),
1005                index,
1006                length,
1007            )
1008        })
1009        .map_or(Ok(()), Err)
1010}
1011/// Custom validator function for [research areas](Research::areas)
1012pub(crate) fn is_attribute_areas(value: &[String]) -> ValidationResult {
1013    const CODE: &str = "sections.areas";
1014    const MAX_LENGTH: usize = MAX_LENGTH_RESEARCH_AREA;
1015    value
1016        .iter()
1017        .enumerate()
1018        .find(|(_, x)| x.len() > MAX_LENGTH)
1019        .map(|(index, x)| {
1020            let length = x.len();
1021            validation_error_with_index_and_length(CODE, format!("Each area should be less than {MAX_LENGTH} characters"), index, length)
1022        })
1023        .map_or(Ok(()), Err)
1024}
1025/// Custom validator function for [`ResearchActivity`] [books](ResearchActivityMetadata::books)
1026pub(crate) fn is_attribute_books(value: &[String]) -> ValidationResult {
1027    const CODE: &str = "meta.books";
1028    value
1029        .iter()
1030        .position(|x| !x.is_isbn())
1031        .map(|index| validation_error_with_index(CODE, "Every book should be a valid ISBN".to_string(), index))
1032        .map_or(Ok(()), Err)
1033}
1034/// Custom validator function for [`ResearchActivity`] [capabilities](Sections::capabilities)
1035pub(crate) fn is_attribute_capabilities(value: &[String]) -> ValidationResult {
1036    const CODE: &str = "sections.capabilities";
1037    const MAX_LENGTH: usize = MAX_LENGTH_CAPABILIY;
1038    value
1039        .iter()
1040        .enumerate()
1041        .find(|(_, x)| x.len() > MAX_LENGTH)
1042        .map(|(index, x)| {
1043            let length = x.len();
1044            validation_error_with_index_and_length(
1045                CODE,
1046                format!("Each capability should be less than {MAX_LENGTH} characters"),
1047                index,
1048                length,
1049            )
1050        })
1051        .map_or(Ok(()), Err)
1052}
1053/// Custom validator for [`ResearchActivity`] publication identifiers in [`ResearchActivityMetadata::doi`]
1054pub(crate) fn is_attribute_publication_identifier_list(value: &[String]) -> ValidationResult {
1055    const CODE: &str = "meta.doi";
1056    value
1057        .iter()
1058        .position(|identifier| matches!(PublicationIdentifierType::from(identifier.as_str()), PublicationIdentifierType::Unknown))
1059        .map(|index| {
1060            validation_error_with_index(
1061                CODE,
1062                "Every publication identifier should be a valid DOI or arXiv identifier".to_string(),
1063                index,
1064            )
1065        })
1066        .map_or(Ok(()), Err)
1067}
1068/// Custom validator function for DOI-only identifier lists
1069pub(crate) fn is_attribute_doi_list(value: &[String]) -> ValidationResult {
1070    const CODE: &str = "meta.raid";
1071    value
1072        .iter()
1073        .position(|identifier| is_doi(identifier).is_err())
1074        .map(|index| validation_error_with_index(CODE, "Every DOI should be valid".to_string(), index))
1075        .map_or(Ok(()), Err)
1076}
1077/// Custom validator function for [`ResearchActivity`] [ror](ResearchActivityMetadata::ror)
1078pub(crate) fn is_attribute_ror_list(value: &[String]) -> ValidationResult {
1079    const CODE: &str = "meta.ror";
1080    value
1081        .iter()
1082        .position(|x| is_ror(x).is_err())
1083        .map(|index| validation_error_with_index(CODE, "Every ROR should be valid".to_string(), index))
1084        .map_or(Ok(()), Err)
1085}
1086/// Custom validator function for [`ResearchActivity`] [impact](Sections::impact)
1087pub(crate) fn is_attribute_impact(value: &[String]) -> ValidationResult {
1088    const CODE: &str = "sections.impact";
1089    const MAX_LENGTH: usize = MAX_LENGTH_IMPACT;
1090    value
1091        .iter()
1092        .enumerate()
1093        .find(|(_, x)| x.len() > MAX_LENGTH)
1094        .map(|(index, x)| {
1095            let length = x.len();
1096            validation_error_with_index_and_length(
1097                CODE,
1098                format!("Each impact statement should be less than {MAX_LENGTH} characters"),
1099                index,
1100                length,
1101            )
1102        })
1103        .or_else(|| {
1104            value
1105                .first()
1106                .and_then(|first| {
1107                    let ends_with_period = first.trim().ends_with(".");
1108                    value.iter().position(|x| x.trim().ends_with(".") != ends_with_period)
1109                })
1110                .map(|index| {
1111                    validation_error_with_index(
1112                        CODE,
1113                        "Impact statements should be all sentences with periods or all phrases without periods".to_string(),
1114                        index,
1115                    )
1116                })
1117        })
1118        .or_else(|| {
1119            value
1120                .iter()
1121                .position(|x| {
1122                    x.trim().chars().find(|c| c.is_alphabetic()).is_none_or(|letter| {
1123                        let actual = letter.to_string();
1124                        actual != actual.to_case(Case::Upper)
1125                    })
1126                })
1127                .map(|index| validation_error_with_index(CODE, "Impact statements should begin with a capital letter".to_string(), index))
1128        })
1129        .map_or(Ok(()), Err)
1130}
1131
1132#[cfg(test)]
1133mod tests;