Skip to main content

forge/
main.rs

1//! Command-line workspace profiling, auditing, and comparison tools.
2
3mod 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    /// Suppress non-essential output.
56    #[arg(long, short, global = true)]
57    quiet: bool,
58}
59
60#[derive(Subcommand)]
61enum Command {
62    /// Benchmark engine throughput across specs, output HTML report.
63    Bench(bench::BenchArgs),
64    /// Compare `WoW` Lab engine output against `SimC`.
65    Compare(compare::CompareArgs),
66    /// Compare `WoW` Lab against `SimC` across every supported spec.
67    CompareMatrix(compare_matrix::CompareMatrixArgs),
68    /// Run a v2 intent envelope through the browser-preview engine path.
69    Envelope(envelope::EnvelopeArgs),
70    /// Profile a workspace crate with samply.
71    Profile(profile::ProfileArgs),
72    /// Audit spec manifests against game data.
73    Audit(audit::AuditArgs),
74    /// Dump the character sheet for a spec (stats, auras, gear).
75    Paperdoll(paperdoll::PaperdollArgs),
76    /// Measure each registered item's DPS contribution for a spec.
77    Items(items::ItemsArgs),
78    /// Inventory authored manifest and hook behavior at operation granularity.
79    ManifestLedger(manifest_ledger::ManifestLedgerArgs),
80    /// Encode, decode, and inspect LibParcel-1.0 envelopes.
81    Parcel(parcel::ParcelArgs),
82    /// Generate golden spell-description render fixtures for the regression suites.
83    GenSpellFixtures(spell_fixtures::GenSpellFixturesArgs),
84    /// Emit the rotation DSL JSON Schema (engine source of truth) for the AI edge function.
85    GenRotationSchema(gen_rotation_schema::GenRotationSchemaArgs),
86    /// Compare normalized MID2 talent records against `SimulationCraft`.
87    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}