1use crate::snapshot::{CallTreeSnapshot, NodeSnapshot};
2use std::fmt;
3use std::time::Duration;
4
5pub struct SnapshotDisplay<'a>(pub(crate) &'a CallTreeSnapshot);
6
7impl fmt::Display for SnapshotDisplay<'_> {
8 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
9 let rows = build_rows(self.0);
10 let widths = ColumnWidths::from_rows(&rows);
11
12 for (index, row) in rows.iter().enumerate() {
13 write_row(f, row, &widths)?;
14 if index + 1 != rows.len() {
15 writeln!(f)?;
16 }
17 }
18
19 Ok(())
20 }
21}
22
23impl fmt::Display for CallTreeSnapshot {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 self.display().fmt(f)
26 }
27}
28
29fn write_row(f: &mut fmt::Formatter<'_>, row: &DisplayRow, widths: &ColumnWidths) -> fmt::Result {
30 write!(
31 f,
32 "{:<name_width$} {:>avg_width$} {:>p95_width$} {:>samples_width$}",
33 row.label,
34 row.avg,
35 row.p95,
36 row.samples,
37 name_width = widths.name,
38 avg_width = widths.avg,
39 p95_width = widths.p95,
40 samples_width = widths.samples,
41 )
42}
43
44fn build_rows(snapshot: &CallTreeSnapshot) -> Vec<DisplayRow> {
45 let mut roots = snapshot.roots.iter().collect::<Vec<_>>();
46 roots.sort_by(|left, right| {
47 right
48 .wall
49 .mean
50 .cmp(&left.wall.mean)
51 .then_with(|| left.name.cmp(&right.name))
52 });
53
54 let mut rows = Vec::new();
55 for (index, root) in roots.into_iter().enumerate() {
56 collect_rows(root, "", true, index + 1 == snapshot.roots.len(), &mut rows);
57 }
58 rows
59}
60
61fn collect_rows(
62 node: &NodeSnapshot,
63 prefix: &str,
64 is_root: bool,
65 is_last: bool,
66 rows: &mut Vec<DisplayRow>,
67) {
68 let branch = if is_root {
69 ""
70 } else if is_last {
71 "└── "
72 } else {
73 "├── "
74 };
75
76 rows.push(DisplayRow {
77 label: format!("{prefix}{branch}{}", node.name),
78 avg: format!("{} avg", format_duration(node.wall.mean)),
79 p95: format!("{} p95", format_duration(node.wall.p95)),
80 samples: format!("n={}", node.wall.samples),
81 });
82
83 let child_prefix = if is_root {
84 String::new()
85 } else {
86 format!("{prefix}{}", if is_last { " " } else { "│ " })
87 };
88
89 for (index, child) in node.children.iter().enumerate() {
90 collect_rows(
91 child,
92 &child_prefix,
93 false,
94 index + 1 == node.children.len(),
95 rows,
96 );
97 }
98}
99
100fn format_duration(duration: Duration) -> String {
101 let nanos = duration.as_nanos();
102
103 if nanos >= 1_000_000_000 {
104 format!("{:.1}s", nanos as f64 / 1_000_000_000.0)
105 } else if nanos >= 1_000_000 {
106 format!("{:.1}ms", nanos as f64 / 1_000_000.0)
107 } else if nanos >= 1_000 {
108 format!("{:.1}us", nanos as f64 / 1_000.0)
109 } else {
110 format!("{nanos}ns")
111 }
112}
113
114#[derive(Debug)]
115struct DisplayRow {
116 label: String,
117 avg: String,
118 p95: String,
119 samples: String,
120}
121
122#[derive(Debug)]
123struct ColumnWidths {
124 name: usize,
125 avg: usize,
126 p95: usize,
127 samples: usize,
128}
129
130impl ColumnWidths {
131 fn from_rows(rows: &[DisplayRow]) -> Self {
132 Self {
133 name: rows.iter().map(|row| row.label.len()).max().unwrap_or(0),
134 avg: rows.iter().map(|row| row.avg.len()).max().unwrap_or(0),
135 p95: rows.iter().map(|row| row.p95.len()).max().unwrap_or(0),
136 samples: rows.iter().map(|row| row.samples.len()).max().unwrap_or(0),
137 }
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use crate::snapshot::CallTreeSnapshot;
144 use crate::stats::TimingStats;
145 use std::time::Duration;
146
147 #[test]
148 fn renders_tree_display_with_dynamic_columns_and_sorted_roots() {
149 use super::format_duration;
150 use crate::snapshot::NodeSnapshot;
151
152 let snapshot = CallTreeSnapshot {
153 roots: vec![
154 NodeSnapshot {
155 name: "request".to_string(),
156 target: "app".to_string(),
157 module_path: None,
158 line: None,
159 total_calls: 4,
160 wall: TimingStats {
161 samples: 4,
162 min: Duration::from_millis(8),
163 max: Duration::from_millis(12),
164 mean: Duration::from_millis(10),
165 p95: Duration::from_millis(12),
166 },
167 active: TimingStats {
168 samples: 4,
169 min: Duration::from_millis(4),
170 max: Duration::from_millis(8),
171 mean: Duration::from_millis(6),
172 p95: Duration::from_millis(8),
173 },
174 suspended: TimingStats {
175 samples: 4,
176 min: Duration::from_millis(2),
177 max: Duration::from_millis(4),
178 mean: Duration::from_millis(3),
179 p95: Duration::from_millis(4),
180 },
181 children: vec![
182 NodeSnapshot {
183 name: "authenticate".to_string(),
184 target: "app".to_string(),
185 module_path: None,
186 line: None,
187 total_calls: 1,
188 wall: TimingStats {
189 samples: 1,
190 min: Duration::from_millis(5),
191 max: Duration::from_millis(5),
192 mean: Duration::from_millis(5),
193 p95: Duration::from_millis(5),
194 },
195 active: TimingStats::from_nanos(std::iter::empty()),
196 suspended: TimingStats::from_nanos(std::iter::empty()),
197 children: vec![],
198 },
199 NodeSnapshot {
200 name: "database".to_string(),
201 target: "app".to_string(),
202 module_path: None,
203 line: None,
204 total_calls: 1,
205 wall: TimingStats {
206 samples: 1,
207 min: Duration::from_millis(20),
208 max: Duration::from_millis(20),
209 mean: Duration::from_millis(20),
210 p95: Duration::from_millis(20),
211 },
212 active: TimingStats::from_nanos(std::iter::empty()),
213 suspended: TimingStats::from_nanos(std::iter::empty()),
214 children: vec![NodeSnapshot {
215 name: "query".to_string(),
216 target: "app".to_string(),
217 module_path: None,
218 line: None,
219 total_calls: 1,
220 wall: TimingStats {
221 samples: 1,
222 min: Duration::from_millis(1),
223 max: Duration::from_millis(1),
224 mean: Duration::from_millis(1),
225 p95: Duration::from_millis(1),
226 },
227 active: TimingStats::from_nanos(std::iter::empty()),
228 suspended: TimingStats::from_nanos(std::iter::empty()),
229 children: vec![],
230 }],
231 },
232 ],
233 },
234 NodeSnapshot {
235 name: "maintenance_job_with_long_name".to_string(),
236 target: "app".to_string(),
237 module_path: None,
238 line: None,
239 total_calls: 12,
240 wall: TimingStats {
241 samples: 12,
242 min: Duration::from_secs(2),
243 max: Duration::from_secs(2),
244 mean: Duration::from_secs(2),
245 p95: Duration::from_secs(2),
246 },
247 active: TimingStats::from_nanos(std::iter::empty()),
248 suspended: TimingStats::from_nanos(std::iter::empty()),
249 children: vec![],
250 },
251 ],
252 };
253
254 let rendered = snapshot.to_string();
255
256 let name_width = "maintenance_job_with_long_name".len();
257 let avg_width = "20.0ms avg".len();
258 let p95_width = "20.0ms p95".len();
259 let samples_width = "n=12".len();
260
261 let line_root = format!(
262 "{:<name_width$} {:>avg_width$} {:>p95_width$} {:>samples_width$}",
263 "maintenance_job_with_long_name", "2.0s avg", "2.0s p95", "n=12",
264 );
265
266 let line_request = format!(
267 "{:<name_width$} {:>avg_width$} {:>p95_width$} {:>samples_width$}",
268 "request",
269 format!("{} avg", format_duration(Duration::from_millis(10))),
270 format!("{} p95", format_duration(Duration::from_millis(12))),
271 "n=4",
272 );
273
274 let line_auth = format!(
275 "{:<name_width$} {:>avg_width$} {:>p95_width$} {:>samples_width$}",
276 "├── authenticate",
277 format!("{} avg", format_duration(Duration::from_millis(5))),
278 format!("{} p95", format_duration(Duration::from_millis(5))),
279 "n=1",
280 );
281
282 let line_db = format!(
283 "{:<name_width$} {:>avg_width$} {:>p95_width$} {:>samples_width$}",
284 "└── database",
285 format!("{} avg", format_duration(Duration::from_millis(20))),
286 format!("{} p95", format_duration(Duration::from_millis(20))),
287 "n=1",
288 );
289
290 let line_query = format!(
291 "{:<name_width$} {:>avg_width$} {:>p95_width$} {:>samples_width$}",
292 " └── query",
293 format!("{} avg", format_duration(Duration::from_millis(1))),
294 format!("{} p95", format_duration(Duration::from_millis(1))),
295 "n=1",
296 );
297
298 let expected = format!(
299 "{}\n{}\n{}\n{}\n{}",
300 line_root, line_request, line_auth, line_db, line_query
301 );
302
303 assert_eq!(rendered, expected);
304 }
305}