use crate::error::{BooruError, Result};
use std::borrow::Cow;
#[derive(Debug, Clone)]
pub struct TagValidation {
pub original: String,
pub normalized: Option<String>,
pub warnings: Vec<TagWarning>,
pub is_valid: bool,
}
impl TagValidation {
#[must_use]
pub fn has_warnings(&self) -> bool {
!self.warnings.is_empty()
}
#[must_use]
pub fn tag(&self) -> &str {
self.normalized.as_ref().unwrap_or(&self.original)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TagWarning {
SpacesFound {
original: String,
suggested: String,
},
LeadingTrailingWhitespace,
EmptyTag,
ConsecutiveUnderscores,
VeryLongTag {
length: usize,
},
UnusualCharacters {
chars: Vec<char>,
},
UnsupportedMetaTag {
prefix: String,
},
}
impl std::fmt::Display for TagWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TagWarning::SpacesFound {
original,
suggested,
} => {
write!(
f,
"Tag contains spaces: '{}'. Did you mean '{}'?",
original, suggested
)
}
TagWarning::LeadingTrailingWhitespace => {
write!(f, "Tag has leading or trailing whitespace")
}
TagWarning::EmptyTag => write!(f, "Tag is empty"),
TagWarning::ConsecutiveUnderscores => write!(f, "Tag contains consecutive underscores"),
TagWarning::VeryLongTag { length } => {
write!(f, "Tag is very long ({} chars), may cause issues", length)
}
TagWarning::UnusualCharacters { chars } => {
write!(f, "Tag contains unusual characters: {:?}", chars)
}
TagWarning::UnsupportedMetaTag { prefix } => {
write!(
f,
"Meta tag '{}:' may not be supported on all booru sites",
prefix
)
}
}
}
}
const COMMON_META_TAGS: &[&str] = &[
"rating", "score", "order", "sort", "user", "height", "width", "id", "md5", "source", "parent",
"pool",
];
const DANBOORU_ONLY_META_TAGS: &[&str] = &[
"pixiv_id",
"favcount",
"gentags",
"arttags",
"chartags",
"copytags",
"approver",
"commenter",
"noter",
"flagger",
];
#[must_use]
pub fn validate_tag(tag: &str) -> TagValidation {
let mut warnings = Vec::new();
let mut normalized = None;
if tag.is_empty() {
return TagValidation {
original: tag.to_string(),
normalized: None,
warnings: vec![TagWarning::EmptyTag],
is_valid: false,
};
}
let trimmed = tag.trim();
if trimmed != tag {
warnings.push(TagWarning::LeadingTrailingWhitespace);
normalized = Some(trimmed.to_string());
}
let working_tag = trimmed;
if working_tag.contains(' ') {
let suggested = working_tag.replace(' ', "_");
warnings.push(TagWarning::SpacesFound {
original: working_tag.to_string(),
suggested: suggested.clone(),
});
normalized = Some(suggested);
}
if working_tag.contains("__") {
warnings.push(TagWarning::ConsecutiveUnderscores);
}
if working_tag.len() > 100 {
warnings.push(TagWarning::VeryLongTag {
length: working_tag.len(),
});
}
let unusual: Vec<char> = working_tag
.chars()
.filter(|c| {
!c.is_alphanumeric()
&& *c != '_'
&& *c != '-'
&& *c != ':'
&& *c != '('
&& *c != ')'
&& *c != '<'
&& *c != '>'
&& *c != '='
&& *c != '.'
&& *c != '*'
&& *c != '?'
})
.collect();
if !unusual.is_empty() {
warnings.push(TagWarning::UnusualCharacters { chars: unusual });
}
if let Some(colon_pos) = working_tag.find(':') {
let prefix = &working_tag[..colon_pos];
if !COMMON_META_TAGS.contains(&prefix) && DANBOORU_ONLY_META_TAGS.contains(&prefix) {
warnings.push(TagWarning::UnsupportedMetaTag {
prefix: prefix.to_string(),
});
}
}
TagValidation {
original: tag.to_string(),
normalized,
warnings,
is_valid: true,
}
}
pub fn validate_tag_strict(tag: &str) -> Result<Cow<'_, str>> {
let result = validate_tag(tag);
if !result.is_valid {
return Err(BooruError::InvalidTag {
tag: tag.to_string(),
reason: result
.warnings
.first()
.map(|w| w.to_string())
.unwrap_or_else(|| "Unknown validation error".to_string()),
});
}
if let Some(normalized) = result.normalized {
Ok(Cow::Owned(normalized))
} else {
Ok(Cow::Borrowed(tag))
}
}
pub fn validate_tags<'a, I>(tags: I) -> Result<Vec<String>>
where
I: IntoIterator<Item = &'a str>,
{
tags.into_iter()
.map(|tag| validate_tag_strict(tag).map(|t| t.into_owned()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_tag() {
let result = validate_tag("cat_ears");
assert!(result.is_valid);
assert!(!result.has_warnings());
assert!(result.normalized.is_none());
}
#[test]
fn test_spaces_to_underscores() {
let result = validate_tag("cat ears");
assert!(result.is_valid);
assert!(result.has_warnings());
assert_eq!(result.normalized, Some("cat_ears".to_string()));
}
#[test]
fn test_empty_tag() {
let result = validate_tag("");
assert!(!result.is_valid);
assert!(matches!(
result.warnings.first(),
Some(TagWarning::EmptyTag)
));
}
#[test]
fn test_leading_trailing_whitespace() {
let result = validate_tag(" cat_ears ");
assert!(result.is_valid);
assert!(result.has_warnings());
assert_eq!(result.normalized, Some("cat_ears".to_string()));
}
#[test]
fn test_meta_tag() {
let result = validate_tag("rating:general");
assert!(result.is_valid);
assert!(!result.has_warnings());
}
#[test]
fn test_danbooru_specific_meta_tag() {
let result = validate_tag("pixiv_id:12345");
assert!(result.is_valid);
assert!(result.has_warnings());
assert!(matches!(
result.warnings.first(),
Some(TagWarning::UnsupportedMetaTag { .. })
));
}
#[test]
fn test_validate_tag_strict() {
assert!(validate_tag_strict("cat_ears").is_ok());
assert!(validate_tag_strict("").is_err());
let result = validate_tag_strict("cat ears").unwrap();
assert_eq!(result.as_ref(), "cat_ears");
}
}