1use 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 #[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(®ular_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}