use serde::Serialize;
use utoipa::ToSchema;
use crate::models::{Course, CourseInstance};
#[derive(Debug, Clone, Serialize, PartialEq, Eq, ToSchema)]
pub struct ValidationError {
pub field: String,
pub message: String,
}
impl ValidationError {
fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
Self { field: field.into(), message: message.into() }
}
}
const COURSE_CODE_MAX: usize = 100;
const BCP47_MIN: usize = 2;
const BCP47_MAX: usize = 35;
const CREDITS_MAX: u32 = 10_000;
pub fn validate_course(c: &Course) -> Vec<ValidationError> {
let mut errs = Vec::new();
if c.name.trim().is_empty() {
errs.push(ValidationError::new("name", "name is required and must be non-empty"));
}
if let Some(code) = c.course_code.as_deref() {
let len = code.chars().count();
if len == 0 || len > COURSE_CODE_MAX {
errs.push(ValidationError::new(
"course_code",
format!("course_code must be 1..={COURSE_CODE_MAX} characters"),
));
}
}
if let Some(n) = c.number_of_credits {
if n > CREDITS_MAX {
errs.push(ValidationError::new(
"number_of_credits",
format!("number_of_credits must be ≤ {CREDITS_MAX}"),
));
}
}
for (i, code) in c.in_language.iter().enumerate() {
if !is_plausible_bcp47(code) {
errs.push(ValidationError::new(
format!("in_language[{i}]"),
format!("'{code}' is not a plausible BCP-47 language code"),
));
}
}
for (i, code) in c.available_language.iter().enumerate() {
if !is_plausible_bcp47(code) {
errs.push(ValidationError::new(
format!("available_language[{i}]"),
format!("'{code}' is not a plausible BCP-47 language code"),
));
}
}
if let Some(url) = c.url.as_deref() {
if !is_http_url(url) {
errs.push(ValidationError::new("url", "url must start with http:// or https://"));
}
}
for (i, u) in c.image.iter().enumerate() {
if !is_http_url(u) {
errs.push(ValidationError::new(
format!("image[{i}]"),
"image url must start with http:// or https://",
));
}
}
for (i, u) in c.same_as.iter().enumerate() {
if !is_http_url(u) {
errs.push(ValidationError::new(
format!("same_as[{i}]"),
"same_as url must start with http:// or https://",
));
}
}
for (i, ident) in c.identifiers.iter().enumerate() {
if let Some(u) = ident.url.as_deref() {
if !is_http_url(u) {
errs.push(ValidationError::new(
format!("identifiers[{i}].url"),
"identifier url must start with http:// or https://",
));
}
}
}
for (i, inst) in c.instances.iter().enumerate() {
for mut e in validate_instance(inst) {
e.field = format!("instances[{i}].{}", e.field);
errs.push(e);
}
}
errs
}
pub fn validate_instance(inst: &CourseInstance) -> Vec<ValidationError> {
let mut errs = Vec::new();
for (i, code) in inst.in_language.iter().enumerate() {
if !is_plausible_bcp47(code) {
errs.push(ValidationError::new(
format!("in_language[{i}]"),
format!("'{code}' is not a plausible BCP-47 language code"),
));
}
}
if let Some(sched) = inst.schedule.as_ref() {
if let (Some(start), Some(end)) = (sched.start_date, sched.end_date) {
if end < start {
errs.push(ValidationError::new(
"schedule.end_date",
"end_date must be on or after start_date",
));
}
}
}
if let (Some(opens), Some(closes)) = (inst.enrollment_opens, inst.enrollment_closes) {
if closes < opens {
errs.push(ValidationError::new(
"enrollment_closes",
"enrollment_closes must be on or after enrollment_opens",
));
}
}
if let (Some(max), Some(enrolled)) = (inst.maximum_attendee_capacity, inst.enrolled_count) {
if max < enrolled {
errs.push(ValidationError::new(
"maximum_attendee_capacity",
"maximum_attendee_capacity must be ≥ enrolled_count",
));
}
}
errs
}
fn is_http_url(s: &str) -> bool {
let lower = s.trim().to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
fn is_plausible_bcp47(s: &str) -> bool {
let len = s.len();
if len < BCP47_MIN || len > BCP47_MAX {
return false;
}
let bytes = s.as_bytes();
if bytes[0] == b'-' || bytes[len - 1] == b'-' {
return false;
}
s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, Utc};
use crate::models::{CourseIdentifier, IdentifierType, Schedule};
fn valid_course() -> Course {
let mut c = Course::new("Intro to CS");
c.course_code = Some("CS101".into());
c.url = Some("https://example.edu/cs101".into());
c.in_language = vec!["en".into(), "en-GB".into()];
c
}
#[test]
fn valid_course_has_no_errors() {
assert!(validate_course(&valid_course()).is_empty());
}
#[test]
fn blank_name_is_rejected() {
let mut c = valid_course();
c.name = " ".into();
let errs = validate_course(&c);
assert!(errs.iter().any(|e| e.field == "name"));
}
#[test]
fn over_length_course_code_is_rejected() {
let mut c = valid_course();
c.course_code = Some("X".repeat(101));
let errs = validate_course(&c);
assert!(errs.iter().any(|e| e.field == "course_code"));
}
#[test]
fn empty_course_code_is_rejected() {
let mut c = valid_course();
c.course_code = Some(String::new());
let errs = validate_course(&c);
assert!(errs.iter().any(|e| e.field == "course_code"));
}
#[test]
fn non_http_url_is_rejected() {
let mut c = valid_course();
c.url = Some("ftp://example.edu".into());
let errs = validate_course(&c);
assert!(errs.iter().any(|e| e.field == "url"));
}
#[test]
fn identifier_url_must_be_http() {
let mut c = valid_course();
c.identifiers.push(CourseIdentifier {
property_id: IdentifierType::Doi,
value: "10.1/x".into(),
name: None,
url: Some("javascript:alert(1)".into()),
});
let errs = validate_course(&c);
assert!(errs.iter().any(|e| e.field == "identifiers[0].url"));
}
#[test]
fn implausible_language_code_is_rejected() {
let mut c = valid_course();
c.in_language = vec!["E".into(), "english".into(), "-en".into()];
let errs = validate_course(&c);
assert_eq!(
errs.iter().filter(|e| e.field.starts_with("in_language")).count(),
2,
"expected 2 in_language errors (single-char and leading-hyphen), got {errs:?}"
);
}
#[test]
fn schedule_end_before_start_is_rejected() {
let mut inst = CourseInstance {
id: uuid::Uuid::new_v4(),
course_id: uuid::Uuid::new_v4(),
name: None,
course_mode: None,
status: Default::default(),
schedule: None,
in_language: vec![],
location: None,
location_id: None,
instructor_ids: vec![],
instructor_names: vec![],
maximum_attendee_capacity: None,
enrolled_count: None,
enrollment_opens: None,
enrollment_closes: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let start = Utc::now();
let end = start - Duration::days(7);
inst.schedule = Some(Schedule {
start_date: Some(start),
end_date: Some(end),
time_zone: None,
recurrence: None,
sessions: vec![],
});
let errs = validate_instance(&inst);
assert!(errs.iter().any(|e| e.field == "schedule.end_date"));
}
#[test]
fn enrollment_window_must_be_ordered() {
let inst = CourseInstance {
id: uuid::Uuid::new_v4(),
course_id: uuid::Uuid::new_v4(),
name: None,
course_mode: None,
status: Default::default(),
schedule: None,
in_language: vec![],
location: None,
location_id: None,
instructor_ids: vec![],
instructor_names: vec![],
maximum_attendee_capacity: None,
enrolled_count: None,
enrollment_opens: Some(Utc::now()),
enrollment_closes: Some(Utc::now() - Duration::days(1)),
created_at: Utc::now(),
updated_at: Utc::now(),
};
let errs = validate_instance(&inst);
assert!(errs.iter().any(|e| e.field == "enrollment_closes"));
}
#[test]
fn enrolled_cannot_exceed_capacity() {
let inst = CourseInstance {
id: uuid::Uuid::new_v4(),
course_id: uuid::Uuid::new_v4(),
name: None,
course_mode: None,
status: Default::default(),
schedule: None,
in_language: vec![],
location: None,
location_id: None,
instructor_ids: vec![],
instructor_names: vec![],
maximum_attendee_capacity: Some(30),
enrolled_count: Some(31),
enrollment_opens: None,
enrollment_closes: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let errs = validate_instance(&inst);
assert!(errs.iter().any(|e| e.field == "maximum_attendee_capacity"));
}
#[test]
fn nested_instance_errors_are_path_prefixed() {
let mut c = valid_course();
c.instances.push(CourseInstance {
id: uuid::Uuid::new_v4(),
course_id: c.id,
name: None,
course_mode: None,
status: Default::default(),
schedule: None,
in_language: vec![],
location: None,
location_id: None,
instructor_ids: vec![],
instructor_names: vec![],
maximum_attendee_capacity: Some(10),
enrolled_count: Some(20),
enrollment_opens: None,
enrollment_closes: None,
created_at: Utc::now(),
updated_at: Utc::now(),
});
let errs = validate_course(&c);
assert!(errs.iter().any(|e| e.field == "instances[0].maximum_attendee_capacity"));
}
}