bonsai-ninja-trace 0.2.2

Trace result + renderers.
use super::to_text;
use crate::{
    AnalysisLimits, SourceSpan, TraceMetadata, TraceQuery, TraceResult, TraceStep, TraceStepKind,
    TraceSummary,
};
use bonsai_common::Precision;

fn span() -> SourceSpan {
    SourceSpan {
        file: "app.py".to_string(),
        start_line: 1,
        start_col: 1,
        end_line: 1,
        end_col: 7,
        start_byte: 0,
        end_byte: 6,
    }
}

#[test]
fn text_renderer_reports_unresolved_calls_as_incomplete_metadata() {
    let trace = TraceResult {
        trace_id: "trace-test".to_string(),
        query: TraceQuery::default(),
        summary: TraceSummary {
            language: "python".to_string(),
            analysis_complete: false,
            analysis_incomplete_reasons: vec!["unresolved-call:missing".to_string()],
            total_steps: 1,
            total_paths: 1,
            explored_paths: 1,
            precision: Precision::Exact,
            ..TraceSummary::default()
        },
        paths: vec![crate::PathSummary {
            path_id: 1,
            first_step: 0,
            last_step: 0,
            path_constraints: Vec::new(),
            terminated_by: crate::PathTermination::Unknown,
            precision: Precision::Exact,
        }],
        steps: vec![TraceStep {
            id: 0,
            path_id: 1,
            order: 1,
            kind: TraceStepKind::Diagnostic,
            message: "Unresolved call missing".to_string(),
            function: "entry".to_string(),
            module: "app.py".to_string(),
            file: "app.py".to_string(),
            span: span(),
            code: String::new(),
            state_before: None,
            state_after: None,
            precision: Precision::Exact,
            notes: Vec::new(),
        }],
        edges: Vec::new(),
        states: Vec::new(),
        diagnostics: Vec::new(),
        metadata: TraceMetadata {
            engine_version: "test".to_string(),
            analysis_limits: AnalysisLimits::default(),
        },
    };

    let rendered = to_text(&trace);
    assert!(
        rendered.contains("Unresolved call missing"),
        "unresolved calls should be visible as exact diagnostics:\n{rendered}"
    );
    assert!(
        !rendered.contains("[non-semantic"),
        "trace render must not expose non-semantic analysis evidence:\n{rendered}"
    );
    assert!(
        rendered.contains("Analysis incomplete: unresolved calls: 1 (missing)"),
        "semantic gap should still be surfaced in the summary:\n{rendered}"
    );
}

#[test]
fn incomplete_reason_summary_groups_exact_counts_and_samples() {
    let mut reasons = (0..20)
        .map(|index| format!("unresolved-call:callee_{index:02}"))
        .collect::<Vec<_>>();
    reasons.push("ambiguous-call:dispatch:3".to_string());
    reasons.push("max-depth:4".to_string());
    let summary = crate::summarize_incomplete_reasons(&reasons, 3);
    assert_eq!(
        summary,
        "unresolved calls: 20 (callee_00, callee_01, callee_02, … +17); \
         ambiguous calls: 1 (dispatch); other reasons: 1 (max-depth:4)"
    );
}

#[test]
fn text_renderer_suppresses_diagnostic_precision_steps() {
    let trace = TraceResult {
        trace_id: "trace-test".to_string(),
        query: TraceQuery::default(),
        summary: TraceSummary {
            language: "python".to_string(),
            analysis_complete: false,
            analysis_incomplete_reasons: vec!["diagnostic-precision-step:Call".to_string()],
            total_steps: 1,
            total_paths: 1,
            explored_paths: 1,
            precision: Precision::Exact,
            ..TraceSummary::default()
        },
        paths: vec![crate::PathSummary {
            path_id: 1,
            first_step: 0,
            last_step: 0,
            path_constraints: Vec::new(),
            terminated_by: crate::PathTermination::Unknown,
            precision: Precision::Exact,
        }],
        steps: vec![TraceStep {
            id: 0,
            path_id: 1,
            order: 1,
            kind: TraceStepKind::Call,
            message: "Call dynamic_target".to_string(),
            function: "entry".to_string(),
            module: "app.py".to_string(),
            file: "app.py".to_string(),
            span: span(),
            code: String::new(),
            state_before: None,
            state_after: None,
            precision: Precision::Unknown,
            notes: Vec::new(),
        }],
        edges: Vec::new(),
        states: Vec::new(),
        diagnostics: Vec::new(),
        metadata: TraceMetadata {
            engine_version: "test".to_string(),
            analysis_limits: AnalysisLimits::default(),
        },
    };

    let rendered = to_text(&trace);
    assert!(
        rendered.contains("Suppressed diagnostic-precision trace step"),
        "renderer should refuse to present diagnostic precision as evidence:\n{rendered}"
    );
    assert!(
        !rendered.contains("Call dynamic_target"),
        "renderer must not show the suppressed call as evidence:\n{rendered}"
    );
    assert!(
        !rendered.contains("[non-semantic"),
        "renderer must not expose non-semantic analysis evidence:\n{rendered}"
    );
}