Skip to main content

forge/
audit.rs

1// #t(file: rust_alloc_in_loop) CLI binary, allocations are fine for readability.
2
3//! Audit subcommand: validates spec manifests against game data.
4
5use anyhow::{Context, Result};
6use clap::Args;
7use wowlab_common::{cli::workspace_root, output};
8use wowlab_engine_adapter_data::LocalCsvResolver;
9use wowlab_engine_application::{SpecAudit, audit_manifest};
10use wowlab_engine_ports::DynDataResolver;
11use wowlab_fs::{
12    directory::{self, EntryKind},
13    path::{Path, PathBuf},
14};
15use wowlab_manifest_schema::ManifestRepository;
16
17const DATA_DIR_ENV: &str = "WOWLAB_DATA_DIR";
18
19#[derive(Args, Debug)]
20pub(crate) struct AuditArgs {
21    /// Path to manifests directory.
22    #[arg(long)]
23    pub manifests: Option<PathBuf>,
24}
25
26pub(crate) fn run(args: &AuditArgs) -> Result<()> {
27    let manifests_dir = match &args.manifests {
28        Some(path) => path.clone(),
29        None => find_manifests_dir().ok_or_else(|| {
30            anyhow::anyhow!("could not auto-detect manifests directory, use --manifests <path>")
31        })?,
32    };
33
34    let data_dir = match std::env::var(DATA_DIR_ENV) {
35        Ok(dir) => PathBuf::from(dir),
36        Err(_) => default_data_dir().ok_or_else(|| {
37            anyhow::anyhow!(
38                "could not derive the default game-data directory from the repo root (set {DATA_DIR_ENV})"
39            )
40        })?,
41    };
42
43    anyhow::ensure!(
44        is_directory(&data_dir),
45        "game-data directory '{}' does not exist (set {DATA_DIR_ENV})",
46        data_dir.display(),
47    );
48    let resolver = LocalCsvResolver::new(data_dir.as_path());
49    let resolver_dyn = DynDataResolver::from_ref(&resolver);
50
51    output::kv("Resolver", &format!("local ({})", data_dir.display()));
52    output::kv("Manifests", &manifests_dir.display().to_string());
53    output::blank();
54
55    let rt = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?;
56
57    rt.block_on(run_audit(&manifests_dir, resolver_dyn))
58}
59
60async fn run_audit(manifests_dir: &Path, resolver: &DynDataResolver<'_>) -> Result<()> {
61    let repository = ManifestRepository::new(manifests_dir);
62    let paths = repository.spec_paths()?;
63
64    output::banner("Manifest Audit", &format!("{} specs", paths.len()));
65    output::blank();
66
67    let mut total_errors = 0usize;
68    let mut total_warnings = 0usize;
69    let mut specs_with_errors = 0usize;
70
71    for path in &paths {
72        let file_stem = repository.spec_slug(path)?;
73
74        let manifest = repository
75            .load_spec(path)
76            .with_context(|| format!("failed to load {}", path.display()))?;
77
78        let audit = audit_manifest(&file_stem, &manifest, resolver).await;
79
80        if !audit.errors.is_empty() {
81            specs_with_errors += 1;
82        }
83
84        total_errors += audit.errors.len();
85        total_warnings += audit.warnings.len();
86
87        print_spec(&audit);
88    }
89
90    print_summary(total_errors, total_warnings, specs_with_errors, paths.len());
91
92    if total_errors > 0 {
93        anyhow::bail!("{total_errors} audit errors found");
94    }
95
96    Ok(())
97}
98
99fn print_spec(audit: &SpecAudit) {
100    if audit.errors.is_empty() {
101        output::success(&format!(
102            "{} ({} spells, {} auras)",
103            audit.display_name, audit.spell_count, audit.aura_count,
104        ));
105    } else {
106        output::error(&format!(
107            "{} ({} errors)",
108            audit.display_name,
109            audit.errors.len(),
110        ));
111
112        for issue in &audit.errors {
113            output::detail(&format!(
114                "[{}] {} (id={}): {}",
115                issue.category, issue.name, issue.id, issue.message,
116            ));
117        }
118    }
119
120    for w in &audit.warnings {
121        output::warning(w);
122    }
123}
124
125fn print_summary(errors: usize, warnings: usize, specs_with_errors: usize, total_specs: usize) {
126    output::blank();
127    output::separator();
128
129    if errors == 0 {
130        output::success(&format!(
131            "All {total_specs} specs passed ({warnings} warnings)",
132        ));
133    } else {
134        output::error(&format!(
135            "{errors} errors in {specs_with_errors} specs, {warnings} warnings ({total_specs} total specs)",
136        ));
137    }
138}
139
140fn default_data_dir() -> Option<PathBuf> {
141    workspace_root("FORGE_ROOT")
142        .parent()
143        .map(|root| root.join("wowlab-data"))
144}
145
146fn find_manifests_dir() -> Option<PathBuf> {
147    let candidates = [
148        PathBuf::from("crates/engine/manifests"),
149        PathBuf::from("manifests"),
150        PathBuf::from("../engine/manifests"),
151    ];
152
153    first_directory(&candidates)
154}
155
156fn first_directory(candidates: &[PathBuf]) -> Option<PathBuf> {
157    candidates.iter().find(|path| is_directory(path)).cloned()
158}
159
160fn is_directory(path: &Path) -> bool {
161    directory::inspect(path)
162        .is_ok_and(|entry| entry.is_some_and(|entry| entry.kind() == EntryKind::Directory))
163}
164
165#[cfg(test)]
166mod tests {
167    use googletest::prelude::*;
168    use wowlab_fs::{directory, file, temporary::Directory};
169
170    use super::first_directory;
171
172    #[gtest]
173    fn manifest_discovery_selects_the_first_directory_only() -> Result<()> {
174        let temporary = Directory::new().or_fail()?;
175        let missing = temporary.path().join("missing");
176        let regular_file = temporary.path().join("manifest-file");
177        let first_directory_path = temporary.path().join("first");
178        let second_directory_path = temporary.path().join("second");
179
180        file::write_text(&regular_file, "not a directory").or_fail()?;
181        directory::ensure(&first_directory_path).or_fail()?;
182        directory::ensure(&second_directory_path).or_fail()?;
183
184        let candidates = [
185            missing,
186            regular_file,
187            first_directory_path.clone(),
188            second_directory_path,
189        ];
190
191        verify_that!(
192            first_directory(&candidates),
193            some(eq(&first_directory_path))
194        )
195    }
196}