use enumset::EnumSet;
use lazy_regex::regex;
use regex::Regex;
use std::collections::HashSet;
use std::fmt;
use thiserror::Error;
use crate::validation::{Error, Options};
pub const COMPONENT_NAME_PATTERN: &str = r"^[a-zA-Z0-9.\-_]+$";
#[derive(Debug, Clone, PartialEq, Eq, Hash, Error)]
#[error("component name {name:?} must match pattern `{COMPONENT_NAME_PATTERN}`")]
pub struct InvalidComponentName {
pub name: String,
}
pub fn check_component_name(name: &str) -> Result<(), InvalidComponentName> {
if regex!(r"^[a-zA-Z0-9.\-_]+$").is_match(name) {
Ok(())
} else {
Err(InvalidComponentName {
name: name.to_owned(),
})
}
}
pub trait ValidateWithContext<T> {
fn validate_with_context(&self, ctx: &mut Context<T>, path: String);
}
#[derive(Debug, Clone, PartialEq)]
pub struct Context<'a, T> {
pub spec: &'a T,
pub visited: HashSet<String>,
pub errors: Vec<String>,
pub options: EnumSet<Options>,
}
pub trait PushError<T> {
fn error(&mut self, path: String, args: T);
}
impl<T> PushError<&str> for Context<'_, T> {
fn error(&mut self, path: String, msg: &str) {
if msg.starts_with('.') {
self.errors.push(format!("{path}{msg}"));
} else {
self.errors.push(format!("{path}: {msg}"));
}
}
}
impl<T> PushError<String> for Context<'_, T> {
fn error(&mut self, path: String, msg: String) {
self.error(path, msg.as_str());
}
}
impl<T> PushError<fmt::Arguments<'_>> for Context<'_, T> {
fn error(&mut self, path: String, args: fmt::Arguments<'_>) {
self.error(path, args.to_string().as_str());
}
}
impl<T> Context<'_, T> {
pub fn reset(&mut self) {
self.visited.clear();
self.errors.clear();
}
pub fn visit(&mut self, path: String) -> bool {
self.visited.insert(path)
}
pub fn is_visited(&self, path: &str) -> bool {
self.visited.contains(path)
}
pub fn is_option(&self, option: Options) -> bool {
self.options.contains(option)
}
}
impl Context<'_, ()> {
pub fn new<T>(spec: &T, options: EnumSet<Options>) -> Context<'_, T> {
Context {
spec,
visited: HashSet::new(),
errors: Vec::new(),
options,
}
}
}
impl<'a, T> From<Context<'a, T>> for Result<(), Error> {
fn from(val: Context<'a, T>) -> Self {
if val.errors.is_empty() {
Ok(())
} else {
Err(Error { errors: val.errors })
}
}
}
pub fn validate_email<T>(email: &Option<String>, ctx: &mut Context<T>, path: String) {
if let Some(email) = email
&& !email.contains('@')
{
ctx.error(
path,
format_args!("must be a valid email address, found `{email}`"),
);
}
}
const HTTP: &str = "http://";
const HTTPS: &str = "https://";
pub fn validate_optional_url<T>(url: &Option<String>, ctx: &mut Context<T>, path: String) {
if let Some(url) = url {
validate_required_url(url, ctx, path);
}
}
pub fn validate_optional_uri<T>(uri: &Option<String>, ctx: &mut Context<T>, path: String) {
let Some(uri) = uri else { return };
if ctx.is_option(Options::IgnoreInvalidUrls) {
return;
}
if uri.is_empty() {
ctx.error(path, "must be a valid URI, found ``");
return;
}
if has_uri_unsafe_bytes(uri) {
ctx.error(path, format_args!("must be a valid URI, found `{uri}`"));
}
}
pub fn has_uri_unsafe_bytes(s: &str) -> bool {
s.bytes()
.any(|b| b.is_ascii_whitespace() || b.is_ascii_control())
}
pub fn validate_required_uri<T>(uri: &String, ctx: &mut Context<T>, path: String) {
validate_required_string(uri, ctx, path.clone());
if uri.is_empty() || ctx.is_option(Options::IgnoreInvalidUrls) {
return;
}
if has_uri_unsafe_bytes(uri) {
ctx.error(path, format_args!("must be a valid URI, found `{uri}`"));
}
}
pub fn validate_required_url<T>(url: &String, ctx: &mut Context<T>, path: String) {
if !ctx.is_option(Options::IgnoreEmptyExternalDocumentationUrl) {
validate_required_string(url, ctx, path.clone());
}
if url.is_empty() || ctx.is_option(Options::IgnoreInvalidUrls) {
return;
}
if !url.starts_with(HTTP) && !url.starts_with(HTTPS) {
ctx.error(path, format_args!("must be a valid URL, found `{url}`"));
}
}
pub fn validate_required_string<T>(s: &str, ctx: &mut Context<T>, path: String) {
if s.is_empty() {
ctx.error(path, "must not be empty");
}
}
pub fn validate_string_matches<T>(s: &str, pattern: &Regex, ctx: &mut Context<T>, path: String) {
if !pattern.is_match(s) {
ctx.error(
path,
format_args!("must match pattern `{pattern}`, found `{s}`"),
);
}
}
pub fn validate_optional_string_matches<T>(
s: &Option<String>,
pattern: &Regex,
ctx: &mut Context<T>,
path: String,
) {
if let Some(s) = s {
validate_string_matches(s, pattern, ctx, path);
}
}
pub fn validate_pattern<T>(pattern: &str, ctx: &mut Context<T>, path: String) {
match Regex::new(pattern) {
Ok(_) => {}
Err(e) => ctx.error(path, format_args!("pattern `{pattern}` is invalid: {e}")),
}
}
pub fn validate_not_visited<T, D>(
obj: &D,
ctx: &mut Context<T>,
ignore_option: Options,
path: String,
) where
D: ValidateWithContext<T>,
{
if ctx.visit(path.clone()) {
if !ctx.is_option(ignore_option) {
ctx.error(path.clone(), "unused");
}
obj.validate_with_context(ctx, path);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_url() {
let mut ctx = Context::new(&(), Options::new());
validate_required_url(
&String::from("http://example.com"),
&mut ctx,
String::from("test_url"),
);
assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
let mut ctx = Context::new(&(), Options::new());
validate_required_url(
&String::from("https://example.com"),
&mut ctx,
String::from("test_url"),
);
assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
let mut ctx = Context::new(&(), Options::new());
validate_required_url(&String::from("foo-bar"), &mut ctx, String::from("test_url"));
assert!(
ctx.errors
.contains(&"test_url: must be a valid URL, found `foo-bar`".to_string()),
"expected error: {:?}",
ctx.errors
);
let mut ctx = Context::new(&(), Options::only(&Options::IgnoreInvalidUrls));
validate_required_url(&String::from("foo-bar"), &mut ctx, String::from("test_url"));
assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
let mut ctx = Context::new(&(), Options::new());
validate_optional_url(&None, &mut ctx, String::from("test_url"));
assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
let mut ctx = Context::new(&(), Options::new());
validate_optional_url(
&Some(String::from("http://example.com")),
&mut ctx,
String::from("test_url"),
);
assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
let mut ctx = Context::new(&(), Options::new());
validate_optional_url(
&Some(String::from("https://example.com")),
&mut ctx,
String::from("test_url"),
);
assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
let mut ctx = Context::new(&(), Options::new());
validate_optional_url(
&Some(String::from("foo-bar")),
&mut ctx,
String::from("test_url"),
);
assert!(
ctx.errors
.contains(&"test_url: must be a valid URL, found `foo-bar`".to_string()),
"expected error: {:?}",
ctx.errors
);
let mut ctx = Context::new(&(), Options::only(&Options::IgnoreInvalidUrls));
validate_optional_url(
&Some(String::from("foo-bar")),
&mut ctx,
String::from("test_url"),
);
assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
}
#[test]
fn validate_optional_uri_rejects_control_chars_and_whitespace() {
for s in ["bad\turi", "with\nnewline", "with\x01ctl", "with\x7fdel"] {
let mut ctx = Context::new(&(), Options::new());
validate_optional_uri(&Some(s.to_owned()), &mut ctx, "u".to_owned());
assert!(
ctx.errors.iter().any(|e| e.contains("must be a valid URI")),
"expected error for `{s:?}`: {:?}",
ctx.errors
);
}
let mut ctx = Context::new(&(), Options::new());
validate_optional_uri(
&Some("urn:example:dialect".to_owned()),
&mut ctx,
"u".to_owned(),
);
assert!(ctx.errors.is_empty(), "no errors: {:?}", ctx.errors);
}
}