agentdiff 0.1.27

Audit and trace autonomous AI code contributions in git repositories
use crate::cli::ContextArgs;
use crate::data::{AgentTrace, TraceFile};
use crate::store::Store;
use crate::util::{agent_color_str, print_command_header, print_not_initialized};
use anyhow::Result;
use colored::Colorize;
use std::path::Path;

pub fn run(store: &Store, args: &ContextArgs) -> Result<()> {
    if !store.is_initialized() {
        print_not_initialized();
        return Ok(());
    }

    let rel_path = normalize_path(&args.file);
    let mut traces: Vec<AgentTrace> = store
        .load_all_traces()?
        .into_iter()
        .filter(|trace| trace.files.iter().any(|file| file.path == rel_path))
        .collect();

    if let Some(agent) = &args.agent {
        traces.retain(|trace| trace.agent_name().contains(agent));
    }

    traces.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
    traces.truncate(args.limit);

    if args.json {
        let body = context_json(&rel_path, &traces)?;
        println!("{}", serde_json::to_string_pretty(&body)?);
        return Ok(());
    }

    print_command_header("context");
    println!("  {}", rel_path.bold());
    println!();

    if traces.is_empty() {
        println!("  No AgentDiff context found for this file.");
        println!();
        return Ok(());
    }

    for trace in &traces {
        let meta = trace.agentdiff_metadata();
        let intent = meta
            .as_ref()
            .and_then(|m| m.intent.as_deref())
            .filter(|s| !s.trim().is_empty())
            .unwrap_or("unspecified");
        println!(
            "  {} {} {}",
            short_id(&trace.id).dimmed(),
            agent_color_str(trace.agent_name()),
            trace.timestamp.to_rfc3339().dimmed()
        );
        println!("    Intent: {}", intent);
        if let Some(prompt) = meta
            .as_ref()
            .and_then(|m| m.prompt_excerpt.as_deref())
            .filter(|p| !p.trim().is_empty())
        {
            println!("    Prompt: {}", prompt);
        }
        if let Some(files_read) = meta
            .as_ref()
            .map(|m| m.files_read.as_slice())
            .filter(|files| !files.is_empty())
        {
            println!("    Files read: {}", files_read.join(", "));
        }
        if let Some(flags) = meta
            .as_ref()
            .map(|m| m.flags.as_slice())
            .filter(|flags| !flags.is_empty())
        {
            println!("    Flags: {}", flags.join(", "));
        }
        for file in trace.files.iter().filter(|file| file.path == rel_path) {
            println!("    Ranges: {}", format_ranges(file));
        }
        println!();
    }

    Ok(())
}

fn normalize_path(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}

fn context_json(file: &str, traces: &[AgentTrace]) -> Result<serde_json::Value> {
    let values: Vec<_> = traces
        .iter()
        .map(|trace| {
            let meta = trace.agentdiff_metadata();
            serde_json::json!({
                "id": trace.id,
                "short_id": short_id(&trace.id),
                "timestamp": trace.timestamp,
                "sha": trace.sha(),
                "agent": trace.agent_name(),
                "intent": meta.as_ref().and_then(|m| m.intent.clone()),
                "prompt_excerpt": meta.as_ref().and_then(|m| m.prompt_excerpt.clone()),
                "files_read": meta.as_ref().map(|m| m.files_read.clone()).unwrap_or_default(),
                "flags": meta.as_ref().map(|m| m.flags.clone()).unwrap_or_default(),
                "trust": meta.as_ref().and_then(|m| m.trust),
                "ranges": trace.files.iter()
                    .filter(|f| f.path == file)
                    .flat_map(|f| f.conversations.iter())
                    .flat_map(|c| c.ranges.iter())
                    .map(|r| serde_json::json!({
                        "start_line": r.start_line,
                        "end_line": r.end_line,
                    }))
                    .collect::<Vec<_>>()
            })
        })
        .collect();
    Ok(serde_json::json!({
        "file": file,
        "traces": values,
    }))
}

fn format_ranges(file: &TraceFile) -> String {
    let ranges: Vec<String> = file
        .conversations
        .iter()
        .flat_map(|conv| conv.ranges.iter())
        .map(|range| {
            if range.start_line == range.end_line {
                range.start_line.to_string()
            } else {
                format!("{}-{}", range.start_line, range.end_line)
            }
        })
        .collect();
    if ranges.is_empty() {
        "unknown".to_string()
    } else {
        ranges.join(", ")
    }
}

fn short_id(id: &str) -> &str {
    &id[..id.len().min(8)]
}