Add ruleset packs, linter, fixtures, and JSON schema
This commit is contained in:
@@ -4,4 +4,4 @@ mod types;
|
||||
|
||||
pub use json::render_json;
|
||||
pub use text::{render_fix_line, render_scan_line, render_summary};
|
||||
pub use types::{Report, ScanReport};
|
||||
pub use types::{FixJsonReport, Report, ScanJsonReport, ScanReport, SCHEMA_VERSION};
|
||||
|
||||
@@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::fix::FixOutcome;
|
||||
use crate::scan::ScanOutcome;
|
||||
|
||||
pub const SCHEMA_VERSION: &str = "1.0";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScanReport {
|
||||
pub scan: ScanOutcome,
|
||||
@@ -13,3 +15,16 @@ pub struct Report {
|
||||
pub scan: ScanOutcome,
|
||||
pub fix: Option<FixOutcome>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScanJsonReport {
|
||||
pub schema_version: String,
|
||||
pub scans: Vec<ScanOutcome>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FixJsonReport {
|
||||
pub schema_version: String,
|
||||
pub scans: Vec<ScanOutcome>,
|
||||
pub fixes: Vec<FixOutcome>,
|
||||
}
|
||||
|
||||
113
vid-repair-core/src/rules/lint.rs
Normal file
113
vid-repair-core/src/rules/lint.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use super::model::{FixTier, Rule};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct LintReport {
|
||||
pub errors: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl LintReport {
|
||||
pub fn has_errors(&self) -> bool {
|
||||
!self.errors.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lint_rules(rules: &[Rule]) -> LintReport {
|
||||
let mut report = LintReport::default();
|
||||
let mut ids = HashSet::new();
|
||||
let mut pattern_map: HashMap<String, String> = HashMap::new();
|
||||
|
||||
for rule in rules {
|
||||
if rule.id.trim().is_empty() {
|
||||
report.errors.push("Rule id is empty".to_string());
|
||||
}
|
||||
if !ids.insert(rule.id.clone()) {
|
||||
report
|
||||
.errors
|
||||
.push(format!("Duplicate rule id: {}", rule.id));
|
||||
}
|
||||
if rule.domain.trim().is_empty() {
|
||||
report
|
||||
.errors
|
||||
.push(format!("Rule {} has empty domain", rule.id));
|
||||
}
|
||||
if rule.patterns.is_empty() {
|
||||
report
|
||||
.errors
|
||||
.push(format!("Rule {} has no patterns", rule.id));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&rule.confidence) {
|
||||
report.errors.push(format!(
|
||||
"Rule {} has invalid confidence {}",
|
||||
rule.id, rule.confidence
|
||||
));
|
||||
}
|
||||
if rule.stop_scan && rule.fix_tier != FixTier::Reencode {
|
||||
report.errors.push(format!(
|
||||
"Rule {} has stop_scan=true but fix_tier is {:?}",
|
||||
rule.id, rule.fix_tier
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(action) = &rule.action {
|
||||
if action.eq_ignore_ascii_case("faststart") && rule.fix_tier == FixTier::Reencode {
|
||||
report.warnings.push(format!(
|
||||
"Rule {} uses faststart action but fix_tier is reencode",
|
||||
rule.id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for pattern in &rule.patterns {
|
||||
if let Err(err) = Regex::new(pattern) {
|
||||
report.errors.push(format!(
|
||||
"Rule {} has invalid regex '{}': {}",
|
||||
rule.id, pattern, err
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(existing) = pattern_map.get(pattern) {
|
||||
if existing != &rule.id {
|
||||
report.warnings.push(format!(
|
||||
"Pattern '{}' appears in rules {} and {}",
|
||||
pattern, existing, rule.id
|
||||
));
|
||||
}
|
||||
} else {
|
||||
pattern_map.insert(pattern.clone(), rule.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::rules::model::{FixTier, Rule, Severity};
|
||||
|
||||
#[test]
|
||||
fn detects_duplicate_ids() {
|
||||
let rule = Rule {
|
||||
id: "DUP".to_string(),
|
||||
domain: "test".to_string(),
|
||||
severity: Severity::Low,
|
||||
confidence: 0.5,
|
||||
fix_tier: FixTier::None,
|
||||
stop_scan: false,
|
||||
patterns: vec!["foo".to_string()],
|
||||
notes: None,
|
||||
action: None,
|
||||
requires: vec![],
|
||||
excludes: vec![],
|
||||
};
|
||||
|
||||
let report = lint_rules(&[rule.clone(), rule]);
|
||||
assert!(report.has_errors());
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,13 @@ use anyhow::Result;
|
||||
use crate::scan::ProbeData;
|
||||
|
||||
mod loader;
|
||||
mod lint;
|
||||
mod matcher;
|
||||
mod model;
|
||||
|
||||
use model::Rule;
|
||||
|
||||
pub use lint::{lint_rules, LintReport};
|
||||
pub use matcher::{RuleContext, RuleMatch};
|
||||
pub use model::{FixTier, Severity};
|
||||
|
||||
@@ -41,6 +45,12 @@ impl RuleSet {
|
||||
Ok(Self { rules: Vec::new() })
|
||||
}
|
||||
|
||||
pub fn load_from_dir(dir: &std::path::Path) -> Result<Self> {
|
||||
let rules = loader::load_rules_from_dir(dir)?;
|
||||
let compiled = loader::compile_rules(rules)?;
|
||||
Ok(Self { rules: compiled })
|
||||
}
|
||||
|
||||
pub fn match_lines(&self, lines: &[String], context: &RuleContext) -> Vec<RuleMatch> {
|
||||
let mut matches = Vec::new();
|
||||
for rule in &self.rules {
|
||||
@@ -61,6 +71,29 @@ impl RuleSet {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_raw_rules() -> Result<Vec<Rule>> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Ok(current) = std::env::current_dir() {
|
||||
candidates.push(current.join("rulesets"));
|
||||
}
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(parent) = exe.parent() {
|
||||
candidates.push(parent.join("rulesets"));
|
||||
}
|
||||
}
|
||||
|
||||
for dir in candidates {
|
||||
let rules = loader::load_rules_from_dir(&dir)?;
|
||||
if !rules.is_empty() {
|
||||
return Ok(rules);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn build_context(probe: &ProbeData) -> RuleContext {
|
||||
let mut context = RuleContext::default();
|
||||
|
||||
|
||||
76
vid-repair-core/tests/fixtures.rs
Normal file
76
vid-repair-core/tests/fixtures.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
use vid_repair_core::config::Config;
|
||||
use vid_repair_core::rules::RuleSet;
|
||||
use vid_repair_core::scan::scan_file;
|
||||
|
||||
fn command_available(cmd: &str) -> bool {
|
||||
Command::new(cmd)
|
||||
.arg("-version")
|
||||
.output()
|
||||
.map(|out| out.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn ruleset_dir() -> PathBuf {
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
manifest_dir
|
||||
.parent()
|
||||
.expect("workspace root")
|
||||
.join("rulesets")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_clean_fixture_has_no_issues() {
|
||||
if !command_available("ffmpeg") || !command_available("ffprobe") {
|
||||
eprintln!("ffmpeg/ffprobe not available; skipping fixture test");
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let output = dir.path().join("clean.mp4");
|
||||
|
||||
let status = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc=size=128x72:rate=30",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=1000:sample_rate=44100",
|
||||
"-t",
|
||||
"1",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
output.to_str().unwrap(),
|
||||
])
|
||||
.status()
|
||||
.expect("ffmpeg run");
|
||||
|
||||
if !status.success() {
|
||||
eprintln!("ffmpeg failed to create fixture");
|
||||
return;
|
||||
}
|
||||
|
||||
let config = Config::default();
|
||||
let ruleset = RuleSet::load_from_dir(&ruleset_dir()).expect("ruleset load");
|
||||
|
||||
let scan = scan_file(&output, &config, &ruleset).expect("scan file");
|
||||
|
||||
assert!(scan.issues.is_empty(), "Expected no issues, got {}", scan.issues.len());
|
||||
}
|
||||
Reference in New Issue
Block a user