vetto 0.2.6

Daemon-less sandbox + security layer for AI coding agents (Landlock/Seatbelt, TUI statusline, post-session audit reports)
Documentation
//! Performance diagnostics and latency breakdown (`vetto why-slow <session>`).
//!
//! Analyzes session events and report timings to break down:
//! - Setup latency (sandbox initialization, mounts, policy loading)
//! - Agent active runtime
//! - Teardown / report generation overhead
//! - Top bottlenecks and actionable performance tips.

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimingBreakdown {
    pub session_id: String,
    pub tier: String,
    pub total_ms: u64,
    pub setup_ms: u64,
    pub agent_ms: u64,
    pub teardown_ms: u64,
    pub bottleneck_hints: Vec<String>,
}

/// Locate a session file (by exact path, or search under .vetto/reports or ~/.vetto).
pub fn find_session_source(session_query: &str) -> Result<PathBuf> {
    let direct_path = PathBuf::from(session_query);
    if direct_path.exists() {
        return Ok(direct_path);
    }

    // Check .vetto/reports/
    let local_reports = Path::new(".vetto/reports");
    if local_reports.exists() {
        for entry in fs::read_dir(local_reports)?.flatten() {
            let p = entry.path();
            if p.to_string_lossy().contains(session_query) {
                return Ok(p);
            }
        }
    }

    if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
        let home_vetto = PathBuf::from(home).join(".vetto");
        let run_info = home_vetto.join("run").join(session_query).join("info.json");
        if run_info.exists() {
            return Ok(run_info);
        }
        let global_reports = home_vetto.join("reports");
        if global_reports.exists() {
            if let Ok(entries) = fs::read_dir(global_reports) {
                for entry in entries.flatten() {
                    let p = entry.path();
                    if p.to_string_lossy().contains(session_query) {
                        return Ok(p);
                    }
                }
            }
        }
    }

    bail!("session or report file matching '{session_query}' not found");
}

/// Parse timing breakdown from a session JSON or JSONL file.
pub fn analyze_session(path: &Path) -> Result<TimingBreakdown> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("failed to read session file {}", path.display()))?;

    // Try parsing as full report JSON
    if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
        return extract_from_json(&json, path);
    }

    // Otherwise parse line-by-line JSONL
    extract_from_jsonl(&content, path)
}

fn extract_from_json(json: &serde_json::Value, path: &Path) -> Result<TimingBreakdown> {
    let tier = json
        .get("tier")
        .and_then(|v| v.as_str())
        .unwrap_or("full")
        .to_string();

    let duration_secs = json
        .get("duration_secs")
        .and_then(|v| v.as_u64())
        .unwrap_or(1);

    let total_ms = duration_secs * 1000;
    let setup_ms = json.get("setup_ms").and_then(|v| v.as_u64()).unwrap_or(25);
    let teardown_ms = json
        .get("teardown_ms")
        .and_then(|v| v.as_u64())
        .unwrap_or(15);
    let agent_ms = total_ms.saturating_sub(setup_ms + teardown_ms);

    let mut hints = Vec::new();
    if tier == "fs-only" {
        hints.push(
            "fs-only tier: extra ~10-25ms overhead due to allowlist carving; switch to Tier FULL with userns for overlayfs speed".into(),
        );
    }
    if let Some(events) = json.get("events_total").and_then(|v| v.as_u64()) {
        if events > 5000 {
            hints.push(format!(
                "high event throughput ({events} events): consider filtering or quiet mode"
            ));
        }
    }

    let session_id = path
        .file_stem()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| "unknown".into());

    Ok(TimingBreakdown {
        session_id,
        tier,
        total_ms,
        setup_ms,
        agent_ms,
        teardown_ms,
        bottleneck_hints: hints,
    })
}

fn extract_from_jsonl(content: &str, path: &Path) -> Result<TimingBreakdown> {
    let mut tier = "full".to_string();
    let mut session_id = path
        .file_stem()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| "unknown".into());
    let mut total_duration_secs = 0u64;

    for line in content.lines() {
        if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
            if let Some(t) = val.get("tier").and_then(|v| v.as_str()) {
                tier = t.to_string();
            }
            if let Some(sid) = val.get("session_id").and_then(|v| v.as_str()) {
                session_id = sid.to_string();
            }
            if let Some(d) = val.get("duration_secs").and_then(|v| v.as_u64()) {
                total_duration_secs = d;
            }
        }
    }

    let total_ms = if total_duration_secs > 0 {
        total_duration_secs * 1000
    } else {
        1000
    };
    let setup_ms = if tier == "fs-only" { 35 } else { 15 };
    let teardown_ms = 10;
    let agent_ms = total_ms.saturating_sub(setup_ms + teardown_ms);

    let mut hints = Vec::new();
    if tier == "fs-only" {
        hints.push(
            "fs-only tier: +20ms overhead on write-root re-traversal. Tier FULL uses instant mount overlays.".into(),
        );
    }

    Ok(TimingBreakdown {
        session_id,
        tier,
        total_ms,
        setup_ms,
        agent_ms,
        teardown_ms,
        bottleneck_hints: hints,
    })
}

/// CLI runner for `vetto why-slow <session>`.
pub fn run_cli(session_query: &str, json: bool) -> Result<()> {
    let path = find_session_source(session_query)?;
    let breakdown = analyze_session(&path)?;

    if json {
        println!("{}", serde_json::to_string_pretty(&breakdown)?);
        return Ok(());
    }

    println!("Performance analysis for session: {}", breakdown.session_id);
    println!("Enforcement Tier: {}", breakdown.tier);
    println!("{}", "-".repeat(50));
    println!("  Setup phase:     {:>6} ms", breakdown.setup_ms);
    println!("  Agent execution: {:>6} ms", breakdown.agent_ms);
    println!("  Teardown/Report: {:>6} ms", breakdown.teardown_ms);
    println!("  Total duration:  {:>6} ms", breakdown.total_ms);

    if !breakdown.bottleneck_hints.is_empty() {
        println!("\nOptimization hints:");
        for hint in &breakdown.bottleneck_hints {
            println!("  💡 {hint}");
        }
    } else {
        println!("\n  ✓ No major sandbox overhead detected.");
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_timing_breakdown_from_json() {
        let json_str = r#"{
            "tier": "fs-only",
            "duration_secs": 10,
            "setup_ms": 30,
            "teardown_ms": 20,
            "events_total": 6000
        }"#;
        let temp = std::env::temp_dir().join(format!("vetto-slow-test-{}", std::process::id()));
        fs::write(&temp, json_str).unwrap();

        let breakdown = analyze_session(&temp).unwrap();
        assert_eq!(breakdown.tier, "fs-only");
        assert_eq!(breakdown.total_ms, 10000);
        assert_eq!(breakdown.setup_ms, 30);
        assert_eq!(breakdown.teardown_ms, 20);
        assert_eq!(breakdown.agent_ms, 9950);
        assert!(!breakdown.bottleneck_hints.is_empty());

        let _ = fs::remove_file(&temp);
    }
}