Skip to main content

forge/
main.rs

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