use anyhow::{anyhow, Context, Result};
use colored::Colorize;
use serde::Serialize;
use std::path::{Path, PathBuf};
use tracing::{debug, info};
#[derive(Debug, Clone)]
pub struct ValidateOptions {
pub inputs: Vec<PathBuf>,
pub checks: Vec<ValidationCheck>,
pub strict: bool,
pub fix: bool,
pub json_output: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationCheck {
Format,
Codec,
Stream,
Corruption,
Metadata,
All,
}
impl ValidationCheck {
pub fn from_str(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"format" => Ok(Self::Format),
"codec" => Ok(Self::Codec),
"stream" => Ok(Self::Stream),
"corruption" => Ok(Self::Corruption),
"metadata" => Ok(Self::Metadata),
"all" => Ok(Self::All),
_ => Err(anyhow!("Unknown validation check: {}", s)),
}
}
pub fn name(&self) -> &'static str {
match self {
Self::Format => "Format",
Self::Codec => "Codec",
Self::Stream => "Stream",
Self::Corruption => "Corruption",
Self::Metadata => "Metadata",
Self::All => "All",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
#[allow(dead_code)]
pub enum IssueSeverity {
Info,
Warning,
Error,
Critical,
}
impl IssueSeverity {
pub fn color_string(&self, text: &str) -> String {
match self {
Self::Info => text.cyan().to_string(),
Self::Warning => text.yellow().to_string(),
Self::Error => text.red().to_string(),
Self::Critical => text.red().bold().to_string(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ValidationIssue {
pub severity: IssueSeverity,
pub check: String,
pub message: String,
pub location: Option<String>,
pub fixable: bool,
}
#[derive(Debug, Serialize)]
pub struct FileValidationResult {
pub file: String,
pub valid: bool,
pub issues: Vec<ValidationIssue>,
pub checks_performed: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct ValidationSummary {
pub total_files: usize,
pub valid_files: usize,
pub files_with_issues: usize,
pub total_issues: usize,
pub critical_issues: usize,
pub errors: usize,
pub warnings: usize,
pub results: Vec<FileValidationResult>,
}
pub async fn validate_files(options: ValidateOptions) -> Result<()> {
info!("Starting file validation");
debug!("Validation options: {:?}", options);
for input in &options.inputs {
if !input.exists() {
return Err(anyhow!("Input file does not exist: {}", input.display()));
}
}
if !options.json_output {
print_validation_plan(&options);
}
let results = validate_files_impl(&options).await?;
let summary = create_summary(results);
if options.json_output {
println!("{}", serde_json::to_string_pretty(&summary)?);
} else {
print_validation_summary(&summary);
}
if summary.critical_issues > 0 {
Err(anyhow!(
"Validation failed: {} critical issue(s) found",
summary.critical_issues
))
} else if options.strict && summary.errors > 0 {
Err(anyhow!(
"Validation failed: {} error(s) found (strict mode)",
summary.errors
))
} else {
Ok(())
}
}
fn print_validation_plan(options: &ValidateOptions) {
println!("{}", "Validation Plan".cyan().bold());
println!("{}", "=".repeat(60));
println!("{:20} {}", "Files:", options.inputs.len());
if options.inputs.len() <= 5 {
for (i, input) in options.inputs.iter().enumerate() {
println!(" {}. {}", i + 1, input.display());
}
} else {
println!(" 1. {}", options.inputs[0].display());
println!(" ... and {} more", options.inputs.len() - 1);
}
println!(
"{:20} {:?}",
"Checks:",
options.checks.iter().map(|c| c.name()).collect::<Vec<_>>()
);
if options.strict {
println!("{:20} {}", "Mode:", "Strict".yellow());
}
if options.fix {
println!("{:20} {}", "Auto-fix:", "Enabled".green());
}
println!("{}", "=".repeat(60));
println!();
}
async fn validate_files_impl(options: &ValidateOptions) -> Result<Vec<FileValidationResult>> {
let mut results = Vec::new();
for (i, input) in options.inputs.iter().enumerate() {
if !options.json_output {
println!(
"{} [{}/{}] Validating {}",
">>".cyan().bold(),
i + 1,
options.inputs.len(),
input.display()
);
}
let result = validate_single_file(input, options).await?;
if !options.json_output {
print_file_result(&result);
}
results.push(result);
}
Ok(results)
}
async fn validate_single_file(
path: &Path,
options: &ValidateOptions,
) -> Result<FileValidationResult> {
debug!("Validating file: {}", path.display());
let mut issues = Vec::new();
let mut checks_performed = Vec::new();
let checks = if options.checks.contains(&ValidationCheck::All) {
vec![
ValidationCheck::Format,
ValidationCheck::Codec,
ValidationCheck::Stream,
ValidationCheck::Corruption,
ValidationCheck::Metadata,
]
} else {
options.checks.clone()
};
for check in &checks {
checks_performed.push(check.name().to_string());
match check {
ValidationCheck::Format => {
check_format(path, &mut issues).await?;
}
ValidationCheck::Codec => {
check_codec(path, &mut issues).await?;
}
ValidationCheck::Stream => {
check_stream(path, &mut issues).await?;
}
ValidationCheck::Corruption => {
check_corruption(path, &mut issues).await?;
}
ValidationCheck::Metadata => {
check_metadata(path, &mut issues).await?;
}
ValidationCheck::All => {}
}
}
if options.fix {
fix_issues(path, &mut issues).await?;
}
let valid = issues.is_empty() || issues.iter().all(|i| i.severity < IssueSeverity::Error);
Ok(FileValidationResult {
file: path.display().to_string(),
valid,
issues,
checks_performed,
})
}
async fn check_format(path: &Path, issues: &mut Vec<ValidationIssue>) -> Result<()> {
debug!("Checking format for {}", path.display());
use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(path)
.await
.context("Failed to open file")?;
let mut buffer = vec![0u8; 4096];
let bytes_read = file
.read(&mut buffer)
.await
.context("Failed to read file")?;
buffer.truncate(bytes_read);
match oximedia_container::probe_format(&buffer) {
Ok(result) => {
if result.confidence < 0.8 {
issues.push(ValidationIssue {
severity: IssueSeverity::Warning,
check: "Format".to_string(),
message: format!("Low format confidence: {:.1}%", result.confidence * 100.0),
location: None,
fixable: false,
});
}
debug!(
"Format detected: {:?} (confidence: {:.1}%)",
result.format,
result.confidence * 100.0
);
}
Err(e) => {
issues.push(ValidationIssue {
severity: IssueSeverity::Error,
check: "Format".to_string(),
message: format!("Could not detect format: {}", e),
location: None,
fixable: false,
});
}
}
Ok(())
}
const PATENT_ENCUMBERED_EXTS: &[&str] = &["mp4", "m4v", "m4a", "mov", "avi", "wmv", "wma"];
const FREE_EXTS: &[&str] = &["webm", "mkv", "ogg", "ogv", "oga", "opus", "flac"];
async fn check_codec(path: &Path, issues: &mut Vec<ValidationIssue>) -> Result<()> {
debug!("Checking codec compliance for {}", path.display());
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if PATENT_ENCUMBERED_EXTS.contains(&ext.as_str()) {
issues.push(ValidationIssue {
severity: IssueSeverity::Warning,
check: "Codec".to_string(),
message: format!(
".{} container typically uses patent-encumbered codecs (e.g. H.264/AAC). \
Consider re-encoding to AV1/Opus in a WebM or MKV container.",
ext
),
location: Some("Container".to_string()),
fixable: true,
});
} else if FREE_EXTS.contains(&ext.as_str()) {
debug!("Container .{} uses patent-free codec family", ext);
} else if !ext.is_empty() {
issues.push(ValidationIssue {
severity: IssueSeverity::Info,
check: "Codec".to_string(),
message: format!(
"Unknown container extension '.{}'; codec compliance cannot be determined.",
ext
),
location: Some("Container".to_string()),
fixable: false,
});
}
Ok(())
}
const MIN_CONTAINER_HEADER_BYTES: u64 = 1024;
async fn check_stream(path: &Path, issues: &mut Vec<ValidationIssue>) -> Result<()> {
debug!("Checking stream integrity for {}", path.display());
use tokio::io::AsyncReadExt;
let fs_meta = tokio::fs::metadata(path)
.await
.context("Failed to read file metadata")?;
let file_size = fs_meta.len();
if file_size == 0 {
issues.push(ValidationIssue {
severity: IssueSeverity::Critical,
check: "Stream".to_string(),
message: "File is empty — no stream data present.".to_string(),
location: None,
fixable: false,
});
return Ok(());
}
if file_size < MIN_CONTAINER_HEADER_BYTES {
issues.push(ValidationIssue {
severity: IssueSeverity::Warning,
check: "Stream".to_string(),
message: format!(
"File is only {} bytes, which is smaller than the minimum expected for a \
valid media container ({} bytes).",
file_size, MIN_CONTAINER_HEADER_BYTES
),
location: None,
fixable: false,
});
}
let mut file = tokio::fs::File::open(path)
.await
.context("Failed to open file for stream check")?;
let mut head = vec![0u8; 512.min(file_size as usize)];
file.read_exact(&mut head)
.await
.context("Failed to read stream header")?;
let null_run = head.windows(32).any(|w| w.iter().all(|&b| b == 0));
if null_run {
issues.push(ValidationIssue {
severity: IssueSeverity::Warning,
check: "Stream".to_string(),
message: "Header region contains a long run of null bytes, which may indicate \
stream corruption or an incompletely written file."
.to_string(),
location: Some("File header".to_string()),
fixable: false,
});
}
Ok(())
}
async fn check_corruption(path: &Path, issues: &mut Vec<ValidationIssue>) -> Result<()> {
debug!("Checking for corruption in {}", path.display());
use tokio::io::AsyncReadExt;
let fs_meta = tokio::fs::metadata(path)
.await
.context("Failed to read file metadata for corruption check")?;
let file_size = fs_meta.len();
if file_size == 0 {
return Ok(());
}
let mut file = tokio::fs::File::open(path)
.await
.context("Failed to open file for corruption check")?;
let mut buffer = vec![0u8; 65536];
let mut total_bytes_read: u64 = 0;
let mut zero_chunk_count: u64 = 0;
let mut read_error: Option<String> = None;
loop {
match file.read(&mut buffer).await {
Ok(0) => break, Ok(n) => {
total_bytes_read += n as u64;
let chunk = &buffer[..n];
if chunk.iter().all(|&b| b == 0) {
zero_chunk_count += 1;
}
}
Err(e) => {
read_error = Some(e.to_string());
break;
}
}
}
if let Some(err) = read_error {
issues.push(ValidationIssue {
severity: IssueSeverity::Critical,
check: "Corruption".to_string(),
message: format!("I/O error while reading file: {}", err),
location: Some(format!("around byte offset {}", total_bytes_read)),
fixable: false,
});
return Ok(());
}
if total_bytes_read < file_size {
issues.push(ValidationIssue {
severity: IssueSeverity::Error,
check: "Corruption".to_string(),
message: format!(
"Only {} of {} bytes could be read — file may be truncated.",
total_bytes_read, file_size
),
location: None,
fixable: false,
});
}
if zero_chunk_count > 0 {
issues.push(ValidationIssue {
severity: IssueSeverity::Warning,
check: "Corruption".to_string(),
message: format!(
"{} zero-filled chunk(s) found; stream data may be missing or zeroed out.",
zero_chunk_count
),
location: None,
fixable: false,
});
}
debug!(
"Corruption check complete: read {} bytes, {} zero chunks",
total_bytes_read, zero_chunk_count
);
Ok(())
}
async fn check_metadata(path: &Path, issues: &mut Vec<ValidationIssue>) -> Result<()> {
debug!("Checking metadata for {}", path.display());
let mut sidecar = path.to_path_buf();
sidecar.set_extension("oxmeta");
if !sidecar.exists() {
issues.push(ValidationIssue {
severity: IssueSeverity::Info,
check: "Metadata".to_string(),
message: "No metadata sidecar (.oxmeta) found. \
Run `oximedia metadata --set` to embed metadata."
.to_string(),
location: None,
fixable: false,
});
return Ok(());
}
let json = match tokio::fs::read_to_string(&sidecar).await {
Ok(s) => s,
Err(e) => {
issues.push(ValidationIssue {
severity: IssueSeverity::Error,
check: "Metadata".to_string(),
message: format!("Failed to read metadata sidecar: {}", e),
location: Some(sidecar.display().to_string()),
fixable: false,
});
return Ok(());
}
};
let meta: serde_json::Value = match serde_json::from_str(&json) {
Ok(v) => v,
Err(e) => {
issues.push(ValidationIssue {
severity: IssueSeverity::Error,
check: "Metadata".to_string(),
message: format!("Metadata sidecar is not valid JSON: {}", e),
location: Some(sidecar.display().to_string()),
fixable: false,
});
return Ok(());
}
};
if let Some(year) = meta.get("year").and_then(|v| v.as_u64()) {
if year < 1888 || year > 2200 {
issues.push(ValidationIssue {
severity: IssueSeverity::Warning,
check: "Metadata".to_string(),
message: format!(
"Metadata year {} is outside the plausible range 1888–2200.",
year
),
location: Some("year".to_string()),
fixable: true,
});
}
}
if let Some(track) = meta.get("track_number").and_then(|v| v.as_u64()) {
if track == 0 || track > 999 {
issues.push(ValidationIssue {
severity: IssueSeverity::Warning,
check: "Metadata".to_string(),
message: format!(
"Track number {} is outside the expected range (1–999).",
track
),
location: Some("track_number".to_string()),
fixable: true,
});
}
}
debug!("Metadata sidecar validated successfully");
Ok(())
}
async fn fix_issues(path: &Path, issues: &mut Vec<ValidationIssue>) -> Result<()> {
let fixable_count = issues.iter().filter(|i| i.fixable).count();
if fixable_count == 0 {
return Ok(());
}
info!(
"Attempting to fix {} issue(s) in {}",
fixable_count,
path.display()
);
for issue in issues.iter_mut() {
if !issue.fixable {
continue;
}
match issue.check.as_str() {
"Codec" => {
issue.message = format!(
"{} (Auto-fix: run `oximedia transcode --video-codec av1` to convert.)",
issue.message
);
issue.fixable = false; info!(
"Codec issue noted; user action required for {}",
path.display()
);
}
"Metadata" => {
let mut sidecar = path.to_path_buf();
sidecar.set_extension("oxmeta");
if sidecar.exists() {
if let Ok(json) = tokio::fs::read_to_string(&sidecar).await {
if let Ok(mut meta) = serde_json::from_str::<serde_json::Value>(&json) {
let mut patched = false;
if let Some(y) = meta.get("year").and_then(|v| v.as_u64()) {
if y < 1888 || y > 2200 {
meta["year"] = serde_json::json!(1970u64);
patched = true;
}
}
if let Some(t) = meta.get("track_number").and_then(|v| v.as_u64()) {
if t == 0 || t > 999 {
meta["track_number"] = serde_json::json!(1u64);
patched = true;
}
}
if patched {
if let Ok(fixed_json) = serde_json::to_string_pretty(&meta) {
if tokio::fs::write(&sidecar, fixed_json).await.is_ok() {
issue.message = format!(
"{} (Auto-fixed: sidecar updated.)",
issue.message
);
issue.fixable = false;
info!("Metadata auto-fix applied to {}", sidecar.display());
}
}
}
}
}
}
}
_ => {
debug!("No automated fix for check '{}'", issue.check);
}
}
}
Ok(())
}
fn print_file_result(result: &FileValidationResult) {
if result.valid {
println!(" {} {}", "✓".green().bold(), "Valid".green());
} else {
println!(
" {} {} issue(s) found",
"✗".red().bold(),
result.issues.len()
);
for issue in &result.issues {
print_issue(issue);
}
}
println!();
}
fn print_issue(issue: &ValidationIssue) {
let severity_str = match issue.severity {
IssueSeverity::Info => "INFO",
IssueSeverity::Warning => "WARN",
IssueSeverity::Error => "ERROR",
IssueSeverity::Critical => "CRITICAL",
};
let colored_severity = issue.severity.color_string(severity_str);
let location_str = if let Some(ref loc) = issue.location {
format!(" [{}]", loc)
} else {
String::new()
};
println!(
" {} [{}]{}: {}",
colored_severity, issue.check, location_str, issue.message
);
}
fn create_summary(results: Vec<FileValidationResult>) -> ValidationSummary {
let total_files = results.len();
let valid_files = results.iter().filter(|r| r.valid).count();
let files_with_issues = total_files - valid_files;
let mut total_issues = 0;
let mut critical_issues = 0;
let mut errors = 0;
let mut warnings = 0;
for result in &results {
total_issues += result.issues.len();
for issue in &result.issues {
match issue.severity {
IssueSeverity::Critical => critical_issues += 1,
IssueSeverity::Error => errors += 1,
IssueSeverity::Warning => warnings += 1,
IssueSeverity::Info => {}
}
}
}
ValidationSummary {
total_files,
valid_files,
files_with_issues,
total_issues,
critical_issues,
errors,
warnings,
results,
}
}
fn print_validation_summary(summary: &ValidationSummary) {
println!("{}", "Validation Summary".green().bold());
println!("{}", "=".repeat(60));
println!("{:20} {}", "Total Files:", summary.total_files);
println!(
"{:20} {}",
"Valid Files:",
summary.valid_files.to_string().green()
);
println!(
"{:20} {}",
"Files with Issues:",
if summary.files_with_issues > 0 {
summary.files_with_issues.to_string().red()
} else {
summary.files_with_issues.to_string().normal()
}
);
println!();
if summary.total_issues > 0 {
println!("{}", "Issues Found:".yellow().bold());
if summary.critical_issues > 0 {
println!(" {} {}", "Critical:".red().bold(), summary.critical_issues);
}
if summary.errors > 0 {
println!(" {} {}", "Errors:".red(), summary.errors);
}
if summary.warnings > 0 {
println!(" {} {}", "Warnings:".yellow(), summary.warnings);
}
} else {
println!("{}", "No issues found!".green().bold());
}
println!("{}", "=".repeat(60));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validation_check_parsing() {
assert_eq!(
ValidationCheck::from_str("format").expect("ValidationCheck::from_str should succeed"),
ValidationCheck::Format
);
assert_eq!(
ValidationCheck::from_str("codec").expect("ValidationCheck::from_str should succeed"),
ValidationCheck::Codec
);
assert_eq!(
ValidationCheck::from_str("all").expect("ValidationCheck::from_str should succeed"),
ValidationCheck::All
);
assert!(ValidationCheck::from_str("invalid").is_err());
}
#[test]
fn test_issue_severity_ordering() {
assert!(IssueSeverity::Info < IssueSeverity::Warning);
assert!(IssueSeverity::Warning < IssueSeverity::Error);
assert!(IssueSeverity::Error < IssueSeverity::Critical);
}
}