1#[cfg(feature = "std")]
6use crate::io::database::{schema::LicenseRow, Row};
7#[cfg(feature = "std")]
8use crate::io::License;
9#[cfg(feature = "std")]
10use crate::prelude::env;
11use crate::prelude::*;
12use crate::schema::geonames::{CodeFormat, GeonamesParser};
13use crate::schema::pid::{Patent, PersistentIdentifierConvert};
14use crate::schema::standard::datacite::GeoLocationPoint;
15#[cfg(feature = "std")]
16use crate::util::constants::env::DATABASE_PATH;
17use crate::util::constants::{RE_FAKE_PHONE, RE_IMAGE_EXTENSION, RE_IP6, RE_ISO_8601_DATE, RE_PARTIAL_DATE, RE_PHONE, RE_RAID, RE_UNIX_EPOCH};
18use crate::util::Constant;
19#[cfg(not(feature = "std"))]
20use crate::util::License;
21use crate::util::SemanticVersion;
22use crate::{list_validator, method_validator, regex_validator};
23#[cfg(feature = "std")]
24use chrono::SecondsFormat;
25#[cfg(feature = "std")]
26use chrono::Utc;
27use chrono::{DateTime, Datelike, NaiveDate};
28use convert_case::{Case, Casing};
29use core::iter::once;
30use fluent_uri::UriRef;
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33use validator::{Validate, ValidationError, ValidationErrors};
34
35method_validator!(is_ark, "ARK");
36method_validator!(is_doi, "DOI");
37method_validator!(is_isbn, "ISBN");
38method_validator!(is_orcid, "ORCiD");
39method_validator!(is_ror, "ROR");
40regex_validator!(is_ip6, RE_IP6, "IP6");
41regex_validator!(is_raid, RE_RAID, "RAiD");
42regex_validator!(
43 has_image_extension,
44 RE_IMAGE_EXTENSION,
45 "image",
46 "Provide path with a PNG, JPEG, GIF, WEBP, TIFF or SVG extension"
47);
48regex_validator!(is_date, RE_ISO_8601_DATE, "date", "Provide valid ISO 8601 date (e.g., YYYY-MM-DD)");
49regex_validator!(
50 is_partial_date,
51 RE_PARTIAL_DATE,
52 "date",
53 "Provide valid date (e.g., YYYY-MM-DD or YYYY-MM)"
54);
55list_validator!(
56 is_states,
58 is_state,
59 "state",
60 "Every state should be valid"
61);
62list_validator!(
63 is_urls,
65 is_url,
66 "url",
67 "Every URL should be valid"
68);
69#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
71#[serde(untagged)]
72pub enum IntegerOrString {
73 Number(i64),
75 Text(String),
77}
78
79#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
81#[serde(untagged)]
82pub enum MonthValue {
83 Number(u8),
85 Text(String),
87}
88
89#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
91#[serde(untagged)]
92pub enum NumberOrString {
93 Number(f64),
95 Text(String),
97}
98#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
100#[serde(untagged)]
101pub enum YearValue {
102 Number(i64),
104 Text(String),
106}
107
108#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
110#[serde(untagged)]
111pub enum PostalCode {
112 Number(i64),
114 Text(String),
116}
117pub fn format_phone_number(value: &str) -> Result<String, ValidationError> {
128 const CODE: &str = "phone";
129 const MESSAGE: &str = "Unable to format telephone number";
130 match RE_PHONE.captures(value) {
131 | Ok(value) => match value {
132 | Some(captures) => {
133 let country_code = match captures.name("country") {
134 | Some(value) => Some(value.as_str().trim().to_string()),
135 | None => None,
136 };
137 let area_code = match captures.name("area") {
138 | Some(value) => Some(value.as_str().replace("(", "").replace(")", "")),
139 | None => None,
140 };
141 let prefix = match captures.name("prefix") {
142 | Some(value) => Some(value.as_str().to_string()),
143 | None => None,
144 };
145 let line = match captures.name("line") {
146 | Some(value) => Some(value.as_str().to_string()),
147 | None => None,
148 };
149 Ok([country_code, area_code, prefix, line]
150 .into_iter()
151 .flatten()
152 .collect::<Vec<String>>()
153 .join("."))
154 }
155 | None => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
156 },
157 | _ => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
158 }
159}
160pub fn format_timestamp(value: &str) -> String {
193 #[cfg(feature = "std")]
194 {
195 DateTime::parse_from_rfc3339(value)
196 .map(|dt| dt.to_rfc3339_opts(SecondsFormat::Micros, false))
197 .unwrap_or_else(|_| value.to_string())
198 }
199 #[cfg(not(feature = "std"))]
200 {
201 value.to_string()
202 }
203}
204pub fn has_at_least_one_truthy<T>(values: &[T], method: fn(&T) -> bool, code: &'static str, message: &'static str) -> Result<(), ValidationError> {
208 match values.iter().any(method) {
209 | true => Ok(()),
210 | false => Err(ValidationError::new(code).with_message(message.into())),
211 }
212}
213pub fn is_commit(value: &str) -> Result<(), ValidationError> {
215 const CODE: &str = "commit";
216 const MESSAGE: &str = "Provide valid commit hash (7-64 hexadecimal characters)";
217 let valid = (7..=64).contains(&value.len()) && value.chars().all(|character| character.is_ascii_hexdigit());
218 match valid {
219 | true => Ok(()),
220 | false => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
221 }
222}
223pub fn is_country_code(value: impl ToString) -> Result<(), ValidationError> {
229 const CODE: &str = "country_code";
230 const MESSAGE: &str = "Provide valid ISO 3166-1 alpha-2 country code";
231 let x = value.to_string();
232 let valid = x.len() == 2 && Constant::country_codes(CodeFormat::Alpha2).contains(&x.to_lowercase());
233 match valid {
234 | true => Ok(()),
235 | false => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
236 }
237}
238pub fn is_iso_639_language_code(value: &str) -> Result<(), ValidationError> {
244 const CODE: &str = "language";
245 const MESSAGE: &str = "Provide valid ISO 639 language code";
246 let candidate = value.to_lowercase();
247 let valid = Constant::languages(CodeFormat::Alpha2).contains(&candidate) || Constant::languages(CodeFormat::Alpha3).contains(&candidate);
248 match valid {
249 | true => Ok(()),
250 | false => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
251 }
252}
253pub fn is_iso_639_1_language_code(value: &str) -> Result<(), ValidationError> {
259 const CODE: &str = "language";
260 const MESSAGE: &str = "Provide valid ISO 639-1 language code (two-letter code only)";
261 let valid = value.len() == 2 && Constant::languages(CodeFormat::Alpha2).contains(&value.to_lowercase());
262 match valid {
263 | true => Ok(()),
264 | false => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
265 }
266}
267pub fn is_kebabcase(value: &str) -> Result<(), ValidationError> {
269 const CODE: &str = "kebabcase";
270 let valid = value.to_case(Case::Kebab);
271 let message = format!("Provide ID in kebab-case format (e.g., {valid})");
272 match valid.eq(&value) {
273 | true => Ok(()),
274 | _ => Err(ValidationError::new(CODE).with_message(message.into())),
275 }
276}
277pub fn is_latitude(value: impl ToString) -> Result<(), ValidationError> {
279 const CODE: &str = "latitude";
280 const MESSAGE: &str = "Provide valid latitude (-90 to 90)";
281 let parsed = value.to_string().parse::<f64>().ok();
282 match parsed.filter(|x| (-90.0..=90.0).contains(x)) {
283 | Some(_) => Ok(()),
284 | None => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
285 }
286}
287#[cfg(feature = "std")]
291pub fn is_license(value: &str) -> Result<(), ValidationError> {
292 const CODE: &str = "license";
293 const MESSAGE: &str = "Provide valid SPDX license identifier";
294 let candidate = value.trim().to_string();
295 let normalized = candidate.to_ascii_lowercase();
296 let validation_error = |message: String| {
297 let mut why = ValidationError::new(CODE).with_message(message.into());
298 why.add_param("value".into(), &candidate);
299 why
300 };
301 let predicate = |row: &LicenseRow| {
302 let LicenseRow { identifier, name, .. } = row;
303 let is_valid_identifier = identifier.as_ref().map(|id| id.eq_ignore_ascii_case(&normalized)).unwrap_or(false);
304 let is_valid_name = name.as_ref().map(|name| name.eq_ignore_ascii_case(&normalized)).unwrap_or(false);
305 is_valid_identifier || is_valid_name
306 };
307 if normalized.is_empty() {
308 Err(validation_error(format!("{MESSAGE} (cannot be empty)")))
309 } else {
310 match env::var(DATABASE_PATH) {
311 | Ok(path) => {
312 let exists = LicenseRow::default().select(Some(path.into()), predicate).map(|row| row.is_some());
313 match exists {
314 | Ok(true) => Ok(()),
315 | Ok(false) => Err(validation_error(MESSAGE.to_string())),
316 | Err(_) => Err(validation_error("Failed to access local database for license validation".to_string())),
317 }
318 }
319 | Err(_) => Err(validation_error("Failed to access local database for license validation".to_string())),
320 }
321 }
322}
323#[cfg(not(feature = "std"))]
324pub fn is_license(value: &str) -> Result<(), ValidationError> {
326 const CODE: &str = "license";
327 const MESSAGE: &str = "Provide valid SPDX license identifier";
328 let value = value.trim();
329 if value.is_empty() {
330 Err(ValidationError::new(CODE).with_message(format!("{MESSAGE} (cannot be empty)").into()))
331 } else {
332 Ok(())
333 }
334}
335pub fn is_longitude<T>(value: T) -> Result<(), ValidationError>
337where
338 T: ToString,
339{
340 const CODE: &str = "longitude";
341 const MESSAGE: &str = "Provide valid longitude (-180 to 180)";
342 let parsed = value.to_string().parse::<f64>().ok();
343 match parsed.filter(|x| (-180.0..=180.0).contains(x)) {
344 | Some(_) => Ok(()),
345 | None => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
346 }
347}
348pub fn is_patent_identifier(value: &str) -> Result<(), ValidationError> {
350 const CODE: &str = "patent";
351 const MESSAGE: &str = "Provide valid patent identifier";
352 match Patent::is_valid(value) {
353 | true => Ok(()),
354 | _ => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
355 }
356}
357pub fn is_phone_number(value: &str) -> Result<(), ValidationError> {
361 const CODE: &str = "phone";
362 let is_fake = RE_FAKE_PHONE.is_match(value).unwrap_or(false);
363 let is_valid = RE_PHONE.is_match(value).unwrap_or(false);
364 match (is_valid, is_fake) {
365 | (true, false) => Ok(()),
366 | (_, true) => Err(ValidationError::new(CODE).with_message("Provide real phone number, not a placeholder".into())),
367 | _ => Err(ValidationError::new(CODE).with_message("Provide valid phone number".into())),
368 }
369}
370pub fn is_polygon(value: &[GeoLocationPoint]) -> Result<(), ValidationError> {
372 const CODE: &str = "polygon";
373 const EMPTY_MESSAGE: &str = "Provide valid polygon — no points were provided";
374 let length = value.len();
375 let result = if length == 0 {
376 Err(ValidationError::new(CODE).with_message(EMPTY_MESSAGE.into()))
377 } else if length < 4 {
378 let length_message = format!("Provide valid polygon — at least 4 points are required (only {length} found)");
379 Err(ValidationError::new(CODE).with_message(length_message.into()))
380 } else {
381 match (value.first(), value.last()) {
382 | (Some(first), Some(last)) if first == last => Ok(()),
383 | (Some(first), Some(last)) => {
384 let closed_message = format!("Provide valid polygon — first point must equal last point ({first} ≠ {last})");
385 Err(ValidationError::new(CODE).with_message(closed_message.into()))
386 }
387 | _ => Err(ValidationError::new(CODE).with_message(EMPTY_MESSAGE.into())),
388 }
389 };
390 result
391}
392pub fn is_rfc3339(value: impl ToString) -> Result<(), ValidationError> {
397 const CODE: &str = "date";
398 const MESSAGE: &str = "Provide valid RFC 3339 timestamp (ISO 8601 compliant)";
399 let x = value.to_string();
400 let is_rfc3339 = DateTime::parse_from_rfc3339(&x).is_ok();
401 let is_date = NaiveDate::parse_from_str(&x, "%Y-%m-%d").is_ok();
402 let is_supported_date_value = |value: &str| DateTime::parse_from_rfc3339(value).is_ok() || NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok();
403 let is_date_interval = x
404 .split_once('/')
405 .map(|(start, end)| is_supported_date_value(start) && is_supported_date_value(end))
406 .unwrap_or(false);
407 match is_rfc3339 || is_date || is_date_interval {
408 | true => Ok(()),
409 | false => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
410 }
411}
412pub fn is_semantic_version(value: &str) -> Result<(), ValidationError> {
416 const CODE: &str = "version";
417 const MESSAGE: &str = "Provide valid semantic version (e.g., 1.2.3)";
418 let parsed = SemanticVersion::from(value);
419 match parsed.to_string().starts_with(value) {
420 | true => Ok(()),
421 | false => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
422 }
423}
424pub fn is_state(value: impl ToString) -> Result<(), ValidationError> {
426 const CODE: &str = "state";
427 const MESSAGE: &str = "Provide valid US state or Canadian province abbreviation (e.g., CA, NY, ON)";
428 let x = value.to_string();
429 let valid = x.len() == 2 && x.chars().all(|c| c.is_ascii_alphabetic());
430 match valid {
431 | true => Ok(()),
432 | false => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
433 }
434}
435pub fn is_unix_epoch(value: usize) -> Result<(), ValidationError> {
437 const CODE: &str = "epoch";
438 const MESSAGE: &str = "Provide valid Unix epoch timestamp";
439 match RE_UNIX_EPOCH.is_match(&value.to_string()) {
440 | Ok(value) if value => Ok(()),
441 | _ => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
442 }
443}
444pub fn is_url(value: impl ToString) -> Result<(), ValidationError> {
446 const CODE: &str = "url";
447 const MESSAGE: &str = "Provide valid URL";
448 let x = value.to_string();
449 match UriRef::parse(x.as_str()) {
450 | Ok(_) => Ok(()),
451 | Err(_) => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
452 }
453}
454pub fn is_year(value: impl ToString) -> Result<(), ValidationError> {
458 const CODE: &str = "year";
459 const MESSAGE: &str = "Provide valid ISO 8601 year (e.g., YYYY)";
460 let x = value.to_string();
461 let parsed_year = (x.len() == 4)
462 .then(|| format!("{x}-01-01"))
463 .and_then(|date| NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok())
464 .map(|date| date.year());
465 match parsed_year {
466 | Some(year) if is_viable_year(year) => Ok(()),
467 | _ => Err(ValidationError::new(CODE).with_message(MESSAGE.into())),
468 }
469}
470#[cfg(feature = "std")]
471fn is_viable_year(year: i32) -> bool {
472 let now: DateTime<Utc> = Utc::now();
473 year <= now.year()
474}
475#[cfg(not(feature = "std"))]
476fn is_viable_year(_year: i32) -> bool {
477 true
478}
479impl Validate for IntegerOrString {
480 fn validate(&self) -> Result<(), ValidationErrors> {
481 Ok(())
482 }
483}
484impl Validate for License {
485 fn validate(&self) -> Result<(), ValidationErrors> {
486 let to_validation_errors = |why: ValidationError| {
487 once(why).fold(ValidationErrors::new(), |mut acc, err| {
488 acc.add("license", err);
489 acc
490 })
491 };
492 #[cfg(feature = "std")]
493 {
494 let invalid = match self {
495 | License::Single(value) => is_license(value).err(),
496 | License::Multiple(values) => values.iter().find_map(|value| is_license(value).err()),
497 };
498 invalid.map_or(Ok(()), |why| Err(to_validation_errors(why)))
499 }
500 #[cfg(not(feature = "std"))]
501 {
502 is_license(&self.to_string()).map_err(to_validation_errors)
503 }
504 }
505}
506impl Validate for MonthValue {
507 fn validate(&self) -> Result<(), ValidationErrors> {
508 let to_validation_errors = |why: ValidationError| {
509 once(why).fold(ValidationErrors::new(), |mut acc, err| {
510 acc.add("month", err);
511 acc
512 })
513 };
514 let valid = match self {
515 | Self::Number(value) => (1..=12).contains(value),
516 | Self::Text(value) => matches!(value.as_str(), "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11" | "12"),
517 };
518 match valid {
519 | true => Ok(()),
520 | false => Err(to_validation_errors(
521 ValidationError::new("range").with_message("Month should be an integer 1-12 or a string value from \"1\" to \"12\"".into()),
522 )),
523 }
524 }
525}
526impl Validate for NumberOrString {
527 fn validate(&self) -> Result<(), ValidationErrors> {
528 Ok(())
529 }
530}
531impl Validate for YearValue {
532 fn validate(&self) -> Result<(), ValidationErrors> {
533 let to_validation_errors = |why: ValidationError| {
534 once(why).fold(ValidationErrors::new(), |mut acc, err| {
535 acc.add("year", err);
536 acc
537 })
538 };
539 match self {
540 | Self::Number(value) => is_year(*value).map_err(to_validation_errors),
541 | Self::Text(value) => is_year(value).map_err(to_validation_errors),
542 }
543 }
544}
545
546impl Validate for PostalCode {
547 fn validate(&self) -> Result<(), ValidationErrors> {
548 let to_validation_errors = |why: ValidationError| {
549 once(why).fold(ValidationErrors::new(), |mut acc, err| {
550 acc.add("postal_code", err);
551 acc
552 })
553 };
554 let valid = match self {
555 | Self::Number(value) => *value >= 0,
556 | Self::Text(value) => {
557 let trimmed = value.trim();
558 !trimmed.is_empty()
559 && trimmed.len() <= 20
560 && trimmed
561 .chars()
562 .all(|character| character.is_alphanumeric() || character == ' ' || character == '-')
563 }
564 };
565 match valid {
566 | true => Ok(()),
567 | false => Err(to_validation_errors(ValidationError::new("format").with_message(
568 "Postal code should be a non-negative integer or a non-empty string up to 20 characters using letters, digits, spaces, or hyphens"
569 .into(),
570 ))),
571 }
572 }
573}