1#![expect(
2 clippy::cast_possible_truncation,
3 clippy::cast_sign_loss,
4 reason = "clamped non-negative percentages are converted to bounded text bar widths"
5)]
6
7use tabled::Tabled;
8use wowlab_common::output::{self, fmt_duration, fmt_number, fmt_pct, truncate};
9
10use super::{
11 analyze::{ProfileAnalysis, clean_name},
12 categories,
13 monitor::{HostStats, RuntimeStats},
14};
15
16const BAR_WIDTH: usize = 40;
17const MAX_FUNC_NAME_LEN: usize = 55;
18const MAX_CATEGORY_FUNC_NAME_LEN: usize = 65;
19const MAX_HOTSPOT_NAME_LEN: usize = 70;
20const MAX_CALLER_NAME_LEN: usize = 60;
21const MAX_TOP_HOTSPOT_NAME_LEN: usize = 75;
22const MAX_CATEGORY_FUNCS: usize = 5;
23const MAX_HOTSPOT_CONTEXTS: usize = 10;
24const MAX_CALLER_CALLEE: usize = 3;
25const MAX_SOURCE_FILES: usize = 15;
26
27#[derive(Tabled)]
28struct HotFunctionRow {
29 #[tabled(rename = "Self%")]
30 self_pct: String,
31 #[tabled(rename = "Total%")]
32 total_pct: String,
33 #[tabled(rename = "Samples")]
34 samples: String,
35 #[tabled(rename = "Category")]
36 category: String,
37 #[tabled(rename = "Function")]
38 name: String,
39}
40
41#[derive(Tabled)]
42struct BottleneckRow {
44 #[tabled(rename = "Self%")]
45 self_pct: String,
46 #[tabled(rename = "Total%")]
47 total_pct: String,
48 #[tabled(rename = "Category")]
49 category: String,
50 #[tabled(rename = "Function")]
51 name: String,
52}
53
54#[derive(Tabled)]
55struct SourceFileRow {
56 #[tabled(rename = "%")]
57 pct: String,
58 #[tabled(rename = "File")]
59 path: String,
60}
61
62#[derive(Tabled)]
63struct SubsystemRow {
64 #[tabled(rename = "%")]
65 pct: String,
66 #[tabled(rename = "Bar")]
67 bar: String,
68 #[tabled(rename = "Subsystem")]
69 name: String,
70}
71
72pub(super) fn print_text(result: &ProfileAnalysis, host: &HostStats, process: &RuntimeStats) {
73 output::separator();
74 output::header("Engine Profile Results");
75 output::blank();
76
77 output::kv("Spec", &result.spec);
78 output::kv_fmt("Iterations", fmt_number(f64::from(result.iterations)));
79 output::kv("Duration", &fmt_duration(result.duration_sec));
80 output::kv_fmt(
81 "Throughput",
82 format!("{} sims/sec", fmt_number(result.throughput)),
83 );
84 output::kv_fmt("Samples", result.total_samples);
85 output::kv_fmt(
86 "Unresolved",
87 format!("{} symbols", result.unresolved_symbols),
88 );
89 output::blank();
90
91 print_machine(host);
92 print_process(process);
93 print_observations(&result.observations);
94 print_subsystem_breakdown(&result.categories);
95 print_category_functions(result);
96 print_bottlenecks(&result.bottlenecks);
97 print_hot_functions(&result.functions);
98 print_hotspot_context(&result.hotspot_context);
99 print_source_files(&result.source_files);
100 print_top_hotspots(&result.functions);
101}
102
103fn print_machine(host: &HostStats) {
104 if host.platform.is_empty() {
105 return;
106 }
107
108 output::subheader("Machine");
109 output::kv("CPU", &host.cpu_model);
110 output::kv_fmt(
111 "Cores",
112 format!(
113 "{} physical / {} logical",
114 host.physical_cpus, host.logical_cpus
115 ),
116 );
117 output::kv_fmt("Memory", format!("{:.1} GB", host.memory_gb));
118 output::blank();
119}
120
121fn print_process(process: &RuntimeStats) {
122 if process.sample_count == 0 {
123 return;
124 }
125
126 output::subheader("Process (clean run)");
127 output::kv_fmt(
128 "CPU",
129 format!(
130 "{:.0}% avg / {:.0}% peak ({} samples)",
131 process.proc_cpu_pct_avg, process.proc_cpu_pct_max, process.sample_count
132 ),
133 );
134 output::kv_fmt(
135 "RSS",
136 format!(
137 "{:.1} MB avg / {:.1} MB peak",
138 process.rss_mb_avg, process.rss_mb_max
139 ),
140 );
141
142 if process.threads_max > 0 {
143 output::kv_fmt(
144 "Threads",
145 format!(
146 "{:.0} avg / {} peak",
147 process.threads_avg, process.threads_max
148 ),
149 );
150 }
151
152 output::blank();
153}
154
155fn print_observations(observations: &[String]) {
156 if observations.is_empty() {
157 return;
158 }
159
160 output::subheader("Observations");
161
162 for obs in observations {
163 output::warning(obs);
164 }
165
166 output::blank();
167}
168
169fn print_subsystem_breakdown(categories: &[(String, f64)]) {
170 output::subheader("Time Breakdown by Subsystem");
171 let rows: Vec<SubsystemRow> = categories
172 .iter()
173 .filter(|(cat, _)| cat != "stdlib")
174 .map(|(cat, pct)| {
175 let bar_len = (*pct as usize).min(BAR_WIDTH);
176
177 SubsystemRow {
178 pct: format!("{pct:.1}%"),
179 bar: format!("{}{}", "#".repeat(bar_len), ".".repeat(BAR_WIDTH - bar_len)),
180 name: cat.clone(),
181 }
182 })
183 .collect();
184
185 output::table(rows);
186 output::blank();
187}
188
189fn print_category_functions(result: &ProfileAnalysis) {
190 output::subheader("Per-Category Function Breakdown");
191
192 for (cat, pct) in &result.categories {
193 if cat == "stdlib" {
194 continue;
195 }
196
197 let Some(funcs) = result.category_functions.get(cat) else {
198 continue;
199 };
200
201 if funcs.is_empty() {
202 continue;
203 }
204
205 let desc = categories::description(cat);
206
207 output::info(&format!(" [{}] {} {}", cat, fmt_pct(*pct / 100.0), desc));
209
210 for f in funcs.iter().take(MAX_CATEGORY_FUNCS) {
212 output::detail(&format!(
213 "{} self {} total {}",
214 fmt_pct(f.self_pct / 100.0),
215 fmt_pct(f.total_pct / 100.0),
216 truncate(&f.name, MAX_CATEGORY_FUNC_NAME_LEN),
217 ));
218 }
219
220 output::blank();
221 }
222}
223
224fn print_bottlenecks(bottlenecks: &[super::analyze::Function]) {
225 if bottlenecks.is_empty() {
226 return;
227 }
228
229 output::subheader("Bottleneck Parents (high total%, low self%)");
230 let rows: Vec<BottleneckRow> = bottlenecks
231 .iter()
232 .map(|f| BottleneckRow {
233 self_pct: format!("{:.1}", f.self_pct),
234 total_pct: format!("{:.1}", f.total_pct),
235 category: f.category.clone(),
236 name: truncate(&clean_name(&f.name), MAX_FUNC_NAME_LEN),
237 })
238 .collect();
239
240 output::table(rows);
241 output::blank();
242}
243
244fn print_hot_functions(functions: &[super::analyze::Function]) {
245 output::subheader("Hot Functions (excluding stdlib)");
246 let rows: Vec<HotFunctionRow> = functions
247 .iter()
248 .map(|f| HotFunctionRow {
249 self_pct: format!("{:.1}", f.self_pct),
250 total_pct: format!("{:.1}", f.total_pct),
251 samples: f.self_samples.to_string(),
252 category: f.category.clone(),
253 name: truncate(&clean_name(&f.name), MAX_FUNC_NAME_LEN),
254 })
255 .collect();
256
257 output::table(rows);
258 output::blank();
259}
260
261fn print_hotspot_context(contexts: &[super::analyze::HotspotContext]) {
263 if contexts.is_empty() {
264 return;
265 }
266
267 output::subheader("Hotspot Context (callers/callees)");
268
269 for ctx in contexts.iter().take(MAX_HOTSPOT_CONTEXTS) {
270 output::info(&format!(
271 " [{} self, {} total] {}",
272 fmt_pct(ctx.self_pct / 100.0),
273 fmt_pct(ctx.total_pct / 100.0),
274 truncate(&ctx.name, MAX_HOTSPOT_NAME_LEN),
275 ));
276
277 for c in ctx.callers.iter().take(MAX_CALLER_CALLEE) {
278 output::detail(&format!(
279 "<- {} {}",
280 fmt_pct(c.pct / 100.0),
281 truncate(&c.name, MAX_CALLER_NAME_LEN)
282 ));
283 }
284
285 for c in ctx.callees.iter().take(MAX_CALLER_CALLEE) {
286 output::detail(&format!(
287 "-> {} {}",
288 fmt_pct(c.pct / 100.0),
289 truncate(&c.name, MAX_CALLER_NAME_LEN)
290 ));
291 }
292
293 output::blank();
294 }
295}
296
297fn print_source_files(files: &[(String, f64)]) {
298 if files.is_empty() {
299 return;
300 }
301
302 output::subheader("Hot Source Files");
303 let rows: Vec<SourceFileRow> = files
304 .iter()
305 .take(MAX_SOURCE_FILES)
306 .map(|(path, pct)| SourceFileRow {
307 pct: format!("{pct:.1}%"),
308 path: path.clone(),
309 })
310 .collect();
311
312 output::table(rows);
313 output::blank();
314}
315
316fn print_top_hotspots(functions: &[super::analyze::Function]) {
317 output::separator();
318 output::header("Top 10 Hotspots");
319
320 for (i, f) in functions.iter().take(MAX_HOTSPOT_CONTEXTS).enumerate() {
322 output::info(&format!(
323 " {:>2}. [{}] [{}] {}",
324 i + 1,
325 fmt_pct(f.self_pct / 100.0),
326 f.category,
327 truncate(&clean_name(&f.name), MAX_TOP_HOTSPOT_NAME_LEN),
328 ));
329 }
330
331 output::blank();
332}
333
334pub(super) fn print_json(result: &ProfileAnalysis, host: &HostStats, process: &RuntimeStats) {
335 #[derive(serde::Serialize)]
336 struct JsonOutput<'a> {
337 host: &'a HostStats,
338 process: &'a RuntimeStats,
339 #[serde(flatten)]
340 analysis: &'a ProfileAnalysis,
341 category_descriptions: wowlab_types::sim::FastMap<&'static str, &'static str>,
342 }
343
344 let descriptions: wowlab_types::sim::FastMap<&str, &str> = categories::CATEGORIES
345 .iter()
346 .map(|c| (c.name, c.description))
347 .chain([
348 ("stdlib", categories::description("stdlib")),
349 ("other", categories::description("other")),
350 ])
351 .collect();
352
353 output::json(&JsonOutput {
354 host,
355 process,
356 analysis: result,
357 category_descriptions: descriptions,
358 });
359}
360
361pub(super) fn print_bench_summary(bench_output: &str) {
362 output::separator();
363 output::header("Benchmark Results");
364 output::blank();
365
366 for line in bench_output.lines() {
367 output::detail(line);
368 }
369
370 output::blank();
371}