1use crate::correlate::Trace;
4use crate::detect::Finding;
5use crate::normalize::NormalizedEvent;
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct SpanNode {
12 pub span_id: String,
13 pub parent_span_id: Option<String>,
14 pub service: String,
15 pub template: String,
16 pub operation: String,
17 pub duration_us: u64,
18 pub timestamp: String,
19 pub children: Vec<SpanNode>,
20 #[serde(default, skip_serializing_if = "Vec::is_empty")]
21 pub findings: Vec<InlineFinding>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct InlineFinding {
27 #[serde(rename = "type")]
28 pub finding_type: String,
29 pub severity: String,
30 pub occurrences: usize,
31 pub suggestion: String,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub suggested_fix: Option<crate::detect::suggestions::SuggestedFix>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub code_location: Option<crate::event::CodeLocation>,
42}
43
44impl InlineFinding {
45 fn from_finding(f: &Finding) -> Self {
47 Self {
48 finding_type: f.finding_type.as_str().to_string(),
49 severity: f.severity.as_str().to_string(),
50 occurrences: f.pattern.occurrences,
51 suggestion: f.suggestion.clone(),
52 suggested_fix: f.suggested_fix.clone(),
53 code_location: f.code_location.clone(),
54 }
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ExplainTree {
61 pub trace_id: String,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
72 pub trace_level_findings: Vec<InlineFinding>,
73 pub roots: Vec<SpanNode>,
74}
75
76#[must_use]
78pub fn build_tree(trace: &Trace, findings: &[Finding]) -> ExplainTree {
79 let span_templates: HashSet<&str> = trace.spans.iter().map(|s| s.template.as_ref()).collect();
83
84 let mut findings_by_template: HashMap<&str, Vec<&Finding>> = HashMap::new();
89 let mut trace_level_findings: Vec<InlineFinding> = Vec::new();
90 for f in findings {
91 if f.trace_id != trace.trace_id {
92 continue;
93 }
94 if span_templates.contains(f.pattern.template.as_str()) {
95 findings_by_template
96 .entry(f.pattern.template.as_str())
97 .or_default()
98 .push(f);
99 } else {
100 trace_level_findings.push(InlineFinding::from_finding(f));
101 }
102 }
103
104 let nodes: Vec<SpanNode> = trace
105 .spans
106 .iter()
107 .map(|span| make_node(span, &findings_by_template))
108 .collect();
109
110 let mut by_id: HashMap<&str, usize> = HashMap::new();
112 for (i, node) in nodes.iter().enumerate() {
113 by_id.insert(node.span_id.as_str(), i);
114 }
115
116 let mut children_map: HashMap<usize, Vec<usize>> = HashMap::new();
118 let mut roots = Vec::new();
119
120 for (i, span) in trace.spans.iter().enumerate() {
121 if let Some(ref parent_id) = span.event.parent_span_id
122 && let Some(&parent_idx) = by_id.get(parent_id.as_str())
123 {
124 children_map.entry(parent_idx).or_default().push(i);
125 continue;
126 }
127 roots.push(i);
128 }
129
130 let mut nodes: Vec<Option<SpanNode>> = nodes.into_iter().map(Some).collect();
132 let root_nodes = roots
133 .iter()
134 .map(|&idx| assemble_node(idx, &mut nodes, &children_map, 0))
135 .collect();
136
137 ExplainTree {
138 trace_id: trace.trace_id.clone(),
139 trace_level_findings,
140 roots: root_nodes,
141 }
142}
143
144fn make_node(
145 span: &NormalizedEvent,
146 findings_by_template: &HashMap<&str, Vec<&Finding>>,
147) -> SpanNode {
148 let inline_findings = findings_by_template
149 .get(span.template.as_ref())
150 .map(|fs| fs.iter().map(|f| InlineFinding::from_finding(f)).collect())
151 .unwrap_or_default();
152
153 SpanNode {
154 span_id: span.event.span_id.clone(),
155 parent_span_id: span.event.parent_span_id.clone(),
156 service: span.event.service.to_string(),
157 template: span.template.to_string(),
158 operation: span.event.operation.clone(),
159 duration_us: span.event.duration_us,
160 timestamp: span.event.timestamp.clone(),
161 children: Vec::new(),
162 findings: inline_findings,
163 }
164}
165
166const MAX_TREE_DEPTH: usize = 256;
168
169fn assemble_node(
170 idx: usize,
171 nodes: &mut [Option<SpanNode>],
172 children_map: &HashMap<usize, Vec<usize>>,
173 depth: usize,
174) -> SpanNode {
175 let Some(mut node) = nodes[idx].take() else {
178 return SpanNode {
179 span_id: String::new(),
180 parent_span_id: None,
181 service: String::new(),
182 template: "(cycle detected)".to_string(),
183 operation: String::new(),
184 duration_us: 0,
185 timestamp: String::new(),
186 children: Vec::new(),
187 findings: Vec::new(),
188 };
189 };
190 if depth < MAX_TREE_DEPTH
191 && let Some(child_indices) = children_map.get(&idx)
192 {
193 node.children = child_indices
194 .iter()
195 .map(|&ci| assemble_node(ci, nodes, children_map, depth + 1))
196 .collect();
197 node.children.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
198 }
199 node
200}
201
202struct TreeColors {
204 bold: &'static str,
205 cyan: &'static str,
206 red: &'static str,
207 yellow: &'static str,
208 dim: &'static str,
209 reset: &'static str,
210}
211
212#[must_use]
214pub fn format_tree_text(tree: &ExplainTree, use_color: bool) -> String {
215 use std::fmt::Write;
216
217 let colors = if use_color {
218 TreeColors {
219 bold: "\x1b[1m",
220 cyan: "\x1b[36m",
221 red: "\x1b[31m",
222 yellow: "\x1b[33m",
223 dim: "\x1b[2m",
224 reset: "\x1b[0m",
225 }
226 } else {
227 TreeColors {
228 bold: "",
229 cyan: "",
230 red: "",
231 yellow: "",
232 dim: "",
233 reset: "",
234 }
235 };
236
237 let mut out = String::new();
238 let _ = writeln!(
239 out,
240 "{}{cyan}Trace {}{}",
241 colors.bold,
242 tree.trace_id,
243 colors.reset,
244 cyan = colors.cyan
245 );
246
247 format_trace_level_findings(&mut out, &tree.trace_level_findings, &colors);
248
249 for (i, root) in tree.roots.iter().enumerate() {
250 let is_last = i == tree.roots.len() - 1;
251 format_node(&mut out, root, "", is_last, &colors, 0);
252 }
253
254 out
255}
256
257fn format_trace_level_findings(out: &mut String, findings: &[InlineFinding], c: &TreeColors) {
263 use crate::text_safety::sanitize_for_terminal;
264 use std::fmt::Write;
265
266 if findings.is_empty() {
267 return;
268 }
269
270 let _ = writeln!(
271 out,
272 "{}{}\u{26a0} Trace-level findings:{}",
273 c.bold, c.yellow, c.reset,
274 );
275 for f in findings {
276 let severity_color = match f.severity.as_str() {
277 "critical" => c.red,
278 "warning" => c.yellow,
279 _ => c.dim,
280 };
281 let _ = writeln!(
282 out,
283 " {severity_color}\u{2022} {} {} (\u{00d7}{}){}",
284 sanitize_for_terminal(&f.finding_type.replace('_', " ")),
285 sanitize_for_terminal(&f.severity),
286 f.occurrences,
287 c.reset,
288 );
289 write_finding_details(out, " ", f, c);
290 }
291 let _ = writeln!(out);
292}
293
294fn write_finding_details(out: &mut String, prefix: &str, f: &InlineFinding, c: &TreeColors) {
297 use crate::text_safety::{safe_url, sanitize_for_terminal};
298 use std::fmt::Write;
299
300 let mut lines: Vec<String> = Vec::with_capacity(3);
301 lines.push(format!(
302 "suggestion: {}",
303 sanitize_for_terminal(&f.suggestion)
304 ));
305 if let Some(ref fix) = f.suggested_fix {
306 let url_part = match fix.reference_url.as_deref().and_then(safe_url) {
307 Some(u) => format!(" ({u})"),
308 None => String::new(),
309 };
310 lines.push(format!(
311 "fix [{}]: {}{url_part}",
312 sanitize_for_terminal(&fix.framework),
313 sanitize_for_terminal(&fix.recommendation),
314 ));
315 }
316 if let Some(ref loc) = f.code_location {
317 let s = loc.display_string();
318 if !s.is_empty() {
319 lines.push(format!("location: {}", sanitize_for_terminal(&s)));
320 }
321 }
322
323 let last = lines.len().saturating_sub(1);
324 for (i, line) in lines.iter().enumerate() {
325 let connector = if i == last {
326 "\u{2514}\u{2500}"
327 } else {
328 "\u{251c}\u{2500}"
329 };
330 let _ = writeln!(out, "{prefix}{}{connector} {line}{}", c.dim, c.reset);
331 }
332}
333
334fn format_node(
335 out: &mut String,
336 node: &SpanNode,
337 prefix: &str,
338 is_last: bool,
339 c: &TreeColors,
340 depth: usize,
341) {
342 use crate::text_safety::sanitize_for_terminal;
343 use std::fmt::Write;
344
345 let connector = if is_last {
346 "\u{2514}\u{2500} "
347 } else {
348 "\u{251c}\u{2500} "
349 };
350 let duration_str = format_duration(node.duration_us);
351
352 let _ = write!(
353 out,
354 "{prefix}{connector}{}{}{} {}({duration_str}){}",
355 c.bold,
356 sanitize_for_terminal(&node.template),
357 c.reset,
358 c.dim,
359 c.reset,
360 );
361
362 for f in &node.findings {
364 let severity_color = match f.severity.as_str() {
365 "critical" => c.red,
366 "warning" => c.yellow,
367 _ => c.dim,
368 };
369 let _ = write!(
370 out,
371 " {severity_color}\u{2190} {} {} (\u{00d7}{}){}",
372 sanitize_for_terminal(&f.finding_type.replace('_', " ")),
373 sanitize_for_terminal(&f.severity),
374 f.occurrences,
375 c.reset,
376 );
377 }
378 out.push('\n');
379
380 let child_prefix = if is_last {
382 format!("{prefix} ")
383 } else {
384 format!("{prefix}\u{2502} ")
385 };
386
387 let detail_prefix = format!("{child_prefix} ");
388 for f in &node.findings {
389 write_finding_details(out, &detail_prefix, f, c);
390 }
391
392 if depth < MAX_TREE_DEPTH {
393 for (i, child) in node.children.iter().enumerate() {
394 let child_is_last = i == node.children.len() - 1;
395 format_node(out, child, &child_prefix, child_is_last, c, depth + 1);
396 }
397 }
398}
399
400fn format_duration(us: u64) -> String {
401 if us < 1000 {
402 format!("{us}\u{00b5}s")
403 } else if us < 1_000_000 {
404 format!("{:.1}ms", us as f64 / 1000.0)
405 } else {
406 format!("{:.2}s", us as f64 / 1_000_000.0)
407 }
408}
409
410pub fn format_tree_json(tree: &ExplainTree) -> Result<String, serde_json::Error> {
416 serde_json::to_string_pretty(tree)
417}
418
419#[cfg(test)]
420mod tests {
421 use std::sync::Arc;
422
423 use super::*;
424 use crate::detect::{Confidence, FindingType, Pattern, Severity};
425 use crate::test_helpers::{make_sql_event, make_trace};
426
427 fn make_finding_for(trace_id: &str, template: &str) -> Finding {
428 Finding {
429 finding_type: FindingType::NPlusOneSql,
430 severity: Severity::Critical,
431 trace_id: trace_id.to_string(),
432 service: "order-svc".to_string(),
433 source_endpoint: "POST /api/orders/{id}/submit".to_string(),
434 pattern: Pattern {
435 template: template.to_string(),
436 occurrences: 6,
437 window_ms: 200,
438 distinct_params: 6,
439 ..Default::default()
440 },
441 suggestion: "Use WHERE order_id IN (?)".to_string(),
442 first_timestamp: "2025-07-10T14:32:01.000Z".to_string(),
443 last_timestamp: "2025-07-10T14:32:01.250Z".to_string(),
444 green_impact: None,
445 confidence: Confidence::default(),
446 classification_method: None,
447 code_location: None,
448 instrumentation_scopes: Vec::new(),
449 suggested_fix: None,
450 signature: String::new(),
451 }
452 }
453
454 #[test]
455 fn build_tree_single_root() {
456 let events = vec![make_sql_event(
457 "trace-1",
458 "span-1",
459 "SELECT * FROM order_item WHERE order_id = 42",
460 "2025-07-10T14:32:01.000Z",
461 )];
462 let trace = make_trace(events);
463 let tree = build_tree(&trace, &[]);
464
465 assert_eq!(tree.trace_id, "trace-1");
466 assert_eq!(tree.roots.len(), 1);
467 assert!(tree.roots[0].children.is_empty());
468 }
469
470 #[test]
471 fn build_tree_with_children() {
472 let mut events = Vec::new();
473 let mut root = make_sql_event("trace-1", "root", "SELECT 1", "2025-07-10T14:32:01.000Z");
474 root.parent_span_id = None;
475 events.push(root);
476
477 for i in 1..=3 {
478 let mut child = make_sql_event(
479 "trace-1",
480 &format!("child-{i}"),
481 &format!("SELECT * FROM t WHERE id = {i}"),
482 &format!("2025-07-10T14:32:01.{:03}Z", i * 50),
483 );
484 child.parent_span_id = Some("root".to_string());
485 events.push(child);
486 }
487
488 let trace = make_trace(events);
489 let tree = build_tree(&trace, &[]);
490
491 assert_eq!(tree.roots.len(), 1);
492 assert_eq!(tree.roots[0].children.len(), 3);
493 }
494
495 #[test]
496 fn build_tree_with_findings() {
497 let events = crate::test_helpers::make_sql_series_events(5);
498 let trace = make_trace(events);
499
500 let finding = make_finding_for("trace-1", "SELECT * FROM order_item WHERE order_id = ?");
501 let tree = build_tree(&trace, &[finding]);
502
503 let has_finding = tree.roots.iter().any(|r| !r.findings.is_empty());
505 assert!(
506 has_finding,
507 "at least one span should have findings attached"
508 );
509 }
510
511 #[test]
512 fn format_tree_text_no_panic() {
513 let events = vec![make_sql_event(
514 "trace-1",
515 "span-1",
516 "SELECT * FROM order_item WHERE order_id = 42",
517 "2025-07-10T14:32:01.000Z",
518 )];
519 let trace = make_trace(events);
520 let tree = build_tree(&trace, &[]);
521
522 let text = format_tree_text(&tree, false);
523 assert!(text.contains("trace-1"));
524 assert!(text.contains("SELECT * FROM order_item WHERE order_id = ?"));
525 }
526
527 #[test]
528 fn format_tree_json_roundtrip() {
529 let events = vec![make_sql_event(
530 "trace-1",
531 "span-1",
532 "SELECT 1",
533 "2025-07-10T14:32:01.000Z",
534 )];
535 let trace = make_trace(events);
536 let tree = build_tree(&trace, &[]);
537
538 let json = format_tree_json(&tree).unwrap();
539 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
540 assert_eq!(parsed["trace_id"], "trace-1");
541 }
542
543 #[test]
544 fn format_duration_microseconds() {
545 assert_eq!(format_duration(500), "500\u{00b5}s");
546 assert_eq!(format_duration(1200), "1.2ms");
547 assert_eq!(format_duration(2_500_000), "2.50s");
548 }
549
550 #[test]
551 fn format_tree_text_with_color() {
552 let events = vec![make_sql_event(
553 "trace-1",
554 "span-1",
555 "SELECT * FROM order_item WHERE order_id = 42",
556 "2025-07-10T14:32:01.000Z",
557 )];
558 let trace = make_trace(events);
559 let finding = make_finding_for("trace-1", "SELECT * FROM order_item WHERE order_id = ?");
560 let tree = build_tree(&trace, &[finding]);
561
562 let text = format_tree_text(&tree, true);
563 assert!(text.contains("\x1b[1m"), "should contain bold ANSI code");
565 assert!(text.contains("\x1b[36m"), "should contain cyan ANSI code");
566 }
567
568 fn make_chatty_finding(trace_id: &str) -> Finding {
571 Finding {
572 finding_type: FindingType::ChattyService,
573 severity: Severity::Warning,
574 trace_id: trace_id.to_string(),
575 service: "gateway-svc".to_string(),
576 source_endpoint: "GET /api/dashboard/home".to_string(),
577 pattern: Pattern {
578 template: "GET /api/dashboard/home".to_string(),
579 occurrences: 16,
580 window_ms: 300,
581 distinct_params: 16,
582 ..Default::default()
583 },
584 suggestion: "Consider aggregating calls with a BFF layer".to_string(),
585 first_timestamp: "2025-07-10T14:32:00.000Z".to_string(),
586 last_timestamp: "2025-07-10T14:32:00.300Z".to_string(),
587 green_impact: None,
588 confidence: Confidence::default(),
589 classification_method: None,
590 code_location: None,
591 instrumentation_scopes: Vec::new(),
592 suggested_fix: None,
593 signature: String::new(),
594 }
595 }
596
597 #[test]
598 fn trace_level_finding_routed_to_header() {
599 let events = vec![make_sql_event(
602 "trace-1",
603 "span-1",
604 "SELECT * FROM users WHERE id = 42",
605 "2025-07-10T14:32:01.000Z",
606 )];
607 let trace = make_trace(events);
608
609 let finding = make_chatty_finding("trace-1");
610 let tree = build_tree(&trace, &[finding]);
611
612 assert!(
613 tree.roots[0].findings.is_empty(),
614 "chatty finding should not land on the SQL span"
615 );
616 assert_eq!(
617 tree.trace_level_findings.len(),
618 1,
619 "chatty finding should land in trace_level_findings"
620 );
621 assert_eq!(tree.trace_level_findings[0].finding_type, "chatty_service");
622 assert_eq!(tree.trace_level_findings[0].severity, "warning");
623 }
624
625 #[test]
626 fn span_anchored_and_trace_level_coexist() {
627 let events = crate::test_helpers::make_sql_series_events(5);
630 let trace = make_trace(events);
631
632 let nplus = make_finding_for("trace-1", "SELECT * FROM order_item WHERE order_id = ?");
633 let chatty = make_chatty_finding("trace-1");
634 let tree = build_tree(&trace, &[nplus, chatty]);
635
636 let has_inline = tree.roots.iter().any(|r| !r.findings.is_empty());
637 assert!(has_inline, "N+1 finding should annotate the SQL spans");
638 assert_eq!(
639 tree.trace_level_findings.len(),
640 1,
641 "chatty finding should still land in trace_level_findings"
642 );
643 }
644
645 #[test]
646 fn format_tree_text_renders_trace_level_section() {
647 let events = vec![make_sql_event(
648 "trace-1",
649 "span-1",
650 "SELECT 1",
651 "2025-07-10T14:32:01.000Z",
652 )];
653 let trace = make_trace(events);
654 let finding = make_chatty_finding("trace-1");
655 let tree = build_tree(&trace, &[finding]);
656
657 let text = format_tree_text(&tree, false);
658 assert!(
659 text.contains("Trace-level findings:"),
660 "should render the trace-level header: {text}"
661 );
662 assert!(
663 text.contains("chatty service"),
664 "should mention the finding type: {text}"
665 );
666 assert!(text.contains("BFF"), "should render the suggestion: {text}");
667 }
668
669 #[test]
670 fn cyclic_parent_reference_handled() {
671 use crate::event::{EventSource, EventType, SpanEvent};
672 use crate::normalize::NormalizedEvent;
673
674 let span_a = NormalizedEvent {
676 event: SpanEvent {
677 timestamp: "2025-07-10T14:32:01.000Z".to_string(),
678 trace_id: "trace-cycle".to_string(),
679 span_id: "span-a".to_string(),
680 parent_span_id: Some("span-b".to_string()),
681 service: Arc::from("svc"),
682 cloud_region: None,
683 event_type: EventType::Sql,
684 operation: "SELECT".to_string(),
685 target: "SELECT 1".to_string(),
686 duration_us: 100,
687 source: EventSource {
688 endpoint: "GET /test".to_string(),
689 method: "test".to_string(),
690 },
691 status_code: None,
692 response_size_bytes: None,
693 code_function: None,
694 code_filepath: None,
695 code_lineno: None,
696 code_namespace: None,
697 instrumentation_scopes: Vec::new(),
698 },
699 template: Arc::from("SELECT ?"),
700 params: vec!["1".to_string()],
701 };
702 let span_b = NormalizedEvent {
703 event: SpanEvent {
704 timestamp: "2025-07-10T14:32:01.001Z".to_string(),
705 trace_id: "trace-cycle".to_string(),
706 span_id: "span-b".to_string(),
707 parent_span_id: Some("span-a".to_string()),
708 service: Arc::from("svc"),
709 cloud_region: None,
710 event_type: EventType::Sql,
711 operation: "SELECT".to_string(),
712 target: "SELECT 2".to_string(),
713 duration_us: 100,
714 source: EventSource {
715 endpoint: "GET /test".to_string(),
716 method: "test".to_string(),
717 },
718 status_code: None,
719 response_size_bytes: None,
720 code_function: None,
721 code_filepath: None,
722 code_lineno: None,
723 code_namespace: None,
724 instrumentation_scopes: Vec::new(),
725 },
726 template: Arc::from("SELECT ?"),
727 params: vec!["2".to_string()],
728 };
729 let trace = Trace {
730 trace_id: "trace-cycle".to_string(),
731 spans: vec![span_a, span_b],
732 };
733
734 let tree = build_tree(&trace, &[]);
736 let text = format_tree_text(&tree, false);
737 assert!(!text.is_empty());
738 }
739
740 #[test]
741 fn findings_from_other_trace_ignored() {
742 let events = vec![make_sql_event(
743 "trace-1",
744 "span-1",
745 "SELECT * FROM order_item WHERE order_id = 42",
746 "2025-07-10T14:32:01.000Z",
747 )];
748 let trace = make_trace(events);
749
750 let finding =
752 make_finding_for("trace-other", "SELECT * FROM order_item WHERE order_id = ?");
753 let tree = build_tree(&trace, &[finding]);
754
755 assert!(tree.roots[0].findings.is_empty());
756 }
757
758 #[test]
759 fn fix_and_location_render_inline_under_a_finding() {
760 use crate::detect::suggestions::SuggestedFix;
761 use crate::event::CodeLocation;
762
763 let events = crate::test_helpers::make_sql_series_events(5);
764 let trace = make_trace(events);
765 let mut finding =
766 make_finding_for("trace-1", "SELECT * FROM order_item WHERE order_id = ?");
767 finding.suggested_fix = Some(SuggestedFix {
768 pattern: "n_plus_one_sql".to_string(),
769 framework: "java_jpa".to_string(),
770 recommendation: "Use @BatchSize on the lazy collection".to_string(),
771 reference_url: Some("https://docs.example.com/batch".to_string()),
772 });
773 finding.code_location = Some(CodeLocation {
774 function: Some("findItems".to_string()),
775 filepath: Some("src/main/java/orders/OrderService.java".to_string()),
776 lineno: Some(118),
777 namespace: Some("com.foo.orders.OrderService".to_string()),
778 });
779
780 let tree = build_tree(&trace, &[finding]);
781 let text = format_tree_text(&tree, false);
782
783 assert!(
784 text.contains("suggestion: Use WHERE order_id IN (?)"),
785 "missing suggestion line, got:\n{text}"
786 );
787 assert!(
788 text.contains("fix [java_jpa]: Use @BatchSize on the lazy collection (https://docs.example.com/batch)"),
789 "missing fix line, got:\n{text}"
790 );
791 assert!(
792 text.contains("location:")
793 && text.contains("src/main/java/orders/OrderService.java:118"),
794 "missing location line, got:\n{text}"
795 );
796 }
797
798 #[test]
799 fn finding_without_fix_or_location_keeps_only_suggestion() {
800 let events = crate::test_helpers::make_sql_series_events(5);
801 let trace = make_trace(events);
802 let finding = make_finding_for("trace-1", "SELECT * FROM order_item WHERE order_id = ?");
803 let tree = build_tree(&trace, &[finding]);
804 let text = format_tree_text(&tree, false);
805
806 assert!(text.contains("suggestion:"), "got:\n{text}");
807 assert!(!text.contains("fix ["), "fix line leaked, got:\n{text}");
808 assert!(
809 !text.contains("location:"),
810 "location line leaked, got:\n{text}"
811 );
812 }
813
814 #[test]
815 fn fix_without_location_renders_without_location_line() {
816 use crate::detect::suggestions::SuggestedFix;
817
818 let events = crate::test_helpers::make_sql_series_events(5);
819 let trace = make_trace(events);
820 let mut finding =
821 make_finding_for("trace-1", "SELECT * FROM order_item WHERE order_id = ?");
822 finding.suggested_fix = Some(SuggestedFix {
823 pattern: "n_plus_one_sql".to_string(),
824 framework: "rust_diesel".to_string(),
825 recommendation: "Use belonging_to + grouped_by".to_string(),
826 reference_url: None,
827 });
828
829 let tree = build_tree(&trace, &[finding]);
830 let text = format_tree_text(&tree, false);
831
832 assert!(
833 text.contains("fix [rust_diesel]: Use belonging_to + grouped_by"),
834 "missing fix line, got:\n{text}"
835 );
836 assert!(
837 !text.contains("location:"),
838 "location must be omitted, got:\n{text}"
839 );
840 }
841
842 #[test]
843 fn ansi_escape_in_template_is_stripped_from_tree() {
844 let mut events = vec![make_sql_event(
845 "trace-1",
846 "span-1",
847 "SELECT 1",
848 "2025-07-10T14:32:01.000Z",
849 )];
850 events[0].target = "evil\x1b[2J\x1b[H wipe".to_string();
852 let trace = make_trace(events);
853 let tree = build_tree(&trace, &[]);
854 let text = format_tree_text(&tree, false);
855 assert!(
856 !text.as_bytes().contains(&0x1b),
857 "ESC byte from template leaked, got:\n{text}"
858 );
859 }
860
861 #[test]
862 fn ansi_escape_in_suggestion_is_stripped() {
863 let events = crate::test_helpers::make_sql_series_events(5);
864 let trace = make_trace(events);
865 let mut finding =
866 make_finding_for("trace-1", "SELECT * FROM order_item WHERE order_id = ?");
867 finding.suggestion = "click \x1b]8;;https://attacker/\x07here\x1b]8;;\x07".to_string();
868 let tree = build_tree(&trace, &[finding]);
869 let text = format_tree_text(&tree, false);
870 assert!(
871 !text.as_bytes().contains(&0x1b),
872 "ESC leaked from suggestion, got:\n{text}"
873 );
874 assert!(
875 !text.as_bytes().contains(&0x07),
876 "BEL leaked from suggestion, got:\n{text}"
877 );
878 }
879
880 #[test]
881 fn ansi_escape_in_code_location_is_stripped() {
882 use crate::event::CodeLocation;
883
884 let events = crate::test_helpers::make_sql_series_events(5);
885 let trace = make_trace(events);
886 let mut finding =
887 make_finding_for("trace-1", "SELECT * FROM order_item WHERE order_id = ?");
888 finding.code_location = Some(CodeLocation {
889 function: Some("findItems\x1b[31m".to_string()),
890 filepath: Some("src/Foo.java".to_string()),
891 lineno: Some(10),
892 namespace: Some("com.foo".to_string()),
893 });
894 let tree = build_tree(&trace, &[finding]);
895 let text = format_tree_text(&tree, false);
896 assert!(
897 !text.as_bytes().contains(&0x1b),
898 "ESC leaked from code_location, got:\n{text}"
899 );
900 }
901
902 #[test]
903 fn non_https_reference_url_is_omitted_in_tree() {
904 use crate::detect::suggestions::SuggestedFix;
905
906 let events = crate::test_helpers::make_sql_series_events(5);
907 let trace = make_trace(events);
908 let mut finding =
909 make_finding_for("trace-1", "SELECT * FROM order_item WHERE order_id = ?");
910 finding.suggested_fix = Some(SuggestedFix {
911 pattern: "n_plus_one_sql".to_string(),
912 framework: "java_jpa".to_string(),
913 recommendation: "Use @BatchSize".to_string(),
914 reference_url: Some("javascript:alert(1)".to_string()),
915 });
916
917 let tree = build_tree(&trace, &[finding]);
918 let text = format_tree_text(&tree, false);
919 assert!(
920 !text.contains("javascript:"),
921 "javascript: URL leaked, got:\n{text}"
922 );
923 assert!(
924 text.contains("fix [java_jpa]: Use @BatchSize"),
925 "recommendation must still render, got:\n{text}"
926 );
927 }
928
929 #[test]
930 fn trace_level_finding_renders_fix_inline() {
931 use crate::detect::suggestions::SuggestedFix;
932
933 let events = vec![make_sql_event(
934 "trace-1",
935 "span-1",
936 "SELECT 1",
937 "2025-07-10T14:32:01.000Z",
938 )];
939 let trace = make_trace(events);
940 let mut finding = make_chatty_finding("trace-1");
941 finding.suggested_fix = Some(SuggestedFix {
942 pattern: "chatty_service".to_string(),
943 framework: "java_generic".to_string(),
944 recommendation: "Aggregate calls behind a BFF".to_string(),
945 reference_url: None,
946 });
947
948 let tree = build_tree(&trace, &[finding]);
949 let text = format_tree_text(&tree, false);
950
951 assert!(
952 text.contains("Trace-level findings:"),
953 "missing header, got:\n{text}"
954 );
955 assert!(
956 text.contains("fix [java_generic]: Aggregate calls behind a BFF"),
957 "trace-level finding must render fix line, got:\n{text}"
958 );
959 }
960
961 #[test]
962 fn explain_tree_serde_roundtrip() {
963 let events = vec![make_sql_event(
964 "trace-1",
965 "span-1",
966 "SELECT * FROM order_item WHERE order_id = 1",
967 "2025-07-10T14:32:01.050Z",
968 )];
969 let trace = make_trace(events);
970 let finding = make_finding_for("trace-1", "SELECT * FROM order_item WHERE order_id = ?");
971 let tree = build_tree(&trace, &[finding]);
972 let json_str = format_tree_json(&tree).unwrap();
973 let back: ExplainTree = serde_json::from_str(&json_str).unwrap();
974 assert_eq!(back.trace_id, "trace-1");
975 assert!(!back.roots.is_empty());
976 }
977}