use crate::error::{Error, Result};
const MAX_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
pub(crate) fn max_response_bytes() -> u64 {
MAX_RESPONSE_BYTES
}
fn validate_olid(s: &str, suffix: char) -> Result<()> {
if !s.starts_with("OL") {
return Err(Error::InvalidInput(format!(
"OLID must start with 'OL', got: {s}"
)));
}
if !s.ends_with(suffix) {
return Err(Error::InvalidInput(format!(
"OLID must end with '{suffix}', got: {s}"
)));
}
let middle = &s[2..s.len() - 1];
if middle.is_empty() || !middle.chars().all(|c| c.is_ascii_digit()) {
return Err(Error::InvalidInput(format!(
"OLID middle section must be all digits, got: {s}"
)));
}
Ok(())
}
pub fn validate_work_id(id: &str) -> Result<()> {
validate_olid(id, 'W')
}
pub fn validate_edition_id(id: &str) -> Result<()> {
validate_olid(id, 'M')
}
pub fn validate_author_id(id: &str) -> Result<()> {
validate_olid(id, 'A')
}
pub fn validate_isbn(isbn: &str) -> Result<()> {
let digits: String = isbn.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
match digits.len() {
10 => validate_isbn10(&digits),
13 => validate_isbn13(&digits),
_ => Err(Error::InvalidInput(format!(
"ISBN must be 10 or 13 characters, got {} characters: {isbn}",
digits.len()
))),
}
}
fn validate_isbn10(s: &str) -> Result<()> {
let bytes = s.as_bytes();
let mut sum: u32 = 0;
for (i, &b) in bytes.iter().enumerate() {
let digit = if i == 9 && (b == b'X' || b == b'x') {
10
} else if b.is_ascii_digit() {
(b - b'0') as u32
} else {
return Err(Error::InvalidInput(format!(
"invalid ISBN-10 character '{}'",
b as char
)));
};
sum += digit * (10 - i as u32);
}
if !sum.is_multiple_of(11) {
return Err(Error::InvalidInput(format!(
"invalid ISBN-10 check digit: {s}"
)));
}
Ok(())
}
fn validate_isbn13(s: &str) -> Result<()> {
if !s.starts_with("978") && !s.starts_with("979") {
return Err(Error::InvalidInput(format!(
"ISBN-13 must start with 978 or 979: {s}"
)));
}
let bytes = s.as_bytes();
let mut sum: u32 = 0;
for (i, &b) in bytes.iter().enumerate() {
if !b.is_ascii_digit() {
return Err(Error::InvalidInput(format!(
"invalid ISBN-13 character '{}' at position {i}",
b as char
)));
}
let digit = (b - b'0') as u32;
sum += digit * if i % 2 == 0 { 1 } else { 3 };
}
if !sum.is_multiple_of(10) {
return Err(Error::InvalidInput(format!(
"invalid ISBN-13 check digit: {s}"
)));
}
Ok(())
}
pub fn validate_subject_slug(slug: &str) -> Result<()> {
if slug.is_empty() {
return Err(Error::InvalidInput("subject slug must not be empty".into()));
}
if slug.len() > 200 {
return Err(Error::InvalidInput(format!(
"subject slug must be ≤ 200 chars, got {}",
slug.len()
)));
}
if !slug
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
return Err(Error::InvalidInput(format!(
"subject slug must match [a-z0-9_]+: {slug}"
)));
}
Ok(())
}
pub fn validate_username(username: &str) -> Result<()> {
if username.is_empty() {
return Err(Error::InvalidInput("username must not be empty".into()));
}
if username.len() > 64 {
return Err(Error::InvalidInput(format!(
"username must be ≤ 64 chars, got {}",
username.len()
)));
}
if !username
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(Error::InvalidInput(format!(
"username must match [a-zA-Z0-9_-]+: {username}"
)));
}
Ok(())
}
pub fn validate_list_id(id: &str) -> Result<()> {
if id.is_empty() {
return Err(Error::InvalidInput("list ID must not be empty".into()));
}
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(Error::InvalidInput(format!(
"list ID must match [a-zA-Z0-9_-]+: {id}"
)));
}
Ok(())
}
pub fn validate_search_query(q: &str) -> Result<()> {
if q.is_empty() {
return Err(Error::InvalidInput(
"search query must not be empty".into(),
));
}
if q.len() > 1000 {
return Err(Error::InvalidInput(format!(
"search query must be ≤ 1000 chars, got {}",
q.len()
)));
}
Ok(())
}
pub fn validate_limit(limit: u32) -> Result<()> {
if limit == 0 || limit > 1000 {
return Err(Error::InvalidInput(format!(
"limit must be 1–1000, got {limit}"
)));
}
Ok(())
}
const VALID_BIBKEY_PREFIXES: &[&str] = &["ISBN:", "OCLC:", "LCCN:", "OLID:", "ID:"];
pub fn validate_bibkey(key: &str) -> Result<()> {
if key.is_empty() {
return Err(Error::InvalidInput("bibkey must not be empty".into()));
}
if !VALID_BIBKEY_PREFIXES
.iter()
.any(|prefix| key.starts_with(prefix))
{
return Err(Error::InvalidInput(format!(
"bibkey must start with one of {:?}: {key}",
VALID_BIBKEY_PREFIXES
)));
}
let value = key.split_once(':').map(|x| x.1).unwrap_or("");
if value.is_empty() {
return Err(Error::InvalidInput(format!(
"bibkey value after ':' must not be empty: {key}"
)));
}
Ok(())
}
pub fn validate_bibkeys(keys: &[String]) -> Result<()> {
if keys.is_empty() {
return Err(Error::InvalidInput(
"bibkeys list must not be empty".into(),
));
}
for key in keys {
validate_bibkey(key)?;
}
Ok(())
}
pub fn validate_date(date: &str) -> Result<()> {
let parts: Vec<&str> = date.split('-').collect();
if parts.len() != 3 {
return Err(Error::InvalidInput(format!(
"date must be YYYY-MM-DD, got: {date}"
)));
}
let year: u32 = parts[0]
.parse()
.map_err(|_| Error::InvalidInput(format!("invalid year in date: {date}")))?;
let month: u32 = parts[1]
.parse()
.map_err(|_| Error::InvalidInput(format!("invalid month in date: {date}")))?;
let day: u32 = parts[2]
.parse()
.map_err(|_| Error::InvalidInput(format!("invalid day in date: {date}")))?;
if year < 1 || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return Err(Error::InvalidInput(format!(
"date out of range: {date}"
)));
}
Ok(())
}
pub fn validate_contact_email(email: &str) -> Result<()> {
if email.is_empty() {
return Err(Error::InvalidInput(
"contact email must not be empty".into(),
));
}
if !email.contains('@') || email.starts_with('@') || email.ends_with('@') {
return Err(Error::InvalidInput(format!(
"contact email does not look valid: {email}"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn work_id_valid() {
assert!(validate_work_id("OL45804W").is_ok());
assert!(validate_work_id("OL1W").is_ok());
}
#[test]
fn work_id_wrong_suffix() {
assert!(validate_work_id("OL45804M").is_err());
assert!(validate_work_id("OL45804A").is_err());
}
#[test]
fn work_id_no_prefix() {
assert!(validate_work_id("45804W").is_err());
}
#[test]
fn work_id_non_digit_middle() {
assert!(validate_work_id("OLabcW").is_err());
}
#[test]
fn edition_id_valid() {
assert!(validate_edition_id("OL7353617M").is_ok());
}
#[test]
fn author_id_valid() {
assert!(validate_author_id("OL23919A").is_ok());
}
#[test]
fn isbn10_valid() {
assert!(validate_isbn("0306406152").is_ok());
assert!(validate_isbn("080442957X").is_ok());
}
#[test]
fn isbn10_bad_check_digit() {
assert!(validate_isbn("0306406151").is_err());
}
#[test]
fn isbn13_valid() {
assert!(validate_isbn("9780306406157").is_ok());
}
#[test]
fn isbn13_bad_prefix() {
assert!(validate_isbn("1230306406157").is_err());
}
#[test]
fn isbn13_bad_check_digit() {
assert!(validate_isbn("9780306406158").is_err());
}
#[test]
fn subject_slug_valid() {
assert!(validate_subject_slug("love").is_ok());
assert!(validate_subject_slug("science_fiction").is_ok());
assert!(validate_subject_slug("history2024").is_ok());
}
#[test]
fn subject_slug_uppercase_rejected() {
assert!(validate_subject_slug("Love").is_err());
}
#[test]
fn subject_slug_empty_rejected() {
assert!(validate_subject_slug("").is_err());
}
#[test]
fn username_valid() {
assert!(validate_username("alice").is_ok());
assert!(validate_username("alice_123-ok").is_ok());
}
#[test]
fn username_empty_rejected() {
assert!(validate_username("").is_err());
}
#[test]
fn username_too_long_rejected() {
let long = "a".repeat(65);
assert!(validate_username(&long).is_err());
}
#[test]
fn limit_valid() {
assert!(validate_limit(1).is_ok());
assert!(validate_limit(1000).is_ok());
}
#[test]
fn limit_zero_rejected() {
assert!(validate_limit(0).is_err());
}
#[test]
fn limit_over_max_rejected() {
assert!(validate_limit(1001).is_err());
}
#[test]
fn bibkey_valid_prefixes() {
assert!(validate_bibkey("ISBN:0306406152").is_ok());
assert!(validate_bibkey("OCLC:45883427").is_ok());
assert!(validate_bibkey("LCCN:2004046975").is_ok());
assert!(validate_bibkey("OLID:OL7408846M").is_ok());
assert!(validate_bibkey("ID:5428012").is_ok());
}
#[test]
fn bibkey_unknown_prefix_rejected() {
assert!(validate_bibkey("ASIN:B000FC1234").is_err());
}
#[test]
fn bibkeys_empty_list_rejected() {
assert!(validate_bibkeys(&[]).is_err());
}
#[test]
fn date_valid() {
assert!(validate_date("2024-06-15").is_ok());
}
#[test]
fn date_invalid_format() {
assert!(validate_date("20240615").is_err());
assert!(validate_date("2024/06/15").is_err());
}
#[test]
fn email_valid() {
assert!(validate_contact_email("me@example.com").is_ok());
}
#[test]
fn email_no_at_rejected() {
assert!(validate_contact_email("notanemail").is_err());
}
}