1mod allocation;
4mod audit;
5mod bench;
6mod compare;
7mod compare_matrix;
8mod constants;
9mod encounter_debug;
10mod encounter_fixture;
11mod encounter_html;
12mod envelope;
13mod gen_rotation_schema;
14mod intent;
15mod items;
16mod manifest_ledger;
17mod paperdoll;
18mod parcel;
19mod profile;
20mod provider;
21mod render;
22mod report;
23mod run;
24mod sim;
25mod simc;
26mod spell_fixtures;
27mod talent_attribute_summary;
28mod talent_aura_summary;
29mod talent_conformance;
30mod talent_effect_summary;
31mod talent_modifier_summary;
32mod talent_target_flag_summary;
33mod talent_target_plan_summary;
34mod trace;
35mod wowlab;
36
37#[global_allocator]
38static GLOBAL: allocation::CountingAllocator<mimalloc::MiMalloc> =
39 allocation::CountingAllocator::new(mimalloc::MiMalloc);
40
41use clap::{Parser, Subcommand};
42use wowlab_common::{cli, output};
43
44fn fatal(args: std::fmt::Arguments<'_>) -> ! {
45 output::error(&format!("{args}"));
46 std::process::exit(1);
47}
48
49#[derive(Parser)]
50#[command(name = "forge", about = "Workspace profiler and comparison tools.")]
51struct Args {
52 #[command(subcommand)]
53 command: Command,
54
55 #[arg(long, short, global = true)]
57 quiet: bool,
58}
59
60#[derive(Subcommand)]
61enum Command {
62 Bench(bench::BenchArgs),
64 Compare(compare::CompareArgs),
66 CompareMatrix(compare_matrix::CompareMatrixArgs),
68 Envelope(envelope::EnvelopeArgs),
70 Profile(profile::ProfileArgs),
72 Audit(audit::AuditArgs),
74 Paperdoll(paperdoll::PaperdollArgs),
76 Items(items::ItemsArgs),
78 ManifestLedger(manifest_ledger::ManifestLedgerArgs),
80 Parcel(parcel::ParcelArgs),
82 GenSpellFixtures(spell_fixtures::GenSpellFixturesArgs),
84 GenRotationSchema(gen_rotation_schema::GenRotationSchemaArgs),
86 TalentConformance(talent_conformance::TalentConformanceArgs),
88}
89
90#[cfg(test)]
91mod tests {
92 use clap::Parser;
93 use googletest::{Result as GtestResult, prelude::*};
94
95 use super::{Args, Command, encounter_fixture::EncounterFixture};
96
97 #[gtest]
98 fn both_fixture_commands_accept_every_canonical_slug() -> GtestResult<()> {
99 let fixtures = [
100 ("two_target_pack", EncounterFixture::TwoTargetPack),
101 ("boss_add_wave", EncounterFixture::BossAddWave),
102 ("two_pull_dungeon", EncounterFixture::TwoPullDungeon),
103 ];
104
105 for (slug, expected) in fixtures {
106 let compare = Args::try_parse_from([
107 "forge",
108 "compare",
109 "feral_druid",
110 "--encounter-fixture",
111 slug,
112 ])
113 .or_fail()?;
114 let Command::Compare(compare) = compare.command else {
115 return fail!("compare command expected");
116 };
117
118 verify_that!(compare.encounter_fixture, eq(Some(expected)))?;
119
120 let paperdoll = Args::try_parse_from([
121 "forge",
122 "paperdoll",
123 "feral_druid",
124 "--encounter-fixture",
125 slug,
126 ])
127 .or_fail()?;
128 let Command::Paperdoll(paperdoll) = paperdoll.command else {
129 return fail!("paperdoll command expected");
130 };
131
132 verify_that!(paperdoll.encounter_fixture, eq(Some(expected)))?;
133 }
134
135 Ok(())
136 }
137
138 #[gtest]
139 fn paperdoll_rejects_an_unknown_encounter_fixture() -> GtestResult<()> {
140 let result = Args::try_parse_from([
141 "forge",
142 "paperdoll",
143 "feral_druid",
144 "--encounter-fixture",
145 "invented_pack",
146 ]);
147 let error = result.err().or_fail()?;
148
149 verify_true!(error.to_string().contains("invalid value 'invented_pack'"))?;
150
151 Ok(())
152 }
153
154 #[gtest]
155 fn envelope_accepts_browser_reproduction_inputs() -> GtestResult<()> {
156 let parsed = Args::try_parse_from([
157 "forge",
158 "envelope",
159 "/tmp/outlaw.toml",
160 "--rotation",
161 "/tmp/outlaw.json",
162 "--seed",
163 "123",
164 "--decision-limit",
165 "50",
166 "--trace",
167 ])
168 .or_fail()?;
169 let Command::Envelope(envelope) = parsed.command else {
170 return fail!("envelope command expected");
171 };
172
173 verify_that!(
174 envelope.envelope.as_path(),
175 eq(wowlab_fs::path::Path::new("/tmp/outlaw.toml"))
176 )?;
177 verify_that!(
178 envelope.rotation.as_deref(),
179 eq(Some(wowlab_fs::path::Path::new("/tmp/outlaw.json")))
180 )?;
181 verify_that!(envelope.seed, eq(123))?;
182 verify_that!(envelope.decision_limit, eq(50))?;
183 verify_true!(envelope.trace)?;
184
185 Ok(())
186 }
187}
188
189#[cfg(test)]
190mod run_parameter_cli_tests {
191 use clap::Parser;
192 use googletest::{Result as GtestResult, prelude::*};
193
194 use super::{Args, Command, run::RunParameters};
195
196 fn command_run_parameters(command: &Command) -> Option<RunParameters> {
197 match command {
198 Command::Compare(args) => RunParameters::try_from(args).ok(),
199 Command::CompareMatrix(args) => RunParameters::try_from(args).ok(),
200 Command::Bench(args) => RunParameters::try_from(args).ok(),
201 Command::Items(args) => RunParameters::try_from(args).ok(),
202 Command::Profile(args) => RunParameters::try_from(args).ok(),
203 _ => None,
204 }
205 }
206
207 #[gtest]
208 fn run_command_defaults_remain_command_specific() -> GtestResult<()> {
209 let cases = [
210 (["forge", "compare", "fire_mage"].as_slice(), 1, 300),
211 (["forge", "compare-matrix"].as_slice(), 1, 300),
212 (["forge", "bench"].as_slice(), 500_000, 300),
213 (["forge", "items"].as_slice(), 100, 60),
214 (["forge", "profile"].as_slice(), 5_000, 300),
215 ];
216
217 for (arguments, iterations, duration) in cases {
218 let parsed = Args::try_parse_from(arguments).or_fail()?;
219 let parameters = command_run_parameters(&parsed.command).or_fail()?;
220
221 verify_that!(parameters.iterations(), eq(iterations))?;
222 verify_that!(parameters.fight_duration_secs(), eq(duration))?;
223 }
224
225 Ok(())
226 }
227
228 #[gtest]
229 fn run_commands_reject_zero_values_at_the_typed_boundary() -> GtestResult<()> {
230 let zero_iterations = [
231 ["forge", "compare", "fire_mage", "--iterations", "0"].as_slice(),
232 ["forge", "compare-matrix", "--iterations", "0"].as_slice(),
233 ["forge", "bench", "--iterations", "0"].as_slice(),
234 ["forge", "items", "--iterations", "0"].as_slice(),
235 ["forge", "profile", "--iterations", "0"].as_slice(),
236 ];
237
238 for arguments in zero_iterations {
239 let parsed = Args::try_parse_from(arguments).or_fail()?;
240 let error = match &parsed.command {
241 Command::Compare(args) => RunParameters::try_from(args).err().or_fail()?,
242 Command::CompareMatrix(args) => RunParameters::try_from(args).err().or_fail()?,
243 Command::Bench(args) => RunParameters::try_from(args).err().or_fail()?,
244 Command::Items(args) => RunParameters::try_from(args).err().or_fail()?,
245 Command::Profile(args) => RunParameters::try_from(args).err().or_fail()?,
246 _ => return fail!("command has no shared run parameters"),
247 };
248
249 verify_that!(
250 error.to_string(),
251 eq("iterations must be greater than zero")
252 )?;
253 }
254
255 Ok(())
256 }
257}
258
259#[cfg(test)]
260mod summary_command_tests {
261 use clap::Parser;
262 use googletest::{Result as GtestResult, prelude::*};
263
264 use super::{Args, Command, run::RunParameters};
265
266 #[gtest]
267 fn compare_matrix_accepts_filters_and_shared_run_parameters() -> GtestResult<()> {
268 let parsed = Args::try_parse_from([
269 "forge",
270 "compare-matrix",
271 "fire_mage",
272 "unholy_death_knight",
273 "--duration",
274 "60",
275 "--iterations",
276 "20",
277 "--jobs",
278 "2",
279 "--format",
280 "json",
281 ])
282 .or_fail()?;
283 let Command::CompareMatrix(matrix) = parsed.command else {
284 return fail!("compare-matrix command expected");
285 };
286
287 verify_that!(
288 matrix
289 .specs
290 .iter()
291 .map(AsRef::as_ref)
292 .collect::<Vec<&str>>(),
293 elements_are![eq(&"fire_mage"), eq(&"unholy_death_knight")]
294 )?;
295 verify_that!(
296 &matrix,
297 matches_pattern!(crate::compare_matrix::CompareMatrixArgs {
298 duration: eq(&60),
299 iterations: eq(&20),
300 jobs: eq(&2),
301 ..
302 })
303 )?;
304 let parameters = RunParameters::try_from(&matrix).or_fail()?;
305
306 verify_that!(parameters.iterations(), eq(20))?;
307 verify_that!(parameters.fight_duration_secs(), eq(60))?;
308
309 Ok(())
310 }
311}
312
313fn main() {
314 let args = Args::parse();
315 let _composition = wowlab_engine::composition::EngineComposition::initialize()
316 .unwrap_or_else(|error| fatal(format_args!("{error}")));
317 let app = cli::boot("forge", env!("CARGO_PKG_VERSION"), args.quiet, "FORGE_ROOT");
318
319 match args.command {
320 Command::Bench(ref bench_args) => {
321 if let Err(e) = bench::run(bench_args) {
322 fatal(format_args!("{e}"));
323 }
324 }
325 Command::Compare(ref compare_args) => {
326 if let Err(e) = compare::run(compare_args) {
327 fatal(format_args!("{e:#}"));
328 }
329 }
330 Command::CompareMatrix(ref matrix_args) => {
331 if let Err(e) = compare_matrix::run(matrix_args) {
332 fatal(format_args!("{e:#}"));
333 }
334 }
335 Command::Envelope(ref envelope_args) => {
336 if let Err(e) = envelope::run(envelope_args) {
337 fatal(format_args!("{e:#}"));
338 }
339 }
340 Command::Profile(ref profile_args) => {
341 let crates_dir = app.crates_dir();
342
343 profile::run(profile_args, &crates_dir);
344 }
345 Command::Audit(ref audit_args) => {
346 if let Err(e) = audit::run(audit_args) {
347 fatal(format_args!("{e}"));
348 }
349 }
350 Command::Paperdoll(ref pd_args) => {
351 if let Err(e) = paperdoll::run(pd_args) {
352 fatal(format_args!("{e:#}"));
353 }
354 }
355 Command::Items(ref items_args) => {
356 if let Err(e) = items::run(items_args) {
357 fatal(format_args!("{e:#}"));
358 }
359 }
360 Command::ManifestLedger(ref ledger_args) => {
361 if let Err(e) = manifest_ledger::run(ledger_args) {
362 fatal(format_args!("{e:#}"));
363 }
364 }
365 Command::Parcel(ref parcel_args) => {
366 if let Err(e) = parcel::run(parcel_args) {
367 fatal(format_args!("{e:#}"));
368 }
369 }
370 Command::GenSpellFixtures(ref gen_args) => {
371 if let Err(e) = spell_fixtures::run(gen_args) {
372 fatal(format_args!("{e:#}"));
373 }
374 }
375 Command::GenRotationSchema(ref gen_args) => {
376 if let Err(e) = gen_rotation_schema::run(gen_args) {
377 fatal(format_args!("{e:#}"));
378 }
379 }
380 Command::TalentConformance(ref conformance_args) => {
381 if let Err(e) = talent_conformance::run(conformance_args) {
382 fatal(format_args!("{e:#}"));
383 }
384 }
385 }
386}