Skip to main content

acorn/schema/
mod.rs

1//! # ACORN schemas
2//!
3//! Here you'll find everything needed to build and use the research activity data schema, including metadata fields, section information, media objects, formats, and functions that power ACORN CLI commands.
4//!
5use crate::prelude::*;
6use alloc::collections::BTreeSet;
7pub mod hardware;
8pub use hardware::memory::{Memory, MemoryUnit};
9pub use hardware::{
10    AcceleratorArchitecture, Architecture, Backend, CpuArchitecture, DspArchitecture, FpgaArchitecture, GpuArchitecture, Model, Paradigm, Regime,
11    Resource, SensorModality, Topology, Vendor, Vendored,
12};
13
14#[cfg(feature = "std")]
15use crate::prelude::PathBuf;
16#[cfg(feature = "std")]
17use crate::schema::validate::format_phone_number;
18#[cfg(feature = "std")]
19use crate::util::constants::app::DEFAULT_AFFILIATION;
20use crate::util::constants::MAX_LENGTH_IMAGE_CAPTION;
21#[cfg(feature = "std")]
22use crate::util::Label;
23use crate::util::{Constant, LinkedData, MarkdownSupport, MimeType};
24use bon::Builder;
25#[cfg(feature = "std")]
26use convert_case::{Case, Casing};
27use core::hash::Hash;
28use core::iter::once;
29use core::num::NonZeroU64;
30use derive_more::Display;
31#[cfg(feature = "std")]
32use nucleo_matcher::{
33    pattern::{CaseMatching, Normalization, Pattern},
34    Config, Matcher,
35};
36#[cfg(feature = "std")]
37use percy_dom::prelude::{html, IterableNodes, View, VirtualNode};
38use petgraph::graph::Graph;
39use schemars::JsonSchema;
40use serde::de::DeserializeOwned;
41use serde::{Deserialize, Serialize};
42use serde_repr::{Deserialize_repr, Serialize_repr};
43use serde_trim::{option_string_trim, string_trim};
44use serde_with::skip_serializing_none;
45#[cfg(feature = "std")]
46use tracing::{debug, error, trace};
47use validator::Validate;
48
49#[cfg(feature = "std")]
50pub mod agent;
51pub mod discovery;
52pub mod geonames;
53pub mod graph;
54pub mod namespaces;
55pub mod pid;
56pub mod research_activity;
57pub mod standard;
58pub mod validate;
59
60use graph::{node_from_label, node_name, node_parent};
61use namespaces::{bibo, foaf, schema_org};
62use validate::{has_image_extension, is_date, is_orcid, is_phone_number};
63
64/// ## Keywords
65/// > Core concepts related to the associated research activity
66///
67/// Could be used to filter research activity data and/or power data analytics through concept composition
68///
69/// ### Guidelines for creating keywords
70/// - **Shall**
71///     - Be officially sanctioned by responsible parties
72///     - Be in lower-kebab-case
73///     - Be unique relative to other keywords
74///     - Contain three or more characters
75/// - **Should**
76///     - Not be too specific
77///     - Be one or two words (ex. `foo` or `foo-bar`)
78///
79/// <div class="warning"><a href="https://code.ornl.gov/research-enablement/acorn/-/blob/main/crates/acorn-lib/assets/constants/keywords.csv">Full list of keywords</a></div>
80pub type Keyword = String;
81/// Generic wrapper for a single value or multiple values.
82///
83/// Supports schema fields and metadata entry points that accept either one item
84/// or a batch of items.
85#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
86#[serde(untagged)]
87pub enum OneOrMany<T> {
88    /// A single item.
89    One(T),
90    /// Multiple items.
91    Many(Vec<T>),
92}
93/// U.S. Classified National Security Information Level
94///
95/// See [President Executive Order 13526](https://www.archives.gov/isoo/policy-documents/cnsi-eo.html)
96#[derive(Clone, Debug, Default, Display, Serialize, Deserialize, PartialEq, PartialOrd, JsonSchema)]
97#[serde(rename_all = "lowercase")]
98pub enum ClassificationLevel {
99    /// ### Unclassified (U)
100    #[default]
101    #[display("UNCLASSIFIED")]
102    Unclassified,
103    /// ### Confidential (C)
104    ///
105    /// Shall be applied to information, the unauthorized disclosure of which reasonably could be expected to cause ***damage*** to the national security that the original classification authority is able to identify or describe.
106    #[display("CONFIDENTIAL")]
107    Confidential,
108    /// ### Secret (S)
109    ///
110    /// Shall be applied to information, the unauthorized disclosure of which reasonably could be expected to cause ***serious damage*** to the national security that the original classification authority is able to identify or describe.
111    #[display("SECRET")]
112    Secret,
113    /// ### Top Secret (TS)
114    ///
115    /// Shall be applied to information, the unauthorized disclosure of which reasonably could be expected to cause ***exceptionally grave damage*** to the national security that the original classification authority is able to identify or describe.
116    #[display("TOP SECRET")]
117    #[serde(alias = "top secret")]
118    TopSecret,
119}
120/// # Media Object
121/// Digital artifact such as an image or video
122///
123/// See <https://schema.org/MediaObject>
124#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
125#[serde(untagged)]
126pub enum MediaObject {
127    /// Image format media
128    Image(ImageObject),
129    /// Video format media
130    Video(VideoObject),
131}
132/// Organization sub type
133#[derive(Clone, Debug, Serialize, Deserialize, Display, Hash, PartialEq, PartialOrd, JsonSchema)]
134#[serde(rename_all = "lowercase")]
135pub enum OrganizationType {
136    /// Agency
137    #[display("agency")]
138    Agency,
139    /// Initiative that involves multiple DOE laboratories partnering together for a shared purpose and leverage "traditional" management
140    ///
141    /// Generally, centers may be more focused on a specific problem (more than an institute, for example)
142    #[display("center")]
143    Center,
144    /// Laboratory, public, and private partners
145    #[display("consortium")]
146    Consortium,
147    /// Top-level organizational unit that contains one or more divisions
148    #[display("directorate")]
149    Directorate,
150    /// Mid-level organizational unit that contains one or more sections and groups
151    #[display("division")]
152    Division,
153    /// Building, room, array of equipment, or a number of such things, designed to serve a particular function
154    ///
155    /// Includes DOE-designated user facilities
156    #[display("facility")]
157    Facility,
158    /// Federally Funded Research and Development Center
159    #[display("FFRDC")]
160    Ffrdc,
161    /// Low-level organizational unit that contains a small number of people that function as a team
162    #[display("group")]
163    Group,
164    /// Institutes tend to be virtual organizations, and are managed largely by goodwill between organization leaders
165    #[display("institute")]
166    Institute,
167    /// Office
168    #[display("office")]
169    Office,
170    /// Program
171    #[display("program")]
172    Program,
173}
174/// Content not easily placed into the schema
175#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
176#[serde(untagged)]
177pub enum Other {
178    /// Free-form test
179    Unformatted(String),
180    /// Structured container for miscellaneaous things
181    Formatted(Notes),
182}
183/// Provides a small subset of common programming languages available for syntax highlighting and contextual actions
184#[derive(Clone, Copy, Debug, Deserialize, Display, Serialize, JsonSchema)]
185#[serde(rename_all = "lowercase")]
186pub enum ProgrammingLanguage {
187    /// HyperText Markup Language (HTML)
188    #[display("html")]
189    Html,
190    /// JavaScript (JS) / ECMAScript (ES)
191    ///
192    /// See [MDN JavaScript docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript) for more information
193    #[display("javascript")]
194    JavaScript,
195    /// Julia
196    ///
197    /// See <https://julialang.org/> for more information
198    #[display("julia")]
199    Julia,
200    /// Markdown
201    ///
202    /// See <https://www.markdownguide.org/> for more information
203    #[display("markdown")]
204    Markdown,
205    /// JavaScript Object Notation (JSON)
206    ///
207    /// See <https://www.json.org/json-en.html> for more information
208    #[display("json")]
209    Json,
210    /// Rust
211    ///
212    /// See <https://rust-lang.org/> for more information
213    #[display("rust")]
214    Rust,
215    /// Shell
216    ///
217    /// Catch-all for shell scripts (e.g., Bash, Zsh, etc.)
218    #[display("shell")]
219    #[serde(alias = "bash", alias = "zsh", alias = "fish", alias = "powershell")]
220    Shell,
221    /// YAM Ain't Markup Language (YAML)
222    ///
223    /// See <https://yaml.org/> for more information
224    #[display("yaml")]
225    Yaml,
226}
227/// Status of research activity data
228/// ### Note
229/// > Status is saved as a numeric value and designed to be comparable by priority (i.e., Active > On Hold > Completed > Canceled)
230///
231/// See <https://schema.org/Status>
232#[derive(Clone, Debug, Default, Deserialize, Display, PartialEq, PartialOrd, Serialize, JsonSchema)]
233#[serde(deny_unknown_fields)]
234#[serde(rename_all = "kebab-case")]
235pub enum Status {
236    /// Activity has been cancelled with not plans to resume
237    #[display("canceled")]
238    #[serde(alias = "cancelled")]
239    Canceled,
240    /// Activity has completed (successfully)
241    #[display("completed")]
242    Completed,
243    /// Activity is postponed with plans to resume
244    #[display("paused")]
245    #[serde(alias = "on-hold", alias = "postponed", alias = "rescheduled")]
246    Paused,
247    /// Activity is in progress
248    #[default]
249    #[display("active")]
250    Active,
251}
252/// TRLs are a method for estimating the maturity of technologies during the acquisition phase of a program.
253///
254/// The "optimal point" to introduce technology depends on technology maturity (TRL) and program requirements. That point can be virtually anywhere in the acquisition process.
255///
256/// See [Technology Readiness for Machine Learning Systems](https://doi.org/10.1038/s41467-022-33128-9) for applying TRLs to machine learning (ML) systems
257#[derive(Clone, Debug, Default, Deserialize_repr, Display, Serialize_repr, PartialEq, PartialOrd, JsonSchema)]
258#[repr(u8)]
259#[serde(deny_unknown_fields)]
260pub enum TechnologyReadinessLevel {
261    #[default]
262    /// A stage for greenfield research
263    ///
264    /// Not a standard TRL
265    #[display("Greenfield Research")]
266    Principles = 0,
267    /// Basic principles observed and reported
268    ///
269    /// ML: Goal-oriented research
270    #[display("Basic Research")]
271    Research = 1,
272    /// Technology concept and/or application formulated
273    ///
274    /// ML: Proof of principle development
275    #[display("Technology Concept")]
276    Concept = 2,
277    /// Analytical and experimental critical function and/or characteristic proof-of-concept
278    ///
279    /// ML: Systems development
280    #[display("Feasible")]
281    Feasible = 3,
282    /// Component and/or breadboard validation in laboratory environment (low fidelity)
283    ///
284    /// ML: Proof of concept development
285    #[display("Developing")]
286    Developing = 4,
287    /// Component and/or breadboard validation in relevant environment (high fidelity)
288    ///
289    /// ML: Machine learning "capability"
290    #[display("Developed")]
291    Developed = 5,
292    /// System/subsystem model or prototype demonstration in a relevant environment (high fidelity)
293    ///
294    /// ML: Application development
295    #[display("Prototype")]
296    Prototype = 6,
297    /// System prototype demonstration in an operational environment
298    ///
299    /// ML: Integrations
300    #[display("Operational")]
301    Operational = 7,
302    /// Actual system completed and qualified through test and demonstration
303    ///
304    /// ML: Mission-ready
305    #[display("Mission Ready")]
306    MissionReady = 8,
307    /// Actual system proven through successful mission operation
308    ///
309    /// ML: Deployment
310    #[display("Mission Capable")]
311    MissionCapable = 9,
312}
313/// Contact point (i.e. "point of contact") for research activity
314#[skip_serializing_none]
315#[derive(Builder, Clone, Debug, Serialize, Deserialize, Validate, JsonSchema)]
316#[builder(start_fn = init)]
317#[serde(deny_unknown_fields, rename_all = "camelCase")]
318pub struct ContactPoint {
319    /// Linked data (e.g., JSON-LD) context for contact point
320    #[serde(rename = "@context")]
321    pub context: Option<ContactPointContext>,
322    /// Linked data (e.g., JSON-LD) type for contact point
323    #[serde(rename = "@type")]
324    pub contact_point_type: Option<String>,
325    /// Job title (e.g., "Group Lead") of role that the contact fills related to the asscociated research activity.
326    /// ### Example
327    /// > Ideal contact title for a project would be "Primary Investigator"
328    ///
329    /// ### Example
330    /// > Ideal contact title for a group organization would be "Group Lead"
331    ///
332    /// <div class="warning">When the nearest associated title is unclear, job role of the contact can be used (e.g., "Senior Scientist").</div>
333    ///
334    /// See <https://schema.org/jobTitle> for more information
335    #[builder(default = "Researcher".to_string())]
336    #[serde(alias = "title", deserialize_with = "string_trim")]
337    pub job_title: String,
338    /// First (given) name of contact point
339    ///
340    /// See <https://schema.org/givenName> for more information
341    #[builder(default = "First".to_string())]
342    #[serde(alias = "first", deserialize_with = "string_trim")]
343    pub given_name: String,
344    /// Last (family) name of contact point
345    ///
346    /// See <https://schema.org/familyName> for more information
347    #[builder(default = "Last".to_string())]
348    #[serde(alias = "last", deserialize_with = "string_trim")]
349    pub family_name: String,
350    /// ORCiD of contact point
351    /// ### Example
352    /// > "<https://orcid.org/0000-0002-2057-9115>"
353    #[validate(custom(function = "is_orcid"))]
354    #[serde(alias = "orcid")]
355    pub identifier: Option<String>,
356    /// Email address of contact point
357    ///
358    /// See <https://schema.org/email> for more information
359    #[validate(email(message = "Email address must be in the format name@example.com"))]
360    #[builder(default = "first_last@example.com".to_string())]
361    #[serde(deserialize_with = "string_trim")]
362    pub email: String,
363    /// Phone number of contact point
364    ///
365    /// See <https://schema.org/telephone> for more information
366    #[validate(custom(function = "is_phone_number"))]
367    #[builder(default = "123-456-7890".to_string())]
368    #[serde(alias = "phone", deserialize_with = "string_trim")]
369    pub telephone: String,
370    /// Profile URL of contact point
371    /// ### Example
372    /// > Profile URL for "Jason Wohlgemuth" could be <https://impact.ornl.gov/en/persons/jason-wohlgemuth>
373    #[validate(url(message = "Profile URL must be in the format https://example.com"))]
374    #[builder(default = "https://example.com".to_string())]
375    #[serde(deserialize_with = "string_trim")]
376    pub url: String,
377    /// Organization of contact point
378    ///
379    /// See [Organization]
380    #[builder(default = "Some Organization".to_string())]
381    #[serde(deserialize_with = "string_trim")]
382    pub organization: String,
383    /// Affiliation of associated research activity data
384    ///
385    /// <div class="warning">Where organization applies to the contact point, affiliation applies to the research activity the contact point is associated with</div>
386    ///
387    /// See <https://schema.org/affiliation> for more information
388    pub affiliation: Option<String>,
389}
390/// Linked data (e.g., JSON-LD) context for contact point
391///
392/// See <https://www.w3.org/TR/json-ld11/#the-context> for more information
393#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
394#[builder(start_fn = init, on(String, into))]
395#[serde(deny_unknown_fields, rename_all = "camelCase")]
396pub struct ContactPointContext {
397    /// Job title
398    pub job_title: String,
399    /// First (given) name
400    pub given_name: String,
401    /// Last (family) name
402    pub family_name: String,
403    /// ORCiD
404    pub identifier: String,
405    /// Email address
406    pub email: String,
407    /// Phone number
408    pub telephone: String,
409    /// Profile URL
410    pub url: String,
411    /// Organization
412    pub organization: String,
413    /// Affiliation
414    pub affiliation: String,
415}
416/// Canonical values resolved against one of ACORN's controlled-vocabulary assets.
417#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
418#[serde(transparent)]
419pub struct ControlledVocabulary(Vec<Keyword>);
420/// Shared start/end date interval used across schema standards.
421#[skip_serializing_none]
422#[derive(Builder, Clone, Debug, Deserialize, JsonSchema, Serialize, Validate)]
423#[builder(start_fn = init, on(String, into))]
424#[serde(rename_all = "camelCase")]
425pub struct Date {
426    /// Start date as ISO 8601 date string (`YYYY-MM-DD`).
427    #[validate(custom(function = "is_date"))]
428    pub start_date: Option<String>,
429    /// End date as ISO 8601 date string (`YYYY-MM-DD`).
430    #[validate(custom(function = "is_date"))]
431    pub end_date: Option<String>,
432}
433/// Image format media (e.g., PNG, JPEG, SVG, etc.)
434///
435/// See <https://schema.org/ImageObject>
436#[skip_serializing_none]
437#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
438#[builder(start_fn = init)]
439#[serde(deny_unknown_fields, rename_all = "camelCase")]
440pub struct ImageObject {
441    /// Image caption
442    #[validate(length(
443        max = "MAX_LENGTH_IMAGE_CAPTION",
444        message = "Caption is too long, reduce the length below 100 characters."
445    ))]
446    #[serde(deserialize_with = "string_trim")]
447    pub caption: String,
448    /// File size (in kilobytes)
449    ///
450    /// <div class="warning">Will be overwritten by running <pre>acorn format</pre></div>
451    ///
452    /// See <https://schema.org/contentSize> for more information
453    #[serde(alias = "size")]
454    pub content_size: Option<NonZeroU64>,
455    /// Content URL
456    #[validate(custom(function = "has_image_extension"))]
457    pub content_url: Option<String>,
458    /// Image height (in pixels)
459    ///
460    /// <div class="warning">Will be overwritten by running <pre>acorn format</pre></div>
461    ///
462    /// See <https://schema.org/height> for more information
463    pub height: Option<NonZeroU64>,
464    /// Image width (in pixels)
465    ///
466    /// <div class="warning">Will be overwritten by running <pre>acorn format</pre></div>
467    ///
468    /// See <https://schema.org/width> for more information
469    pub width: Option<NonZeroU64>,
470}
471/// Notes
472///
473/// Structured container for information not easily captured in other fields
474#[skip_serializing_none]
475#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, Validate)]
476#[serde(deny_unknown_fields)]
477pub struct Notes {
478    /// [ASCR](https://www.energy.gov/science/ascr/advanced-scientific-computing-research) highlight attribute
479    pub managers: Option<Vec<String>>,
480    /// Collection of capabilities aimed at achieving a specific cross-cutting research outcome
481    pub programs: Option<Vec<String>>,
482    /// (PowerPoint) presentation notes
483    #[serde(default, deserialize_with = "option_string_trim")]
484    pub presentation: Option<String>,
485}
486/// Structured container for information about an organization
487///
488/// See also [OrganizationType]
489#[skip_serializing_none]
490#[derive(Clone, Debug, Serialize, Deserialize, Display, Hash, PartialEq, PartialOrd)]
491#[display("Organization ({additional_type}) - {name})")]
492#[serde(deny_unknown_fields, rename_all = "camelCase")]
493pub struct Organization {
494    /// Full name of the organization
495    ///
496    /// See <https://schema.org/name> for more information
497    #[serde(deserialize_with = "string_trim")]
498    pub name: String,
499    /// Research Organization Registry
500    ///
501    /// See <https://www.ror.org/> for more information
502    #[serde(default, deserialize_with = "option_string_trim")]
503    pub ror: Option<String>,
504    /// Organization alias (e.g., acronym or nickname)
505    ///
506    /// See <https://schema.org/alternateName> for more information
507    #[serde(default, deserialize_with = "option_string_trim")]
508    pub alternative_name: Option<String>,
509    /// Organization sub-type
510    ///
511    /// See <https://schema.org/additionalType> for more information
512    pub additional_type: OrganizationType,
513    /// See [Keyword]
514    pub keywords: Option<Vec<Keyword>>,
515    /// Distinct part(s) of the associated containing organization
516    ///
517    /// See <https://schema.org/member> for more information
518    pub member: Vec<Organization>,
519}
520/// Video format media (e.g., MP4, AVI, MOV, GIF, etc.)
521///
522/// See <https://schema.org/VideoObject> for more information
523#[skip_serializing_none]
524#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
525#[serde(deny_unknown_fields, rename_all = "camelCase")]
526pub struct VideoObject {
527    /// File size (in kilobytes)
528    ///
529    /// See <https://schema.org/contentSize> for more information
530    #[serde(alias = "size")]
531    pub content_size: Option<NonZeroU64>,
532    /// Video URL
533    #[validate(url)]
534    pub content_url: Option<String>,
535    /// Video description
536    ///
537    /// See <https://schema.org/description> for more information
538    #[serde(deserialize_with = "string_trim")]
539    pub description: String,
540    /// Duration of video in [ISO 8601 format](https://en.wikipedia.org/wiki/ISO_8601)
541    ///
542    /// See <https://schema.org/duration> for more information
543    pub duration: Option<String>,
544    /// Video height (in pixels)
545    ///
546    /// See <https://schema.org/height> for more information
547    pub height: Option<NonZeroU64>,
548    /// Video width (in pixels)
549    ///
550    /// See <https://schema.org/width> for more information
551    pub width: Option<NonZeroU64>,
552}
553/// Website link and title description
554/// ### Example
555/// When deserializing research activity data, websites can be provided as a list of JSON objects.
556/// ```json
557/// {
558///     "websites": [
559///       {
560///         "title": "Home Page",
561///         "url": "https://example.com"
562///       },
563///       {
564///         "title": "Job Listing",
565///         "url": "https://www.example.com/jobs"
566///       }
567///     ]
568/// }
569/// ```
570///
571#[derive(Clone, Debug, Serialize, Deserialize, Validate, JsonSchema)]
572#[serde(deny_unknown_fields)]
573pub struct Website {
574    /// Brief description of webpage content
575    ///
576    /// See <https://schema.org/description> for more information
577    #[serde(alias = "title", deserialize_with = "string_trim")]
578    pub description: String,
579    /// Associated website URL
580    #[validate(url(message = "Provide valid URL"))]
581    #[serde(deserialize_with = "string_trim")]
582    pub url: String,
583}
584impl ContactPoint {
585    /// Fix, resolve, and augment contact point data
586    #[cfg(feature = "std")]
587    pub fn format(self) -> Self {
588        let ContactPoint {
589            affiliation,
590            context,
591            contact_point_type,
592            email,
593            family_name,
594            given_name,
595            identifier,
596            job_title,
597            organization,
598            telephone,
599            url,
600            ..
601        } = self.clone();
602        let updated_organization = match resolve_from_organization_json(organization) {
603            | Some(value) => value,
604            | None => "".to_string(),
605        };
606        let updated_affiliation = match affiliation {
607            | Some(ref affiliation) => match resolve_from_organization_json(affiliation.to_string()) {
608                | Some(resolved) => Some(resolved),
609                | None => {
610                    error!(affiliation, "=> {} Affiliation", Label::not_found());
611                    Some(DEFAULT_AFFILIATION.to_string())
612                }
613            },
614            | None => match Organization::load().into_iter().next() {
615                | Some(ornl) => match ornl.member(&updated_organization) {
616                    | Some(organization) => match organization.nearest(OrganizationType::Directorate) {
617                        | Some(Organization { name, .. }) => Some(name),
618                        | None => Some(DEFAULT_AFFILIATION.to_string()),
619                    },
620                    | None => {
621                        error!("=> {} Nearest directorate", Label::not_found());
622                        Some(DEFAULT_AFFILIATION.to_string())
623                    }
624                },
625                | None => {
626                    error!("=> {} Organization hierarchy", Label::not_found());
627                    Some(DEFAULT_AFFILIATION.to_string())
628                }
629            },
630        };
631        let updated_telephone = match format_phone_number(&telephone) {
632            | Ok(value) => value,
633            | Err(_) => {
634                error!(value = telephone, "=> {} Phone number", Label::invalid());
635                telephone.to_string()
636            }
637        };
638        Self::init()
639            .maybe_affiliation(updated_affiliation)
640            .maybe_context(context)
641            .maybe_contact_point_type(contact_point_type)
642            .email(email)
643            .family_name(family_name)
644            .given_name(given_name)
645            .maybe_identifier(identifier)
646            .job_title(job_title)
647            .organization(updated_organization)
648            .telephone(updated_telephone)
649            .url(url)
650            .build()
651    }
652    #[cfg(not(feature = "std"))]
653    /// Fix, resolve, and augment contact point data
654    pub fn format(self) -> Self {
655        self
656    }
657    /// Fix, resolve, and augment research activity metadata with access to filesystem and/or remote resources
658    #[cfg(feature = "std")]
659    pub fn format_with(self, _context: Option<PathBuf>) -> Self {
660        // TODO: Resolve ORCiD identifier from first, last, and email
661        self.format()
662    }
663}
664impl ControlledVocabulary {
665    /// Normalize candidate values against the named controlled-vocabulary asset.
666    pub fn normalize<T: Into<String>>(name: &str, values: impl IntoIterator<Item = T>) -> Self {
667        Self(
668            values
669                .into_iter()
670                .filter_map(|value| research_activity::resolve_from_csv_asset(name.to_string(), value.into()))
671                .collect::<BTreeSet<_>>()
672                .into_iter()
673                .collect(),
674        )
675    }
676    /// Consume the vocabulary and return its canonical values.
677    pub fn into_values(self) -> Vec<Keyword> {
678        self.0
679    }
680}
681impl Default for ContactPoint {
682    fn default() -> Self {
683        Self::init().build()
684    }
685}
686impl Default for ContactPointContext {
687    fn default() -> Self {
688        Self::init()
689            .job_title(schema_org("jobTitle"))
690            .given_name(foaf("givenName"))
691            .family_name(foaf("familyName"))
692            .identifier(bibo("identifier"))
693            .email(foaf("mbox"))
694            .telephone(schema_org("telephone"))
695            .url(foaf("workInfoHomepage"))
696            .organization(schema_org("worksFor"))
697            .affiliation(schema_org("affiliation"))
698            .build()
699    }
700}
701impl LinkedData for ContactPoint {
702    fn with_context(&self) -> Self {
703        let mut clone = self.clone();
704        clone.context = Some(ContactPointContext::default());
705        clone.contact_point_type = Some(schema_org("person"));
706        clone
707    }
708}
709impl MediaObject {
710    /// Returns the content URL of the media object
711    pub fn content_url(self) -> Option<String> {
712        match self {
713            | MediaObject::Image(ImageObject { content_url, .. }) | MediaObject::Video(VideoObject { content_url, .. }) => content_url,
714        }
715    }
716    /// Returns the description of the media object
717    pub fn description(self) -> String {
718        match self {
719            | MediaObject::Image(ImageObject { caption, .. }) => caption,
720            | MediaObject::Video(VideoObject { description, .. }) => description,
721        }
722    }
723    /// Returns true if the media object is an image, false otherwise
724    pub fn is_image(self) -> bool {
725        match self {
726            | MediaObject::Image(_) => true,
727            | _ => false,
728        }
729    }
730}
731impl<T> OneOrMany<T> {
732    /// Borrow the contained items as a slice.
733    pub fn as_slice(&self) -> &[T] {
734        match self {
735            | Self::One(value) => core::slice::from_ref(value),
736            | Self::Many(values) => values.as_slice(),
737        }
738    }
739    /// Borrow the first contained item, if one exists.
740    pub fn first(&self) -> Option<&T> {
741        self.as_slice().first()
742    }
743    /// Return true when there are no contained items.
744    pub fn is_empty(&self) -> bool {
745        self.as_slice().is_empty()
746    }
747    /// Convert to a vector of items.
748    pub fn into_vec(self) -> Vec<T> {
749        match self {
750            | Self::One(value) => vec![value],
751            | Self::Many(values) => values,
752        }
753    }
754    /// Iterate over contained items by reference.
755    pub fn iter(&self) -> core::slice::Iter<'_, T> {
756        self.as_slice().iter()
757    }
758    /// Return the number of contained items.
759    pub fn len(&self) -> usize {
760        self.as_slice().len()
761    }
762    /// Transform each item, propagating crosswalk failures.
763    pub fn map<U, F>(self, f: F) -> Result<OneOrMany<U>, standard::crosswalk::CrosswalkError>
764    where
765        F: Fn(T) -> Result<U, standard::crosswalk::CrosswalkError>,
766    {
767        match self {
768            | Self::One(value) => f(value).map(OneOrMany::One),
769            | Self::Many(values) => values
770                .into_iter()
771                .enumerate()
772                .map(|(index, value)| {
773                    f(value).map_err(|e| standard::crosswalk::CrosswalkError::BuildFailed(format!("Failed to convert record at index {index} — {e}")))
774                })
775                .collect::<Result<Vec<_>, _>>()
776                .map(OneOrMany::Many),
777        }
778    }
779    /// Parse a JSON or YAML string into one or many items.
780    pub fn parse(content: &str, mime: MimeType) -> Result<OneOrMany<T>, standard::crosswalk::CrosswalkError>
781    where
782        T: DeserializeOwned,
783    {
784        match mime {
785            | MimeType::Json => serde_json::from_str(content).map_err(|e| standard::crosswalk::CrosswalkError::ParseFailed(e.to_string())),
786            | MimeType::Yaml => serde_norway::from_str(content).map_err(|e| standard::crosswalk::CrosswalkError::ParseFailed(e.to_string())),
787            | _ => Err(standard::crosswalk::CrosswalkError::ParseFailed("Content must be JSON or YAML".into())),
788        }
789    }
790    /// Serialize one or many items to a JSON or YAML string.
791    pub fn serialize(&self, mime: MimeType) -> Result<String, standard::crosswalk::CrosswalkError>
792    where
793        T: Serialize,
794    {
795        match mime {
796            | MimeType::Json => serde_json::to_string_pretty(self).map_err(|e| standard::crosswalk::CrosswalkError::SerializeFailed(e.to_string())),
797            | MimeType::Yaml => serde_norway::to_string(self).map_err(|e| standard::crosswalk::CrosswalkError::SerializeFailed(e.to_string())),
798            | _ => Err(standard::crosswalk::CrosswalkError::SerializeFailed("Output must be JSON or YAML".into())),
799        }
800    }
801}
802impl<'a, T> IntoIterator for &'a OneOrMany<T> {
803    type Item = &'a T;
804    type IntoIter = core::slice::Iter<'a, T>;
805
806    fn into_iter(self) -> Self::IntoIter {
807        self.iter()
808    }
809}
810impl<T> Validate for OneOrMany<T>
811where
812    T: Validate,
813{
814    fn validate(&self) -> Result<(), validator::ValidationErrors> {
815        self.iter().find_map(|value| value.validate().err()).map_or(Ok(()), Err)
816    }
817}
818impl Organization {
819    /// Return list of all alternative names
820    pub fn alternative_names() -> Vec<String> {
821        match Organization::load().into_iter().next() {
822            | Some(organization) => organization
823                .members()
824                .into_iter()
825                .flat_map(|Organization { alternative_name, .. }| alternative_name)
826                .collect::<Vec<String>>(),
827            | None => vec![],
828        }
829    }
830    /// Returns a list of all organizations, loaded from the organization.json asset file
831    pub fn load() -> Vec<Organization> {
832        Constant::from_asset("organization.json")
833            .and_then(|content| serde_json::from_str(&content).ok())
834            .unwrap_or_default()
835    }
836    /// Finds the first organization in the hierarchy with the given label.
837    pub fn member(self, label: &str) -> Option<Organization> {
838        self.members().into_iter().find(|Organization { name, .. }| name == label)
839    }
840    /// Returns a flattened vector of the organization hierarchy.
841    ///
842    /// This function collects the organization, its directorates, divisions, and groups
843    /// into a single vector, maintaining their hierarchical order.
844    pub fn members(self) -> Vec<Organization> {
845        let organization = self.clone();
846        once(organization.clone())
847            .chain(organization.member.iter().flat_map(|directorate| {
848                once(directorate.clone()).chain(
849                    directorate
850                        .member
851                        .iter()
852                        .flat_map(|division| once(division.clone()).chain(division.member.iter().cloned())),
853                )
854            }))
855            .collect()
856    }
857    /// Returns the nearest organization of the given type in the organization hierarchy.
858    pub fn nearest(self, organization_type: OrganizationType) -> Option<Organization> {
859        let a = self.clone().additional_type.order();
860        let b = organization_type.order();
861        if a > b {
862            None
863        } else {
864            let ornl = Organization::load().into_iter().next()?;
865            let graph = ornl.clone().to_graph();
866            let name = match b.saturating_sub(a) {
867                | 3 => Some(ornl.clone().name),
868                | 2 => node_from_label(&graph, &self.name)
869                    .and_then(|node| node_parent(&graph, node))
870                    .and_then(|parent| node_parent(&graph, parent))
871                    .and_then(|grandparent| node_name(&graph, grandparent)),
872                | 1 => node_from_label(&graph, &self.name)
873                    .and_then(|node| node_parent(&graph, node))
874                    .and_then(|parent| node_name(&graph, parent)),
875                | 0 => Some(self.name),
876                | _ => None,
877            };
878            name.and_then(|value| ornl.member(&value))
879        }
880    }
881    /// Returns a graph representation of the organization hierarchy.
882    pub fn to_graph(self) -> Graph<String, u8> {
883        let mut graph: Graph<String, u8, petgraph::Directed> = Graph::new();
884        let organization = &self;
885        let root = graph.add_node(organization.name.clone());
886        let edges: Vec<(String, String)> = organization
887            .member
888            .iter()
889            .flat_map(|directorate| {
890                once((organization.name.clone(), directorate.name.clone())).chain(directorate.member.iter().flat_map(|division| {
891                    once((directorate.name.clone(), division.name.clone()))
892                        .chain(division.member.iter().map(|group| (division.name.clone(), group.name.clone())))
893                }))
894            })
895            .collect();
896
897        edges.into_iter().for_each(|(parent_name, child_name)| {
898            let parent = node_from_label(&graph, &parent_name).unwrap_or(root);
899            let child = node_from_label(&graph, &child_name).unwrap_or_else(|| graph.add_node(child_name.clone()));
900            graph.add_edge(parent, child, 0);
901        });
902        graph
903    }
904}
905impl OrganizationType {
906    /// Parses a string into an `OrganizationType` value
907    pub fn from_string(value: String) -> OrganizationType {
908        match value.to_lowercase().as_str() {
909            | "agency" => OrganizationType::Agency,
910            | "center" => OrganizationType::Center,
911            | "consortium" => OrganizationType::Consortium,
912            | "division" => OrganizationType::Division,
913            | "directorate" => OrganizationType::Directorate,
914            | "group" => OrganizationType::Group,
915            | "office" => OrganizationType::Office,
916            | "program" => OrganizationType::Program,
917            | "facility" => OrganizationType::Facility,
918            | "ffrdc" => OrganizationType::Ffrdc,
919            | _ => OrganizationType::Institute,
920        }
921    }
922    /// Returns the order of an `OrganizationType` value
923    pub fn order(self) -> u8 {
924        match self {
925            | OrganizationType::Ffrdc
926            | OrganizationType::Agency
927            | OrganizationType::Consortium
928            | OrganizationType::Institute
929            | OrganizationType::Office => 4,
930            | OrganizationType::Directorate => 3,
931            | OrganizationType::Division | OrganizationType::Center | OrganizationType::Program | OrganizationType::Facility => 2,
932            | OrganizationType::Group => 1,
933        }
934    }
935}
936impl MarkdownSupport for ContactPoint {
937    fn to_markdown(&self) -> String {
938        let ContactPoint {
939            given_name,
940            family_name,
941            job_title,
942            identifier,
943            email,
944            telephone,
945            url,
946            organization,
947            affiliation,
948            ..
949        } = self;
950        let job_title = job_title.to_markdown_text();
951        let given_name = given_name.to_markdown_text();
952        let family_name = family_name.to_markdown_text();
953        let identifier = identifier.as_ref().map(MarkdownSupport::to_markdown_text);
954        let email = email.to_markdown_text();
955        let telephone = telephone.to_markdown_text();
956        let url = url.to_markdown_text();
957        let organization = organization.to_markdown_text();
958        let affiliation = affiliation.as_ref().map(MarkdownSupport::to_markdown_text);
959        [
960            Some("## Contact".to_string()),
961            Some(format!("- Job Title: {job_title}")),
962            Some(format!("- Given Name: {given_name}")),
963            Some(format!("- Family Name: {family_name}")),
964            identifier.as_ref().map(|value| format!("- Identifier: {value}")),
965            Some(format!("- Email: [{email}](mailto:{email})")),
966            Some(format!("- Telephone: {telephone}")),
967            Some(format!("- URL: {url}")),
968            Some(format!("- Organization: {organization}")),
969            affiliation.as_ref().map(|value| format!("- Affiliation: {value}")),
970        ]
971        .into_iter()
972        .flatten()
973        .collect::<Vec<_>>()
974        .join("\n")
975    }
976}
977impl MarkdownSupport for Website {
978    fn to_markdown(&self) -> String {
979        let Website { description, url } = self;
980        format!("[{}]({})", description, url)
981    }
982}
983impl Validate for MediaObject {
984    fn validate(&self) -> Result<(), validator::ValidationErrors> {
985        match self {
986            | Self::Image(img) => img.validate(),
987            | Self::Video(vid) => vid.validate(),
988        }
989    }
990}
991#[cfg(feature = "std")]
992impl View for ContactPoint {
993    fn render(&self) -> VirtualNode {
994        let ContactPoint {
995            given_name,
996            family_name,
997            job_title: role,
998            email,
999            telephone,
1000            ..
1001        } = self;
1002        html! {
1003            <section id="contact">
1004                <div>
1005                    <span class="label">Contact</span>
1006                    <span class="spacer"> </span>
1007                    <span class="name">{ format!("{} {}", given_name, family_name) }</span>
1008                    <span class="spacer">|</span>
1009                    <span class="title">{ role }</span>
1010                    <span class="spacer">|</span>
1011                    <span class="email">{ email }</span>
1012                    <span class="spacer">|</span>
1013                    <span class="phone">{ telephone }</span>
1014                </div>
1015            </section>
1016        }
1017    }
1018}
1019#[cfg(feature = "std")]
1020pub(crate) fn resolve_from_list_of_lists<I: IntoIterator<Item = Vec<String>>>(value: String, data: I, name: String) -> Option<String> {
1021    fn match_list<I: IntoIterator<Item = String> + Clone>(value: String, values: I) -> Vec<(String, u32)> {
1022        let pattern = Pattern::parse(&value, CaseMatching::Ignore, Normalization::Smart);
1023        let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
1024        pattern.match_list(values.clone(), &mut matcher)
1025    }
1026    fn print_resolution(output: Option<String>, value: String, name: String) {
1027        let label = name.to_case(Case::Title);
1028        match output {
1029            | Some(resolved) => {
1030                if resolved.eq(&value.to_string()) {
1031                    trace!("=> {} {} = \"{}\"", Label::using(), label, value.clone());
1032                } else {
1033                    debug!(input = value.clone(), resolved, "=> {} {}", Label::found(), label);
1034                }
1035            }
1036            | None => {
1037                debug!(value = value.clone(), "=> {} {}", Label::not_found(), label);
1038            }
1039        };
1040    }
1041    let output = data
1042        .into_iter()
1043        .flat_map(|values| {
1044            let sanitized = value.normalize();
1045            let matched = match_list(sanitized, values.clone().into_iter().take(4));
1046            trace!("{} => {:?}", value.clone(), matched.clone());
1047            if matched.clone().is_empty() {
1048                None
1049            } else {
1050                match values.first() {
1051                    | Some(x) => {
1052                        if value.eq(x) {
1053                            Some((x.into(), 10000))
1054                        } else {
1055                            let score = matched.into_iter().map(|(_, score)| score).max();
1056                            match score {
1057                                | Some(value) if value > 0 => Some((x.to_string(), value)),
1058                                | Some(_) | None => None,
1059                            }
1060                        }
1061                    }
1062                    | None => None,
1063                }
1064            }
1065        })
1066        .max_by_key(|(_, score)| *score)
1067        .map(|(x, _)| x.to_string());
1068    print_resolution(output.clone(), value, name);
1069    output
1070}
1071#[cfg(not(feature = "std"))]
1072#[allow(dead_code)]
1073pub(crate) fn resolve_from_list_of_lists<I: IntoIterator<Item = Vec<String>>>(value: String, _data: I, _name: String) -> Option<String> {
1074    Some(value)
1075}
1076#[cfg(feature = "std")]
1077pub(crate) fn resolve_from_organization_json(value: String) -> Option<String> {
1078    let organization = Organization::load().into_iter().next()?;
1079    let items: Vec<Organization> = once(organization.clone())
1080        .chain(
1081            organization
1082                .member
1083                .iter()
1084                .flat_map(|directorate| once(directorate.clone()).chain(directorate.member.iter().cloned())),
1085        )
1086        .collect();
1087    let data = items
1088        .into_iter()
1089        .map(|x| (x.name.clone(), x.alternative_name.clone()))
1090        .filter(|(name, alias)| !(name.is_empty() && alias.is_none()))
1091        .map(|(name, alias)| {
1092            let alternative_name = alias.as_ref().map_or(name.clone(), |x| x.to_string());
1093            vec![name, alternative_name]
1094        })
1095        .collect::<Vec<Vec<String>>>();
1096    resolve_from_list_of_lists(value, data, "organization".to_string())
1097}
1098#[cfg(not(feature = "std"))]
1099#[allow(dead_code)]
1100pub(crate) fn resolve_from_organization_json(value: String) -> Option<String> {
1101    Some(value)
1102}
1103
1104#[cfg(test)]
1105mod tests;