Skip to main content

wowlab_engine_application/audit/
mod.rs

1//! Shared manifest audit: validates spec manifest IDs and literals against game data.
2
3mod checks;
4mod data_values;
5mod generic_folds;
6
7use checks::{audit_auras, audit_auto_attacks, audit_hero_talents, audit_spells, audit_talents};
8use data_values::check_data_values;
9use generic_folds::check_duplicate_generic_folds;
10use wowlab_engine_ports::DynDataResolver;
11use wowlab_manifest_schema::{
12    Manifest, ManifestDiagnosticLocation, ManifestLoadError, ManifestRepository,
13};
14
15/// One audit finding for a single manifest ID or reference.
16#[derive(Debug)]
17pub struct AuditIssue {
18    pub category: &'static str,
19    pub name: String,
20    pub id: u32,
21    pub message: String,
22}
23
24#[derive(Debug, Default)]
25struct AuditSink {
26    errors: Vec<AuditIssue>,
27    warnings: Vec<Box<str>>,
28}
29
30struct AuditCtx<'a> {
31    manifest: &'a Manifest,
32    resolver: &'a DynDataResolver<'a>,
33    sink: &'a mut AuditSink,
34}
35
36impl AuditSink {
37    fn error(&mut self, issue: AuditIssue) {
38        self.errors.push(issue);
39    }
40
41    fn warning(&mut self, message: String) {
42        self.warnings.push(message.into_boxed_str());
43    }
44
45    fn warning_count(&self) -> usize {
46        self.warnings.len()
47    }
48}
49
50/// Full audit result for one spec manifest.
51#[derive(Debug)]
52pub struct SpecAudit {
53    pub display_name: String,
54    pub spec_id: u32,
55    pub errors: Vec<AuditIssue>,
56    pub warnings: Vec<String>,
57    pub spell_count: usize,
58    pub aura_count: usize,
59    pub talent_count: usize,
60    pub hero_spell_count: usize,
61    pub hero_aura_count: usize,
62}
63
64/// Loads and audits every spec in a canonical manifest repository.
65pub async fn audit_manifest_repository(
66    repository: &ManifestRepository,
67    resolver: &DynDataResolver<'_>,
68) -> Result<Vec<SpecAudit>, ManifestLoadError> {
69    let paths = repository.spec_paths()?;
70    let mut audits = Vec::with_capacity(paths.len());
71
72    for path in paths {
73        let file_stem = repository.spec_slug(&path)?;
74        let manifest = repository.load_spec(&path)?;
75
76        audits.push(audit_manifest(&file_stem, &manifest, resolver).await);
77    }
78
79    Ok(audits)
80}
81
82/// Audit one manifest against game data resolved via `resolver`.
83pub async fn audit_manifest(
84    file_stem: &str,
85    manifest: &Manifest,
86    resolver: &DynDataResolver<'_>,
87) -> SpecAudit {
88    let mut sink = AuditSink::default();
89
90    audit_manifest_diagnostics(manifest, &mut sink);
91    let mut ctx = AuditCtx {
92        manifest,
93        resolver,
94        sink: &mut sink,
95    };
96
97    audit_spells(&mut ctx).await;
98    audit_auras(&mut ctx).await;
99    audit_talents(&mut ctx).await;
100    audit_auto_attacks(&mut ctx).await;
101    let (hero_spell_count, hero_aura_count) = audit_hero_talents(&mut ctx).await;
102
103    check_data_values(&mut ctx).await;
104    check_duplicate_generic_folds(&mut ctx).await;
105
106    SpecAudit {
107        display_name: slug_to_display(file_stem),
108        spec_id: manifest.spec.id,
109        errors: sink.errors,
110        warnings: sink.warnings.into_iter().map(Into::into).collect(),
111        spell_count: manifest.spells.len(),
112        aura_count: manifest.auras.len(),
113        talent_count: manifest.talents.len(),
114        hero_spell_count,
115        hero_aura_count,
116    }
117}
118
119// #t(fn: rust_alloc_in_loop) each public audit issue owns its rendered diagnostic message
120fn audit_manifest_diagnostics(manifest: &Manifest, sink: &mut AuditSink) {
121    for diagnostic in manifest.diagnostics() {
122        let (category, name, id) = diagnostic_context(manifest, diagnostic.location());
123
124        sink.error(AuditIssue {
125            category,
126            name,
127            id,
128            message: diagnostic.to_string(),
129        });
130    }
131}
132
133fn diagnostic_context(
134    manifest: &Manifest,
135    location: &ManifestDiagnosticLocation,
136) -> (&'static str, String, u32) {
137    match location {
138        ManifestDiagnosticLocation::SpecPrecombatAuras
139        | ManifestDiagnosticLocation::SpecStealthAura => {
140            ("spec", "spec".to_owned(), manifest.spec.id)
141        }
142        ManifestDiagnosticLocation::Aura { name } => (
143            "aura",
144            name.clone(),
145            manifest.auras.get(name).map_or(0, |aura| aura.id),
146        ),
147        ManifestDiagnosticLocation::Spell { name } => (
148            "spell",
149            name.clone(),
150            manifest.spells.get(name).map_or(0, |spell| spell.id),
151        ),
152        ManifestDiagnosticLocation::AutoAttack { name } => (
153            "auto_attack",
154            name.clone(),
155            manifest
156                .auto_attacks
157                .get(name)
158                .map_or(0, |auto_attack| auto_attack.spell_id),
159        ),
160        ManifestDiagnosticLocation::TalentCompanionAuras { talent } => (
161            "talent",
162            talent.clone(),
163            manifest.talents.get(talent).copied().unwrap_or(0),
164        ),
165        ManifestDiagnosticLocation::SpellGroup { index } => ("spell_group", index.to_string(), 0),
166        ManifestDiagnosticLocation::ImpactProc { index } => ("impact_proc", index.to_string(), 0),
167        _ => ("manifest", location.to_string(), 0),
168    }
169}
170
171fn slug_to_display(slug: &str) -> String {
172    slug.split('_')
173        .map(|w| {
174            let mut c = w.chars();
175
176            match c.next() {
177                None => String::new(),
178                Some(first) => first.to_uppercase().to_string() + c.as_str(),
179            }
180        })
181        .collect::<Vec<_>>()
182        .join(" ")
183}
184
185#[cfg(test)]
186mod tests;