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