1use crate::diff::{DiffNode, DiffStatus};
2use crate::engine::{DiffReport, TreeReport};
3use crate::model::CallRelation;
4
5pub fn render_report(report: &DiffReport) -> String {
6 render_report_with_options(report, &RenderOptions::default())
7}
8
9#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
10pub enum ColorMode {
11 #[default]
12 Ansi,
13 Plain,
14}
15
16#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
17pub struct RenderOptions {
18 pub show_types: bool,
19 pub color: ColorMode,
20}
21
22pub fn render_report_with_options(report: &DiffReport, options: &RenderOptions) -> String {
23 let mut parts = vec![
24 format!(
25 "{}diff {} → {}",
26 report.language, report.before, report.after
27 ),
28 String::new(),
29 ];
30
31 if let Some(message) = &report.message {
32 parts.push(message.clone());
33 return parts.join("\n");
34 }
35
36 for (index, entry) in report.trees.iter().enumerate() {
37 if index > 0 {
38 parts.push(String::new());
39 }
40 parts.push(render_diff_tree_with_options(&entry.tree, options));
41 }
42 parts.join("\n")
43}
44
45pub fn render_tree_report_with_options(report: &TreeReport, options: &RenderOptions) -> String {
46 let mut parts = vec![report.source.clone(), String::new()];
47 if let Some(message) = &report.message {
48 parts.push(message.clone());
49 return parts.join("\n");
50 }
51 for (index, entry) in report.trees.iter().enumerate() {
52 if index > 0 {
53 parts.push(String::new());
54 }
55 parts.push(render_call_tree_with_options(&entry.tree, options));
56 }
57 parts.join("\n")
58}
59
60pub fn render_call_tree_with_options(
61 root: &crate::model::CallNode,
62 options: &RenderOptions,
63) -> String {
64 let diff = crate::diff::diff_optional(Some(root), Some(root))
65 .expect("a call tree always produces a diff node");
66 render_diff_tree_with_options(&diff, options)
67}
68
69pub fn render_diff_tree(root: &DiffNode) -> String {
70 render_diff_tree_with_options(root, &RenderOptions::default())
71}
72
73pub fn render_diff_tree_with_options(root: &DiffNode, options: &RenderOptions) -> String {
74 let mut lines = Vec::new();
75 render_node(root, "", true, true, options, &mut lines);
76 connect_back_edges(&mut lines);
77 lines
78 .into_iter()
79 .map(|line| match line.color {
80 Some(color) => color_line(line.text, color, options.color),
81 None => line.text,
82 })
83 .collect::<Vec<_>>()
84 .join("\n")
85}
86
87#[derive(Debug)]
88struct RenderLine {
89 text: String,
90 color: Option<AnsiColor>,
91 key: Option<String>,
92 backedge_target: Option<String>,
93}
94
95fn render_node(
96 node: &DiffNode,
97 indent: &str,
98 is_last: bool,
99 is_root: bool,
100 options: &RenderOptions,
101 lines: &mut Vec<RenderLine>,
102) {
103 let branch_prefix = branch(node.relation, is_last, is_root);
104 if node.relation == CallRelation::BackEdge {
105 let (marker, color) = match node.status {
106 DiffStatus::Same => (" ", None),
107 DiffStatus::Added => ("+ ", Some(AnsiColor::Green)),
108 DiffStatus::Removed => ("- ", Some(AnsiColor::Red)),
109 DiffStatus::Modified => ("+ ", Some(AnsiColor::Green)),
110 };
111 lines.push(RenderLine {
112 text: format!(
113 "{marker}{indent}{branch_prefix}{}",
114 node.label.text(options.show_types)
115 ),
116 color,
117 key: None,
118 backedge_target: Some(node.key.clone()),
119 });
120 return;
121 }
122
123 match node.status {
124 DiffStatus::Same => lines.push(RenderLine {
125 text: format!(
126 " {indent}{branch_prefix}{}",
127 node.label.text(options.show_types)
128 ),
129 color: None,
130 key: backedge_anchor(node),
131 backedge_target: None,
132 }),
133 DiffStatus::Added => lines.push(RenderLine {
134 text: format!(
135 "+ {indent}{branch_prefix}{}",
136 node.label.text(options.show_types)
137 ),
138 color: Some(AnsiColor::Green),
139 key: backedge_anchor(node),
140 backedge_target: None,
141 }),
142 DiffStatus::Removed => lines.push(RenderLine {
143 text: format!(
144 "- {indent}{branch_prefix}{}",
145 node.label.text(options.show_types)
146 ),
147 color: Some(AnsiColor::Red),
148 key: backedge_anchor(node),
149 backedge_target: None,
150 }),
151 DiffStatus::Modified => {
152 let before = node
153 .before_label
154 .as_ref()
155 .expect("modified nodes carry their previous label");
156 lines.push(RenderLine {
157 text: format!(
158 "- {indent}{}{}",
159 branch(
160 node.before_relation.unwrap_or(node.relation),
161 is_last,
162 is_root
163 ),
164 before.text(options.show_types)
165 ),
166 color: Some(AnsiColor::Red),
167 key: backedge_anchor(node),
168 backedge_target: None,
169 });
170 lines.push(RenderLine {
171 text: format!(
172 "+ {indent}{branch_prefix}{}",
173 node.label.text(options.show_types)
174 ),
175 color: Some(AnsiColor::Green),
176 key: backedge_anchor(node),
177 backedge_target: None,
178 });
179 }
180 }
181
182 let child_indent = if is_root {
183 String::new()
184 } else if is_last {
185 format!("{indent} ")
186 } else {
187 let continuation = match node.relation {
188 CallRelation::Call => "│ ",
189 CallRelation::DispatchCandidate => "║ ",
190 CallRelation::BackEdge => unreachable!("back edges have no children"),
191 };
192 format!("{indent}{continuation}")
193 };
194 for (index, child) in node.children.iter().enumerate() {
195 render_node(
196 child,
197 &child_indent,
198 index + 1 == node.children.len(),
199 false,
200 options,
201 lines,
202 );
203 }
204}
205
206fn backedge_anchor(node: &DiffNode) -> Option<String> {
207 (!node.children.is_empty()).then(|| node.key.clone())
208}
209
210fn branch(relation: CallRelation, is_last: bool, is_root: bool) -> &'static str {
211 if is_root {
212 ""
213 } else {
214 match (relation, is_last) {
215 (CallRelation::Call, false) => "├─ ",
216 (CallRelation::Call, true) => "└─ ",
217 (CallRelation::DispatchCandidate, false) => "╠═ ",
218 (CallRelation::DispatchCandidate, true) => "╚═ ",
219 (CallRelation::BackEdge, false) => "├─ ",
220 (CallRelation::BackEdge, true) => "└─ ",
221 }
222 }
223}
224
225fn connect_back_edges(lines: &mut [RenderLine]) {
226 let edges = lines
227 .iter()
228 .enumerate()
229 .filter_map(|(end, line)| {
230 let target = line.backedge_target.as_ref()?;
231 let start = lines[..end]
232 .iter()
233 .rposition(|candidate| candidate.key.as_ref() == Some(target))?;
234 Some((start, end))
235 })
236 .collect::<Vec<_>>();
237 if edges.is_empty() {
238 return;
239 }
240
241 let original_lengths = lines
242 .iter()
243 .map(|line| line.text.trim_end().chars().count())
244 .collect::<Vec<_>>();
245 let base_rail = original_lengths.iter().copied().max().unwrap_or(0) + 3;
246 let mut rows = lines
247 .iter()
248 .map(|line| line.text.trim_end().chars().collect::<Vec<_>>())
249 .collect::<Vec<_>>();
250
251 let mut grouped = std::collections::BTreeMap::<usize, Vec<usize>>::new();
252 for (start, end) in edges {
253 grouped.entry(start).or_default().push(end);
254 }
255
256 for (rail_index, (start, ends)) in grouped.into_iter().enumerate() {
257 let rail = base_rail + rail_index * 3;
258 let arrow = original_lengths[start] + 1;
259 set_glyph(&mut rows[start], arrow, '◀');
260 for column in arrow + 1..rail {
261 set_glyph(&mut rows[start], column, '─');
262 }
263 set_glyph(&mut rows[start], rail, '┐');
264
265 let last_end = *ends.last().expect("a back-edge group is never empty");
266 for row in rows.iter_mut().take(last_end).skip(start + 1) {
267 set_glyph(row, rail, '│');
268 }
269 for end in ends {
270 for column in original_lengths[end] + 1..rail {
271 set_glyph(&mut rows[end], column, '─');
272 }
273 set_glyph(
274 &mut rows[end],
275 rail,
276 if end == last_end { '┘' } else { '┤' },
277 );
278 }
279 }
280
281 for (line, row) in lines.iter_mut().zip(rows) {
282 line.text = row.into_iter().collect::<String>().trim_end().to_owned();
283 }
284}
285
286fn set_glyph(row: &mut Vec<char>, column: usize, glyph: char) {
287 if row.len() <= column {
288 row.resize(column + 1, ' ');
289 }
290 row[column] = merge_glyph(row[column], glyph);
291}
292
293fn merge_glyph(existing: char, next: char) -> char {
294 match (existing, next) {
295 (' ', next) => next,
296 ('─', '│') | ('│', '─') => '┼',
297 (_, next) => next,
298 }
299}
300
301#[derive(Clone, Copy, Debug)]
302enum AnsiColor {
303 Red,
304 Green,
305}
306
307fn color_line(line: String, color: AnsiColor, mode: ColorMode) -> String {
308 if mode == ColorMode::Plain {
309 return line;
310 }
311 let code = match color {
312 AnsiColor::Red => 31,
313 AnsiColor::Green => 32,
314 };
315 format!("\u{1b}[{code}m{line}\u{1b}[0m")
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::diff::diff_optional;
322 use crate::model::{CallLabel, CallNode, CallRelation};
323
324 #[test]
325 fn ansi_is_the_default_color_mode() {
326 assert_eq!(ColorMode::default(), ColorMode::Ansi);
327 assert_eq!(RenderOptions::default().color, ColorMode::Ansi);
328 }
329
330 #[test]
331 fn ansi_mode_colors_complete_diff_lines() {
332 assert_eq!(
333 color_line(
334 "+ └─ commit()".to_owned(),
335 AnsiColor::Green,
336 ColorMode::Ansi
337 ),
338 "\u{1b}[32m+ └─ commit()\u{1b}[0m"
339 );
340 assert_eq!(
341 color_line(
342 "- └─ rollback()".to_owned(),
343 AnsiColor::Red,
344 ColorMode::Ansi
345 ),
346 "\u{1b}[31m- └─ rollback()\u{1b}[0m"
347 );
348 }
349
350 #[test]
351 fn plain_mode_has_no_terminal_control_sequences() {
352 assert_eq!(
353 color_line(
354 "+ └─ commit()".to_owned(),
355 AnsiColor::Green,
356 ColorMode::Plain
357 ),
358 "+ └─ commit()"
359 );
360 }
361
362 #[test]
363 fn renders_dispatch_candidates_with_a_complete_double_line_relation() {
364 let tree = CallNode {
365 key: "rust://run".to_owned(),
366 callsite: None,
367 label: CallLabel::new("run(store, order)"),
368 relation: CallRelation::Call,
369 children: vec![CallNode {
370 key: "rust://Store::save".to_owned(),
371 callsite: None,
372 label: CallLabel::new("dyn Store::save(order)"),
373 relation: CallRelation::Call,
374 children: vec![
375 CallNode {
376 key: "rust://Postgres::save".to_owned(),
377 callsite: None,
378 label: CallLabel::new("Postgres::save(order)"),
379 relation: CallRelation::DispatchCandidate,
380 children: vec![CallNode {
381 key: "rust://sql::insert".to_owned(),
382 callsite: None,
383 label: CallLabel::new("sql::insert(order)"),
384 relation: CallRelation::Call,
385 children: Vec::new(),
386 }],
387 },
388 CallNode {
389 key: "rust://S3::save".to_owned(),
390 callsite: None,
391 label: CallLabel::new("S3::save(order)"),
392 relation: CallRelation::DispatchCandidate,
393 children: vec![CallNode {
394 key: "rust://aws::put_object".to_owned(),
395 callsite: None,
396 label: CallLabel::new("aws::put_object(order)"),
397 relation: CallRelation::Call,
398 children: Vec::new(),
399 }],
400 },
401 ],
402 }],
403 };
404 let diff = diff_optional(Some(&tree), Some(&tree)).unwrap();
405
406 assert_eq!(
407 render_diff_tree_with_options(
408 &diff,
409 &RenderOptions {
410 show_types: false,
411 color: ColorMode::Plain,
412 },
413 ),
414 " run(store, order)\n └─ dyn Store::save(order)\n ╠═ Postgres::save(order)\n ║ └─ sql::insert(order)\n ╚═ S3::save(order)\n └─ aws::put_object(order)"
415 );
416 }
417
418 #[test]
419 fn renders_recursive_calls_as_right_side_back_edges() {
420 let tree = CallNode {
421 key: "rust://a".to_owned(),
422 callsite: None,
423 label: CallLabel::new("a()"),
424 relation: CallRelation::Call,
425 children: vec![CallNode {
426 key: "rust://b".to_owned(),
427 callsite: None,
428 label: CallLabel::new("b()"),
429 relation: CallRelation::Call,
430 children: vec![CallNode {
431 key: "rust://a".to_owned(),
432 callsite: None,
433 label: CallLabel::new("a()"),
434 relation: CallRelation::BackEdge,
435 children: Vec::new(),
436 }],
437 }],
438 };
439
440 assert_eq!(
441 render_call_tree_with_options(
442 &tree,
443 &RenderOptions {
444 show_types: false,
445 color: ColorMode::Plain,
446 },
447 ),
448 " a() ◀───────┐\n └─ b() │\n └─ a() ──┘"
449 );
450 }
451
452 #[test]
453 fn shares_one_rail_between_calls_to_the_same_recursive_ancestor() {
454 let back_edge = |label: &str| CallNode {
455 key: "ocaml://walk".to_owned(),
456 callsite: None,
457 label: CallLabel::new(label),
458 relation: CallRelation::BackEdge,
459 children: Vec::new(),
460 };
461 let tree = CallNode {
462 key: "ocaml://walk".to_owned(),
463 callsite: None,
464 label: CallLabel::new("walk value"),
465 relation: CallRelation::Call,
466 children: vec![
467 back_edge("walk left"),
468 back_edge("walk right"),
469 back_edge("walk tail"),
470 ],
471 };
472
473 let rendered = render_call_tree_with_options(
474 &tree,
475 &RenderOptions {
476 show_types: false,
477 color: ColorMode::Plain,
478 },
479 );
480 assert_eq!(rendered.matches('┐').count(), 1, "{rendered}");
481 assert_eq!(rendered.matches('┤').count(), 2, "{rendered}");
482 assert_eq!(rendered.matches('┘').count(), 1, "{rendered}");
483 assert!(rendered.lines().all(|line| line.chars().count() < 32));
484 }
485
486 #[test]
487 fn keeps_partial_and_unresolved_dispatch_wording_distinct() {
488 let tree = CallNode {
489 key: "rust://run".to_owned(),
490 callsite: None,
491 label: CallLabel::new("run(store)"),
492 relation: CallRelation::Call,
493 children: vec![
494 CallNode {
495 key: "rust://Store::save#partial".to_owned(),
496 callsite: None,
497 label: CallLabel::new("dyn Store::save() [partial]"),
498 relation: CallRelation::Call,
499 children: vec![
500 CallNode {
501 key: "rust://Postgres::save".to_owned(),
502 callsite: None,
503 label: CallLabel::new("Postgres::save()"),
504 relation: CallRelation::DispatchCandidate,
505 children: Vec::new(),
506 },
507 CallNode {
508 key: "rust://Store::save#unresolved".to_owned(),
509 callsite: None,
510 label: CallLabel::new("… unresolved targets"),
511 relation: CallRelation::DispatchCandidate,
512 children: Vec::new(),
513 },
514 ],
515 },
516 CallNode {
517 key: "rust://Store::load".to_owned(),
518 callsite: None,
519 label: CallLabel::new("dyn Store::load() [unresolved]"),
520 relation: CallRelation::Call,
521 children: Vec::new(),
522 },
523 ],
524 };
525
526 assert_eq!(
527 render_call_tree_with_options(
528 &tree,
529 &RenderOptions {
530 show_types: false,
531 color: ColorMode::Plain,
532 },
533 ),
534 " run(store)\n ├─ dyn Store::save() [partial]\n │ ╠═ Postgres::save()\n │ ╚═ … unresolved targets\n └─ dyn Store::load() [unresolved]"
535 );
536 }
537}