acorn_lib/schema/
validate.rs

1#![deny(missing_docs)]
2//! # Schema validation helpers
3//!
4//! Generic validation functions and custom [validator](https://docs.rs/validator/latest/validator/) functions for validating schema data
5use crate::constants::*;
6use chrono::prelude::*;
7use fancy_regex::Regex;
8use uriparse::URI;
9use validator::ValidationError;
10
11/// Format a phone number into a standard format
12pub fn format_phone_number(value: &str) -> Result<String, ValidationError> {
13    const MESSAGE: &str = "Unable to format telephone number";
14    match RE_PHONE.captures(value) {
15        | Ok(value) => match value {
16            | Some(captures) => {
17                let country_code = match captures.name("country") {
18                    | Some(value) => Some(value.as_str().trim().to_string()),
19                    | None => None,
20                };
21                let area_code = match captures.name("area") {
22                    | Some(value) => Some(value.as_str().replace("(", "").replace(")", "")),
23                    | None => None,
24                };
25                let prefix = match captures.name("prefix") {
26                    | Some(value) => Some(value.as_str().to_string()),
27                    | None => None,
28                };
29                let line = match captures.name("line") {
30                    | Some(value) => Some(value.as_str().to_string()),
31                    | None => None,
32                };
33                Ok([country_code, area_code, prefix, line]
34                    .into_iter()
35                    .flatten()
36                    .collect::<Vec<String>>()
37                    .join("."))
38            }
39            | None => Err(ValidationError::new("telephone").with_message(MESSAGE.into())),
40        },
41        | _ => Err(ValidationError::new("telephone").with_message(MESSAGE.into())),
42    }
43}
44/// Check if a path has a valid image extension
45pub fn has_image_extension(value: &str) -> Result<(), ValidationError> {
46    const MESSAGE: &str = "Please provide a path with a PNG, JPEG, GIF, WEBP, TIFF or SVG extension";
47    match RE_IMAGE_EXTENSION.is_match(value) {
48        | Ok(value) if value => Ok(()),
49        | _ => Err(ValidationError::new("image").with_message(MESSAGE.into())),
50    }
51}
52fn is_current_year(value: String) -> bool {
53    let now: DateTime<Utc> = Utc::now();
54    let year = now.year().to_string().parse::<i32>().unwrap_or_default();
55    value.parse::<i32>().unwrap_or_default() <= year
56}
57/// Check if value is a valid DOI
58/// ### Note
59/// > `10.5555` is not a DOI prefix, but rather a handle prefix
60pub fn is_doi(value: &str) -> Result<(), ValidationError> {
61    const MESSAGE: &str = "Please provide a valid DOI, by itself and without domain or 'doi:' prefix.";
62    match RE_DOI.is_match(value) {
63        | Ok(x) if x && !value.contains("10.5555/") => Ok(()),
64        | _ => Err(ValidationError::new("doi").with_message(MESSAGE.into())),
65    }
66}
67/// Check if value is a valid IP6
68pub fn is_ip6(value: &str) -> Result<(), ValidationError> {
69    const MESSAGE: &str = "Please provide a valid IP6 address";
70    match RE_IP6.is_match(value) {
71        | Ok(value) if value => Ok(()),
72        | _ => Err(ValidationError::new("IP6").with_message(MESSAGE.into())),
73    }
74}
75/// Check if value is a valid ISO 8601 date (e.g., YYYY-MM-DD)
76/// ### Example
77/// > `2025-06-04`
78pub fn is_iso8601_date(value: &str) -> Result<(), ValidationError> {
79    const MESSAGE: &str = "Please provide a valid ISO 8601 date (e.g., YYYY-MM-DD)";
80    // TODO: Make sure not in the future
81    match RE_ISO_8601_DATE.is_match(value) {
82        | Ok(value) if value => Ok(()),
83        | _ => Err(ValidationError::new("ISO 8601 Date").with_message(MESSAGE.into())),
84    }
85}
86/// Check if value is a valid ISO 8601 year (e.g., YYYY)
87/// ### Examples
88/// - `2025`
89pub fn is_iso8601_year(value: &str) -> Result<(), ValidationError> {
90    const MESSAGE: &str = "Please provide a valid ISO 8601 year (e.g., YYYY)";
91    match RE_ISO_8601_YEAR.is_match(value) {
92        | Ok(x) if x && is_current_year(value.to_string()) => Ok(()),
93        | _ => Err(ValidationError::new("ISO 8601 Date").with_message(MESSAGE.into())),
94    }
95}
96/// Check if value is a valid kebab-case (e.g. 'this-is-kebab-case')
97pub fn is_kebabcase(value: &str) -> Result<(), ValidationError> {
98    const MESSAGE: &str = "Please provide an ID in kebab-case format";
99    let kebab = match Regex::new(r"[ *_./\!@#$%^&(){}]") {
100        | Ok(re) => re.replace_all(value, "").trim().to_string(),
101        | Err(err) => err.to_string(),
102    };
103    match kebab.to_lowercase().eq(&value) {
104        | true => Ok(()),
105        | _ => Err(ValidationError::new("kebabcase").with_message(MESSAGE.into())),
106    }
107}
108/// Custom validator function for validating list of URLs
109pub fn is_list_url(value: &[String]) -> Result<(), ValidationError> {
110    let is_valid = value.iter().all(|x| URI::try_from(x.as_str()).is_ok());
111    match is_valid {
112        | true => Ok(()),
113        | _ => Err(ValidationError::new("URLs").with_message("Every URL should be valid".to_string().into())),
114    }
115}
116/// Check if value is a valid phone number
117///
118/// Uses same regex as `format_phone_number`
119pub fn is_phone_number(value: &str) -> Result<(), ValidationError> {
120    const MESSAGE: &str = "Please provide a valid phone number";
121    let is_fake = match RE_FAKE_PHONE.is_match(value) {
122        | Ok(value) if value => true,
123        | _ => false,
124    };
125    match RE_PHONE.is_match(value) {
126        | Ok(value) if value && !is_fake => Ok(()),
127        | _ => Err(ValidationError::new("phone").with_message(MESSAGE.into())),
128    }
129}
130/// Check if value is a valid RAiD
131pub fn is_raid(value: &str) -> Result<(), ValidationError> {
132    const MESSAGE: &str = "Please provide a valid RAiD";
133    match RE_RAID.is_match(value) {
134        | Ok(value) if value => Ok(()),
135        | _ => Err(ValidationError::new("raid").with_message(MESSAGE.into())),
136    }
137}
138/// Check if value is a valid ROR
139pub fn is_ror(value: &str) -> Result<(), ValidationError> {
140    const MESSAGE: &str = "Please provide a valid ROR";
141    match RE_ROR.is_match(value) {
142        | Ok(value) if value => Ok(()),
143        | _ => Err(ValidationError::new("ror").with_message(MESSAGE.into())),
144    }
145}
146/// Custom validator function for [approach](/acorn_lib/schema/struct.Sections.html#structfield.approach)
147pub fn validate_attribute_approach(value: &[String]) -> Result<(), ValidationError> {
148    const MAX_LENGTH: usize = MAX_LENGTH_APPROACH;
149    let message: String = format!("Each approach statement should be less than {MAX_LENGTH} characters");
150    let is_valid = value.iter().all(|x| x.len() <= MAX_LENGTH);
151    match is_valid {
152        | true => Ok(()),
153        | _ => Err(ValidationError::new("approach").with_message(message.into())),
154    }
155}
156/// Custom validator function for [research areas](/acorn_lib/schema/struct.Research.html#structfield.areas)
157pub fn validate_attribute_areas(value: &[String]) -> Result<(), ValidationError> {
158    const MAX_LENGTH: usize = MAX_LENGTH_RESEARCH_AREA;
159    let is_valid = value.iter().all(|x| x.len() <= MAX_LENGTH);
160    match is_valid {
161        | true => Ok(()),
162        | _ => Err(ValidationError::new("area").with_message(format!("Each area should be less than {MAX_LENGTH} characters").into())),
163    }
164}
165/// Custom validator function for [`ResearchActivity`] [capabilities](/acorn_lib/schema/struct.Sections.html#structfield.capabilities)
166///
167/// [`ResearchActivity`]: ../struct.ResearchActivity.html
168pub fn validate_attribute_capabilities(value: &[String]) -> Result<(), ValidationError> {
169    const MAX_LENGTH: usize = MAX_LENGTH_CAPABILIY;
170    let is_valid = value.iter().all(|x| x.len() <= MAX_LENGTH);
171    match is_valid {
172        | true => Ok(()),
173        | _ => Err(ValidationError::new("capability").with_message(format!("Each capability should be less than {MAX_LENGTH} characters").into())),
174    }
175}
176/// Custom validator function for [`ResearchActivity`] [doi](/acorn_lib/schema/struct.Metadata.html#structfield.doi)
177///
178/// [`ResearchActivity`]: ../struct.ResearchActivity.html
179pub fn validate_attribute_doi(value: &[String]) -> Result<(), ValidationError> {
180    let is_valid = value.iter().all(|x| is_doi(x).is_ok());
181    match is_valid {
182        | true => Ok(()),
183        | _ => Err(ValidationError::new("DOIs").with_message("Every DOI should be valid".to_string().into())),
184    }
185}
186/// Custom validator function for [`ResearchActivity`] [ror](/acorn_lib/schema/struct.Metadata.html#structfield.ror)
187///
188/// [`ResearchActivity`]: ../struct.ResearchActivity.html
189pub fn validate_attribute_ror(value: &[String]) -> Result<(), ValidationError> {
190    let is_valid = value.iter().all(|x| is_ror(x).is_ok());
191    match is_valid {
192        | true => Ok(()),
193        | _ => Err(ValidationError::new("RORs").with_message("Every ROR should be valid".to_string().into())),
194    }
195}
196// TODO: Check that statments start with capital letter (use regex for period and captial?)
197/// Custom validator function for [`ResearchActivity`] [impact](/acorn_lib/schema/struct.Sections.html#structfield.impact)
198///
199/// [`ResearchActivity`]: ../struct.ResearchActivity.html
200pub fn validate_attribute_impact(value: &[String]) -> Result<(), ValidationError> {
201    const MAX_LENGTH: usize = MAX_LENGTH_IMPACT;
202    match value.iter().all(|x| x.len() <= MAX_LENGTH) {
203        | true => {
204            let all_periods = value.iter().all(|x| x.trim().ends_with("."));
205            let no_periods = value.iter().all(|x| !x.trim().ends_with("."));
206            let is_valid = all_periods || no_periods;
207            match is_valid {
208                | true => Ok(()),
209                | _ => Err(ValidationError::new("impact")
210                    .with_message("Impact statements should be all sentences with periods or all phrases without periods".into())),
211            }
212        }
213        | _ => Err(ValidationError::new("impact").with_message(format!("Each impact statement should be less than {MAX_LENGTH} characters").into())),
214    }
215}