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!("{marker}{indent}{branch_prefix}"),
113 color,
114 key: None,
115 backedge_target: Some(node.key.clone()),
116 });
117 return;
118 }
119
120 match node.status {
121 DiffStatus::Same => lines.push(RenderLine {
122 text: format!(
123 " {indent}{branch_prefix}{}",
124 node.label.text(options.show_types)
125 ),
126 color: None,
127 key: Some(node.key.clone()),
128 backedge_target: None,
129 }),
130 DiffStatus::Added => lines.push(RenderLine {
131 text: format!(
132 "+ {indent}{branch_prefix}{}",
133 node.label.text(options.show_types)
134 ),
135 color: Some(AnsiColor::Green),
136 key: Some(node.key.clone()),
137 backedge_target: None,
138 }),
139 DiffStatus::Removed => lines.push(RenderLine {
140 text: format!(
141 "- {indent}{branch_prefix}{}",
142 node.label.text(options.show_types)
143 ),
144 color: Some(AnsiColor::Red),
145 key: Some(node.key.clone()),
146 backedge_target: None,
147 }),
148 DiffStatus::Modified => {
149 let before = node
150 .before_label
151 .as_ref()
152 .expect("modified nodes carry their previous label");
153 lines.push(RenderLine {
154 text: format!(
155 "- {indent}{}{}",
156 branch(
157 node.before_relation.unwrap_or(node.relation),
158 is_last,
159 is_root
160 ),
161 before.text(options.show_types)
162 ),
163 color: Some(AnsiColor::Red),
164 key: Some(node.key.clone()),
165 backedge_target: None,
166 });
167 lines.push(RenderLine {
168 text: format!(
169 "+ {indent}{branch_prefix}{}",
170 node.label.text(options.show_types)
171 ),
172 color: Some(AnsiColor::Green),
173 key: Some(node.key.clone()),
174 backedge_target: None,
175 });
176 }
177 }
178
179 let child_indent = if is_root {
180 String::new()
181 } else if is_last {
182 format!("{indent} ")
183 } else {
184 let continuation = match node.relation {
185 CallRelation::Call => "│ ",
186 CallRelation::DispatchCandidate => "║ ",
187 CallRelation::BackEdge => unreachable!("back edges have no children"),
188 };
189 format!("{indent}{continuation}")
190 };
191 for (index, child) in node.children.iter().enumerate() {
192 render_node(
193 child,
194 &child_indent,
195 index + 1 == node.children.len(),
196 false,
197 options,
198 lines,
199 );
200 }
201}
202
203fn branch(relation: CallRelation, is_last: bool, is_root: bool) -> &'static str {
204 if is_root {
205 ""
206 } else {
207 match (relation, is_last) {
208 (CallRelation::Call, false) => "├─ ",
209 (CallRelation::Call, true) => "└─ ",
210 (CallRelation::DispatchCandidate, false) => "╠═ ",
211 (CallRelation::DispatchCandidate, true) => "╚═ ",
212 (CallRelation::BackEdge, false) => "├─ ",
213 (CallRelation::BackEdge, true) => "└─ ",
214 }
215 }
216}
217
218fn connect_back_edges(lines: &mut [RenderLine]) {
219 let edges = lines
220 .iter()
221 .enumerate()
222 .filter_map(|(end, line)| {
223 let target = line.backedge_target.as_ref()?;
224 let start = lines[..end]
225 .iter()
226 .rposition(|candidate| candidate.key.as_ref() == Some(target))?;
227 Some((start, end))
228 })
229 .collect::<Vec<_>>();
230 if edges.is_empty() {
231 return;
232 }
233
234 let original_lengths = lines
235 .iter()
236 .map(|line| line.text.trim_end().chars().count())
237 .collect::<Vec<_>>();
238 let base_rail = original_lengths.iter().copied().max().unwrap_or(0) + 3;
239 let mut rows = lines
240 .iter()
241 .map(|line| line.text.trim_end().chars().collect::<Vec<_>>())
242 .collect::<Vec<_>>();
243
244 for (edge_index, (start, end)) in edges.into_iter().enumerate() {
245 let rail = base_rail + edge_index * 3;
246 let arrow = original_lengths[start] + 1;
247 set_glyph(&mut rows[start], arrow, '◀');
248 for column in arrow + 1..rail {
249 set_glyph(&mut rows[start], column, '─');
250 }
251 set_glyph(&mut rows[start], rail, '┐');
252
253 for row in rows.iter_mut().take(end).skip(start + 1) {
254 set_glyph(row, rail, '│');
255 }
256 for column in original_lengths[end]..rail {
257 set_glyph(&mut rows[end], column, '─');
258 }
259 set_glyph(&mut rows[end], rail, '┘');
260 }
261
262 for (line, row) in lines.iter_mut().zip(rows) {
263 line.text = row.into_iter().collect::<String>().trim_end().to_owned();
264 }
265}
266
267fn set_glyph(row: &mut Vec<char>, column: usize, glyph: char) {
268 if row.len() <= column {
269 row.resize(column + 1, ' ');
270 }
271 row[column] = merge_glyph(row[column], glyph);
272}
273
274fn merge_glyph(existing: char, next: char) -> char {
275 match (existing, next) {
276 (' ', next) => next,
277 ('─', '│') | ('│', '─') => '┼',
278 (_, next) => next,
279 }
280}
281
282#[derive(Clone, Copy, Debug)]
283enum AnsiColor {
284 Red,
285 Green,
286}
287
288fn color_line(line: String, color: AnsiColor, mode: ColorMode) -> String {
289 if mode == ColorMode::Plain {
290 return line;
291 }
292 let code = match color {
293 AnsiColor::Red => 31,
294 AnsiColor::Green => 32,
295 };
296 format!("\u{1b}[{code}m{line}\u{1b}[0m")
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::diff::diff_optional;
303 use crate::model::{CallLabel, CallNode, CallRelation};
304
305 #[test]
306 fn ansi_is_the_default_color_mode() {
307 assert_eq!(ColorMode::default(), ColorMode::Ansi);
308 assert_eq!(RenderOptions::default().color, ColorMode::Ansi);
309 }
310
311 #[test]
312 fn ansi_mode_colors_complete_diff_lines() {
313 assert_eq!(
314 color_line(
315 "+ └─ commit()".to_owned(),
316 AnsiColor::Green,
317 ColorMode::Ansi
318 ),
319 "\u{1b}[32m+ └─ commit()\u{1b}[0m"
320 );
321 assert_eq!(
322 color_line(
323 "- └─ rollback()".to_owned(),
324 AnsiColor::Red,
325 ColorMode::Ansi
326 ),
327 "\u{1b}[31m- └─ rollback()\u{1b}[0m"
328 );
329 }
330
331 #[test]
332 fn plain_mode_has_no_terminal_control_sequences() {
333 assert_eq!(
334 color_line(
335 "+ └─ commit()".to_owned(),
336 AnsiColor::Green,
337 ColorMode::Plain
338 ),
339 "+ └─ commit()"
340 );
341 }
342
343 #[test]
344 fn renders_dispatch_candidates_with_a_complete_double_line_relation() {
345 let tree = CallNode {
346 key: "rust://run".to_owned(),
347 callsite: None,
348 label: CallLabel::new("run(store, order)"),
349 relation: CallRelation::Call,
350 children: vec![CallNode {
351 key: "rust://Store::save".to_owned(),
352 callsite: None,
353 label: CallLabel::new("dyn Store::save(order)"),
354 relation: CallRelation::Call,
355 children: vec![
356 CallNode {
357 key: "rust://Postgres::save".to_owned(),
358 callsite: None,
359 label: CallLabel::new("Postgres::save(order)"),
360 relation: CallRelation::DispatchCandidate,
361 children: vec![CallNode {
362 key: "rust://sql::insert".to_owned(),
363 callsite: None,
364 label: CallLabel::new("sql::insert(order)"),
365 relation: CallRelation::Call,
366 children: Vec::new(),
367 }],
368 },
369 CallNode {
370 key: "rust://S3::save".to_owned(),
371 callsite: None,
372 label: CallLabel::new("S3::save(order)"),
373 relation: CallRelation::DispatchCandidate,
374 children: vec![CallNode {
375 key: "rust://aws::put_object".to_owned(),
376 callsite: None,
377 label: CallLabel::new("aws::put_object(order)"),
378 relation: CallRelation::Call,
379 children: Vec::new(),
380 }],
381 },
382 ],
383 }],
384 };
385 let diff = diff_optional(Some(&tree), Some(&tree)).unwrap();
386
387 assert_eq!(
388 render_diff_tree_with_options(
389 &diff,
390 &RenderOptions {
391 show_types: false,
392 color: ColorMode::Plain,
393 },
394 ),
395 " 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)"
396 );
397 }
398
399 #[test]
400 fn renders_recursive_calls_as_right_side_back_edges() {
401 let tree = CallNode {
402 key: "rust://a".to_owned(),
403 callsite: None,
404 label: CallLabel::new("a()"),
405 relation: CallRelation::Call,
406 children: vec![CallNode {
407 key: "rust://b".to_owned(),
408 callsite: None,
409 label: CallLabel::new("b()"),
410 relation: CallRelation::Call,
411 children: vec![CallNode {
412 key: "rust://a".to_owned(),
413 callsite: None,
414 label: CallLabel::new(""),
415 relation: CallRelation::BackEdge,
416 children: Vec::new(),
417 }],
418 }],
419 };
420
421 assert_eq!(
422 render_call_tree_with_options(
423 &tree,
424 &RenderOptions {
425 show_types: false,
426 color: ColorMode::Plain,
427 },
428 ),
429 " a() ◀────┐\n └─ b() │\n └─────┘"
430 );
431 }
432
433 #[test]
434 fn keeps_partial_and_unresolved_dispatch_wording_distinct() {
435 let tree = CallNode {
436 key: "rust://run".to_owned(),
437 callsite: None,
438 label: CallLabel::new("run(store)"),
439 relation: CallRelation::Call,
440 children: vec![
441 CallNode {
442 key: "rust://Store::save#partial".to_owned(),
443 callsite: None,
444 label: CallLabel::new("dyn Store::save() [partial]"),
445 relation: CallRelation::Call,
446 children: vec![
447 CallNode {
448 key: "rust://Postgres::save".to_owned(),
449 callsite: None,
450 label: CallLabel::new("Postgres::save()"),
451 relation: CallRelation::DispatchCandidate,
452 children: Vec::new(),
453 },
454 CallNode {
455 key: "rust://Store::save#unresolved".to_owned(),
456 callsite: None,
457 label: CallLabel::new("… unresolved targets"),
458 relation: CallRelation::DispatchCandidate,
459 children: Vec::new(),
460 },
461 ],
462 },
463 CallNode {
464 key: "rust://Store::load".to_owned(),
465 callsite: None,
466 label: CallLabel::new("dyn Store::load() [unresolved]"),
467 relation: CallRelation::Call,
468 children: Vec::new(),
469 },
470 ],
471 };
472
473 assert_eq!(
474 render_call_tree_with_options(
475 &tree,
476 &RenderOptions {
477 show_types: false,
478 color: ColorMode::Plain,
479 },
480 ),
481 " run(store)\n ├─ dyn Store::save() [partial]\n │ ╠═ Postgres::save()\n │ ╚═ … unresolved targets\n └─ dyn Store::load() [unresolved]"
482 );
483 }
484}