1mod analyze;
6mod build;
7mod categories;
8mod monitor;
9mod report;
10mod samply;
11mod targets;
12
13use clap::Args;
14#[cfg(test)]
15use googletest::{Result as GtestResult, prelude::*};
16use wowlab_common::{output, prompt};
17use wowlab_fs::{path::Path, temporary};
18
19use crate::run::{FightDurationSeconds, IterationCount, RunParameters, RunParametersError};
20
21fn fatal(args: std::fmt::Arguments<'_>) -> ! {
22 output::error(&format!("{args}"));
23 std::process::exit(1);
24}
25
26use targets::{ProfileTarget, TargetKind};
27
28#[derive(Args, Debug)]
29pub(crate) struct ProfileArgs {
30 #[arg(long, short)]
32 pub target: Option<String>,
33
34 #[arg(long, default_value = "5000")]
36 pub iterations: u32,
37
38 #[arg(long, default_value = "300")]
40 pub duration: u32,
41
42 #[arg(long)]
44 pub spec: Option<String>,
45
46 #[arg(long)]
48 pub json: bool,
49
50 #[arg(long, default_value = "50")]
52 pub top: usize,
53
54 #[arg(long, short)]
56 pub quiet: bool,
57
58 #[arg(long)]
60 pub bench: bool,
61
62 #[arg(last = true)]
64 pub args: Vec<String>,
65}
66
67#[derive(Debug, thiserror::Error)]
68enum CaptureProfileError {
69 #[error("failed to create profile workspace: {0}")]
70 Workspace(#[from] wowlab_fs::error::Error),
71 #[error("samply failed: {0}")]
72 Samply(#[from] samply::RecordError),
73 #[error("failed to parse profile: {0}")]
74 Parse(#[from] anyhow::Error),
75}
76
77impl TryFrom<&ProfileArgs> for RunParameters {
78 type Error = RunParametersError;
79
80 fn try_from(args: &ProfileArgs) -> Result<Self, Self::Error> {
81 let fight_duration = FightDurationSeconds::try_from(args.duration)?;
82 let iterations = IterationCount::try_from(args.iterations)?;
83
84 Ok(Self::new(iterations, fight_duration))
85 }
86}
87
88pub(crate) fn run(profile_args: &ProfileArgs, crates_dir: &Path) {
89 let all_targets = targets::discover(crates_dir);
90
91 if all_targets.is_empty() {
92 fatal(format_args!(
93 "no profiling targets found in {}",
94 crates_dir.display()
95 ));
96 }
97
98 let target = if let Some(ref name) = profile_args.target {
99 all_targets
100 .iter()
101 .find(|t| t.name == *name || t.bin_name == *name)
102 .unwrap_or_else(|| {
103 fatal(format_args!(
104 "target '{}' not found. available: {}",
105 name,
106 all_targets
107 .iter()
108 .map(|t| t.name.as_str())
109 .collect::<Vec<_>>()
110 .join(", ")
111 ));
112 })
113 .clone()
114 } else {
115 interactive_select(&all_targets, crates_dir)
116 };
117
118 if !profile_args.quiet {
119 output::kv("Target", &target.name);
120 output::kv(
121 "Kind",
122 match target.kind {
123 TargetKind::Binary => "binary",
124 TargetKind::Bench => "benchmark",
125 },
126 );
127 output::blank();
128 }
129
130 if profile_args.bench || target.kind == TargetKind::Bench {
131 run_bench(&target, crates_dir, profile_args);
132 } else {
133 run_profile(&target, crates_dir, profile_args);
134 }
135}
136
137fn interactive_select(targets: &[ProfileTarget], crates_dir: &Path) -> ProfileTarget {
138 let labels: Vec<String> = targets.iter().map(ToString::to_string).collect();
139 let idx = prompt::select("Target", &labels);
140 let mut target = targets[idx].clone();
142
143 if target.name == "engine" {
144 let specs = targets::discover_engine_specs(crates_dir);
145 let spec_strs: Vec<&str> = specs.iter().map(String::as_str).collect();
146 let spec_idx = prompt::select("Spec", &spec_strs);
147 let spec = &specs[spec_idx];
149
150 let iterations = prompt::text("Iterations", Some("5000"));
151 let duration = prompt::text("Duration (seconds)", Some("300"));
152
153 target.default_args = vec![
154 "sim".to_string(),
155 "--spec".to_string(),
156 spec.clone(),
157 "--iterations".to_string(),
158 iterations,
159 "--duration".to_string(),
160 duration,
161 "--threads".to_string(),
162 "1".to_string(),
163 "--format".to_string(),
164 "json".to_string(),
165 ];
166 } else if target.kind == TargetKind::Binary && target.default_args.is_empty() {
167 let custom = prompt::text("Command args (space-separated)", Some(""));
168
169 if !custom.is_empty() {
170 target.default_args = custom.split_whitespace().map(ToString::to_string).collect();
171 }
172 }
173
174 target
175}
176
177fn run_bench(target: &ProfileTarget, crates_dir: &Path, args: &ProfileArgs) {
178 if !args.quiet {
179 output::header("Building benchmark...");
180 }
181
182 let bench_binary = build::build_bench(target, crates_dir, args.quiet);
183 let Some(binary) = bench_binary else {
184 fatal(format_args!(
185 "failed to build benchmark '{}'",
186 target.bin_name
187 ));
188 };
189
190 if !args.quiet {
191 output::success(&format!("Built: {}", binary.display()));
192 output::header("Running benchmark...");
193 }
194
195 let result = monitor::run_monitored(&binary, &["--bench".to_string()], "error");
196
197 if result.exit_code != 0 {
198 fatal(format_args!("benchmark failed: {}", result.stderr));
199 }
200
201 report::print_bench_summary(&result.stdout);
202}
203
204fn run_profile(target: &ProfileTarget, crates_dir: &Path, args: &ProfileArgs) {
205 if !samply::check_samply() {
206 fatal(format_args!(
207 "samply not found. install with: cargo install samply"
208 ));
209 }
210
211 if !args.quiet {
212 output::header("Building with bench-profile...");
213 }
214
215 let binary = build::build_target(target, crates_dir, args.quiet);
216 let Some(binary) = binary else {
217 fatal(format_args!("failed to build '{}'", target.bin_name));
218 };
219
220 if !args.quiet {
221 output::success(&format!("Built: {}", binary.display()));
222 }
223
224 let (parameters, run_args) = if target.name == "engine" {
225 let parameters =
226 RunParameters::try_from(args).unwrap_or_else(|error| fatal(format_args!("{error}")));
227
228 (
229 Some(parameters),
230 build_engine_run_args(target, args, parameters),
231 )
232 } else {
233 (None, build_non_engine_run_args(target, args))
234 };
235 let host = monitor::collect_host_stats();
236
237 if !args.quiet {
238 output::header("Timing clean run (no profiler)...");
239 }
240
241 let clean = monitor::run_monitored(&binary, &run_args, "error");
242
243 if clean.exit_code != 0 {
244 fatal(format_args!("engine error: {}", clean.stderr));
245 }
246
247 let clean_secs = clean.duration.as_secs_f64();
248
249 if !args.quiet {
250 output::kv("Duration", &output::fmt_duration(clean_secs));
251 output::kv(
252 "Throughput",
253 &format!(
254 "{} sims/sec",
255 output::fmt_number(
256 f64::from(parameters.map_or(args.iterations, RunParameters::iterations))
257 / clean_secs,
258 )
259 ),
260 );
261 output::blank();
262 }
263
264 if !args.quiet {
265 output::header("Profiling with samply...");
266 }
267
268 let raw = capture_profile(&binary, &run_args, args.quiet)
269 .unwrap_or_else(|error| fatal(format_args!("{error}")));
270
271 let (symbols, _unresolved) = samply::resolve_symbols(&raw.strings, &binary, args.quiet);
272
273 let spec = args
274 .spec
275 .clone()
276 .or_else(|| extract_spec_from_args(&run_args))
277 .unwrap_or_default();
278
279 let analysis = analyze::analyze(
280 &raw,
281 &symbols,
282 &spec,
283 parameters.map_or(args.iterations, RunParameters::iterations),
284 clean_secs,
285 args.top,
286 );
287
288 if args.json {
289 report::print_json(&analysis, &host, &clean.stats);
290 } else {
291 report::print_text(&analysis, &host, &clean.stats);
292 }
293}
294
295fn capture_profile(
296 binary: &Path,
297 run_args: &[String],
298 quiet: bool,
299) -> Result<samply::RawProfile, CaptureProfileError> {
300 let output = temporary::Directory::with_prefix("wowlab-forge-profile-")?;
301 let path = output.path().join("profile.json.gz");
302
303 samply::record(binary, run_args, &path)?;
304
305 if !quiet {
306 output::header("Analyzing profile...");
307 }
308
309 Ok(samply::parse(&path)?)
310}
311
312fn build_engine_run_args(
313 target: &ProfileTarget,
314 args: &ProfileArgs,
315 parameters: RunParameters,
316) -> Vec<String> {
317 let spec = args.spec.clone().unwrap_or_else(|| {
318 target
319 .default_args
320 .iter()
321 .position(|argument| argument == "--spec")
322 .and_then(|index| target.default_args.get(index + 1))
323 .cloned()
324 .expect("engine target must have a default --spec")
325 });
326
327 vec![
328 "sim".to_string(),
329 "--spec".to_string(),
330 spec,
331 "--iterations".to_string(),
332 parameters.iterations().to_string(),
333 "--duration".to_string(),
334 parameters.fight_duration_secs().to_string(),
335 "--threads".to_string(),
336 "1".to_string(),
337 "--format".to_string(),
338 "json".to_string(),
339 ]
340}
341
342fn build_non_engine_run_args(target: &ProfileTarget, args: &ProfileArgs) -> Vec<String> {
343 if args.args.is_empty() {
344 target.default_args.clone()
345 } else {
346 args.args.clone()
347 }
348}
349
350const ARG_VALUE_PAIR: usize = 2;
351
352fn extract_spec_from_args(run_args: &[String]) -> Option<String> {
353 run_args
354 .windows(ARG_VALUE_PAIR)
355 .find(|w| w[0] == "--spec")
357 .map(|w| w[1].clone())
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 #[gtest]
365 fn engine_command_uses_validated_parameters_in_stable_order() -> GtestResult<()> {
366 let target = ProfileTarget {
367 name: "engine".to_string(),
368 package: "wowlab-engine".to_string(),
369 bin_name: "engine".to_string(),
370 kind: TargetKind::Binary,
371 features: Vec::new(),
372 default_args: vec![
373 "sim".to_string(),
374 "--spec".to_string(),
375 "fire_mage".to_string(),
376 ],
377 };
378 let args = ProfileArgs {
379 target: Some("engine".to_string()),
380 iterations: 17,
381 duration: 45,
382 spec: None,
383 json: false,
384 top: 50,
385 quiet: false,
386 bench: false,
387 args: Vec::new(),
388 };
389 let parameters = RunParameters::try_from(&args).or_fail()?;
390
391 verify_that!(
392 build_engine_run_args(&target, &args, parameters),
393 elements_are![
394 "sim",
395 "--spec",
396 "fire_mage",
397 "--iterations",
398 "17",
399 "--duration",
400 "45",
401 "--threads",
402 "1",
403 "--format",
404 "json",
405 ]
406 )?;
407
408 Ok(())
409 }
410
411 #[gtest]
412 fn capture_profile_error_preserves_stage_context_and_source() -> GtestResult<()> {
413 let error = CaptureProfileError::Parse(anyhow::anyhow!("invalid profile"));
414
415 verify_that!(
416 error.to_string(),
417 eq("failed to parse profile: invalid profile")
418 )?;
419 verify_true!(std::error::Error::source(&error).is_some())?;
420
421 Ok(())
422 }
423}