#![allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ValidationRule {
MinDuration(u32),
MaxDuration(u32),
MinGap(u32),
MaxCharsPerLine(usize),
MaxLines(usize),
NonNegativeStart,
EndAfterStart,
}
impl ValidationRule {
pub fn rule_name(&self) -> &'static str {
match self {
ValidationRule::MinDuration(_) => "min_duration",
ValidationRule::MaxDuration(_) => "max_duration",
ValidationRule::MinGap(_) => "min_gap",
ValidationRule::MaxCharsPerLine(_) => "max_chars_per_line",
ValidationRule::MaxLines(_) => "max_lines",
ValidationRule::NonNegativeStart => "non_negative_start",
ValidationRule::EndAfterStart => "end_after_start",
}
}
}
#[derive(Debug, Clone)]
pub struct SubtitleViolation {
pub entry_index: usize,
pub rule: ValidationRule,
pub message: String,
}
impl SubtitleViolation {
pub fn new(entry_index: usize, rule: ValidationRule, message: impl Into<String>) -> Self {
Self {
entry_index,
rule,
message: message.into(),
}
}
pub fn is_timing_error(&self) -> bool {
matches!(
self.rule,
ValidationRule::MinDuration(_)
| ValidationRule::MaxDuration(_)
| ValidationRule::MinGap(_)
| ValidationRule::NonNegativeStart
| ValidationRule::EndAfterStart
)
}
}
#[derive(Debug, Clone)]
pub struct ValidatorEntry {
pub start_ms: i64,
pub end_ms: i64,
pub text: String,
}
impl ValidatorEntry {
pub fn new(start_ms: i64, end_ms: i64, text: impl Into<String>) -> Self {
Self {
start_ms,
end_ms,
text: text.into(),
}
}
pub fn duration_ms(&self) -> i64 {
self.end_ms - self.start_ms
}
pub fn max_line_length(&self) -> usize {
self.text.lines().map(|l| l.len()).max().unwrap_or(0)
}
pub fn line_count(&self) -> usize {
if self.text.is_empty() {
0
} else {
self.text.lines().count()
}
}
}
#[derive(Debug)]
pub struct SubtitleValidator {
rules: Vec<ValidationRule>,
}
impl SubtitleValidator {
pub fn new(rules: Vec<ValidationRule>) -> Self {
Self { rules }
}
pub fn broadcast_defaults() -> Self {
Self::new(vec![
ValidationRule::NonNegativeStart,
ValidationRule::EndAfterStart,
ValidationRule::MinDuration(500),
ValidationRule::MaxDuration(8000),
ValidationRule::MaxCharsPerLine(42),
ValidationRule::MaxLines(2),
ValidationRule::MinGap(40),
])
}
pub fn validate(&self, entries: &[ValidatorEntry]) -> SubtitleReport {
let mut violations = Vec::new();
for (idx, entry) in entries.iter().enumerate() {
for rule in &self.rules {
if let Some(v) = self.check_rule(idx, entry, rule) {
violations.push(v);
}
}
if idx > 0 {
for rule in &self.rules {
if let ValidationRule::MinGap(min_ms) = *rule {
let prev = &entries[idx - 1];
let gap = entry.start_ms - prev.end_ms;
if gap < i64::from(min_ms) {
violations.push(SubtitleViolation::new(
idx,
*rule,
format!(
"Gap {}ms between entries {} and {} is less than minimum {}ms",
gap,
idx - 1,
idx,
min_ms
),
));
}
}
}
}
}
SubtitleReport { violations }
}
fn check_rule(
&self,
idx: usize,
entry: &ValidatorEntry,
rule: &ValidationRule,
) -> Option<SubtitleViolation> {
match *rule {
ValidationRule::NonNegativeStart => {
if entry.start_ms < 0 {
Some(SubtitleViolation::new(
idx,
*rule,
format!("Entry {} has negative start time {}ms", idx, entry.start_ms),
))
} else {
None
}
}
ValidationRule::EndAfterStart => {
if entry.end_ms <= entry.start_ms {
Some(SubtitleViolation::new(
idx,
*rule,
format!(
"Entry {} end {}ms is not after start {}ms",
idx, entry.end_ms, entry.start_ms
),
))
} else {
None
}
}
ValidationRule::MinDuration(min_ms) => {
let dur = entry.duration_ms();
if dur < i64::from(min_ms) {
Some(SubtitleViolation::new(
idx,
*rule,
format!(
"Entry {} duration {}ms is less than minimum {}ms",
idx, dur, min_ms
),
))
} else {
None
}
}
ValidationRule::MaxDuration(max_ms) => {
let dur = entry.duration_ms();
if dur > i64::from(max_ms) {
Some(SubtitleViolation::new(
idx,
*rule,
format!(
"Entry {} duration {}ms exceeds maximum {}ms",
idx, dur, max_ms
),
))
} else {
None
}
}
ValidationRule::MaxCharsPerLine(max_chars) => {
let longest = entry.max_line_length();
if longest > max_chars {
Some(SubtitleViolation::new(
idx,
*rule,
format!(
"Entry {} has a line with {} characters (max {})",
idx, longest, max_chars
),
))
} else {
None
}
}
ValidationRule::MaxLines(max_lines) => {
let count = entry.line_count();
if count > max_lines {
Some(SubtitleViolation::new(
idx,
*rule,
format!("Entry {} has {} lines (max {})", idx, count, max_lines),
))
} else {
None
}
}
ValidationRule::MinGap(_) => None,
}
}
}
#[derive(Debug)]
pub struct SubtitleReport {
pub violations: Vec<SubtitleViolation>,
}
impl SubtitleReport {
pub fn violation_count(&self) -> usize {
self.violations.len()
}
pub fn error_count(&self) -> usize {
self.violations
.iter()
.filter(|v| v.is_timing_error())
.count()
}
pub fn is_clean(&self) -> bool {
self.violations.is_empty()
}
pub fn by_rule(&self) -> std::collections::HashMap<&'static str, Vec<&SubtitleViolation>> {
let mut map: std::collections::HashMap<&'static str, Vec<&SubtitleViolation>> =
std::collections::HashMap::new();
for v in &self.violations {
map.entry(v.rule.rule_name()).or_default().push(v);
}
map
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(start: i64, end: i64, text: &str) -> ValidatorEntry {
ValidatorEntry::new(start, end, text)
}
#[test]
fn test_validation_rule_name() {
assert_eq!(ValidationRule::MinDuration(500).rule_name(), "min_duration");
assert_eq!(
ValidationRule::MaxDuration(8000).rule_name(),
"max_duration"
);
assert_eq!(ValidationRule::MinGap(40).rule_name(), "min_gap");
assert_eq!(
ValidationRule::MaxCharsPerLine(42).rule_name(),
"max_chars_per_line"
);
assert_eq!(ValidationRule::MaxLines(2).rule_name(), "max_lines");
assert_eq!(
ValidationRule::NonNegativeStart.rule_name(),
"non_negative_start"
);
assert_eq!(ValidationRule::EndAfterStart.rule_name(), "end_after_start");
}
#[test]
fn test_subtitle_violation_is_timing_error_true() {
let v = SubtitleViolation::new(0, ValidationRule::EndAfterStart, "err");
assert!(v.is_timing_error());
}
#[test]
fn test_subtitle_violation_is_timing_error_false() {
let v = SubtitleViolation::new(0, ValidationRule::MaxCharsPerLine(42), "err");
assert!(!v.is_timing_error());
}
#[test]
fn test_validator_entry_duration_ms() {
let e = entry(1000, 4500, "Hello");
assert_eq!(e.duration_ms(), 3500);
}
#[test]
fn test_validator_entry_max_line_length() {
let e = entry(0, 1000, "Short\nA very long line indeed");
assert_eq!(e.max_line_length(), 23);
}
#[test]
fn test_validator_entry_line_count() {
let e = entry(0, 1000, "Line one\nLine two");
assert_eq!(e.line_count(), 2);
}
#[test]
fn test_validator_entry_empty_text_line_count() {
let e = entry(0, 1000, "");
assert_eq!(e.line_count(), 0);
}
#[test]
fn test_validate_clean_entries() {
let validator = SubtitleValidator::broadcast_defaults();
let entries = vec![
entry(0, 2000, "Hello world"),
entry(3000, 5000, "Second line"),
];
let report = validator.validate(&entries);
assert!(
report.is_clean(),
"Expected no violations: {:?}",
report.violations
);
}
#[test]
fn test_validate_end_before_start() {
let validator = SubtitleValidator::new(vec![ValidationRule::EndAfterStart]);
let entries = vec![entry(5000, 3000, "Bad timing")];
let report = validator.validate(&entries);
assert_eq!(report.violation_count(), 1);
assert!(report.violations[0].is_timing_error());
}
#[test]
fn test_validate_negative_start() {
let validator = SubtitleValidator::new(vec![ValidationRule::NonNegativeStart]);
let entries = vec![entry(-100, 1000, "Negative")];
let report = validator.validate(&entries);
assert_eq!(report.violation_count(), 1);
}
#[test]
fn test_validate_min_duration() {
let validator = SubtitleValidator::new(vec![
ValidationRule::EndAfterStart,
ValidationRule::MinDuration(1000),
]);
let entries = vec![entry(0, 200, "Too short")];
let report = validator.validate(&entries);
assert_eq!(report.error_count(), 1);
}
#[test]
fn test_validate_max_duration() {
let validator = SubtitleValidator::new(vec![ValidationRule::MaxDuration(3000)]);
let entries = vec![entry(0, 10000, "Too long")];
let report = validator.validate(&entries);
assert_eq!(report.violation_count(), 1);
}
#[test]
fn test_validate_max_chars_per_line() {
let validator = SubtitleValidator::new(vec![ValidationRule::MaxCharsPerLine(10)]);
let entries = vec![entry(0, 2000, "This line is too long for the rule")];
let report = validator.validate(&entries);
assert_eq!(report.violation_count(), 1);
assert!(!report.violations[0].is_timing_error());
}
#[test]
fn test_validate_max_lines() {
let validator = SubtitleValidator::new(vec![ValidationRule::MaxLines(2)]);
let entries = vec![entry(0, 2000, "Line 1\nLine 2\nLine 3")];
let report = validator.validate(&entries);
assert_eq!(report.violation_count(), 1);
}
#[test]
fn test_validate_min_gap() {
let validator = SubtitleValidator::new(vec![ValidationRule::MinGap(500)]);
let entries = vec![
entry(0, 2000, "A"),
entry(2100, 4000, "B"), ];
let report = validator.validate(&entries);
assert_eq!(report.violation_count(), 1);
}
#[test]
fn test_report_error_count() {
let validator = SubtitleValidator::new(vec![
ValidationRule::EndAfterStart,
ValidationRule::MaxCharsPerLine(5),
]);
let entries = vec![entry(5000, 3000, "Too many chars here")];
let report = validator.validate(&entries);
assert_eq!(report.error_count(), 1);
assert_eq!(report.violation_count(), 2);
}
#[test]
fn test_report_by_rule() {
let validator = SubtitleValidator::new(vec![
ValidationRule::MaxCharsPerLine(5),
ValidationRule::MaxLines(1),
]);
let entries = vec![entry(0, 2000, "Line 1 is very long\nLine 2")];
let report = validator.validate(&entries);
let by_rule = report.by_rule();
assert!(by_rule.contains_key("max_chars_per_line"));
assert!(by_rule.contains_key("max_lines"));
}
}