Skip to main content

sentinel_core/
explain.rs

1//! Explain mode: builds a tree view of a trace with findings annotated inline.
2
3use crate::correlate::Trace;
4use crate::detect::Finding;
5use crate::normalize::NormalizedEvent;
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8
9/// A node in the explain tree, representing a single span.
10#[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/// A finding annotated on a span node.
25#[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    /// Framework-specific actionable fix carried over from the source
33    /// finding so the tree view can render it inline. Absent when the
34    /// finding had no inferred framework.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub suggested_fix: Option<crate::detect::suggestions::SuggestedFix>,
37    /// Source code location carried over from the source finding.
38    /// Absent when the instrumentation agent did not emit `code.*`
39    /// span attributes.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub code_location: Option<crate::event::CodeLocation>,
42}
43
44impl InlineFinding {
45    /// Convert a full [`Finding`] into a compact inline representation.
46    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/// The complete explain tree for a trace.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ExplainTree {
61    pub trace_id: String,
62    /// Trace-level findings that cannot be anchored to a single span.
63    ///
64    /// The span-template-based annotation path attaches findings to spans
65    /// whose normalized template matches `finding.pattern.template`. Some
66    /// detectors (chatty service, pool saturation, serialized calls) emit
67    /// findings whose `pattern.template` is a service name or an entry
68    /// endpoint rather than a span template, so the match finds nothing.
69    /// Those findings land here instead of being silently dropped from the
70    /// tree view.
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub trace_level_findings: Vec<InlineFinding>,
73    pub roots: Vec<SpanNode>,
74}
75
76/// Build an explain tree from a trace and its findings.
77#[must_use]
78pub fn build_tree(trace: &Trace, findings: &[Finding]) -> ExplainTree {
79    // Collect the set of span templates that actually appear in the trace.
80    // Used both to index span-anchored findings and to decide which findings
81    // fall through to the trace-level bucket.
82    let span_templates: HashSet<&str> = trace.spans.iter().map(|s| s.template.as_ref()).collect();
83
84    // Index findings by template for quick lookup (span-anchored path).
85    // Any finding whose template does not match a span template is collected
86    // separately into `trace_level_findings` so the tree view can still
87    // surface it at the top instead of dropping it silently.
88    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    // Index by span_id
111    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    // Build tree: collect children, identify roots
117    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    // Recursive tree assembly (with depth guard against stack overflow)
131    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
166/// Maximum tree depth to prevent stack overflow on deeply nested traces.
167const 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    // Guard against cyclic parent references: if the node was already taken,
176    // return a minimal placeholder instead of panicking.
177    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
202/// ANSI color codes for tree formatting.
203struct 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/// Format the explain tree as colored terminal text.
213#[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
257/// Render the trace-level findings header section, if any.
258///
259/// Emitted between the `Trace <id>` header and the span tree, so the reader
260/// sees whole-trace issues (chatty service, pool saturation, serialized
261/// calls) before drilling into individual spans.
262fn 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
294/// Indented sub-lines under an annotated finding (suggestion, optional
295/// fix and code location), using `├─` / `└─` connectors.
296fn 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    // Annotate findings inline
363    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    // Compute child prefix once for suggestions and child recursion
381    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
410/// Format the explain tree as a JSON value.
411///
412/// # Errors
413///
414/// Returns an error if the tree cannot be serialized.
415pub 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        // All spans share the same template, so all should have the finding
504        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        // Should contain ANSI escape codes
564        assert!(text.contains("\x1b[1m"), "should contain bold ANSI code");
565        assert!(text.contains("\x1b[36m"), "should contain cyan ANSI code");
566    }
567
568    /// Build a chatty-service-style finding whose `pattern.template` is the
569    /// entry endpoint (as real detectors emit it), not a span template.
570    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        // A single SQL span with a template that does NOT match the chatty
600        // finding's entry-endpoint template.
601        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        // Five matching SQL spans trigger a span-anchored N+1 SQL finding,
628        // alongside a trace-level chatty finding. Both should be captured.
629        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        // Create two spans that reference each other as parents
675        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        // Should not panic despite cyclic parent references
735        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        // Finding for a different trace
751        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        // Override the normalized template with attacker-controlled bytes.
851        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}