clustervision 0.19.2

clustervision CLI — search, read, and port AI coding-agent sessions across harnesses.
//! `cv blame` — CLI presentation over [`cv_core::blame`].

use anyhow::{Context, Result};
use cv_core::blame::{self, BlameReport, CommitBlame, SessionAttribution, Strength};
use cv_core::ir::{truncate, Harness};

pub fn cmd_blame(file: &str, lines: Option<&str>, show: bool, json: bool) -> Result<()> {
    let report = blame::blame(file, lines)?;
    if json {
        println!("{}", serde_json::to_string_pretty(&report)?);
        return Ok(());
    }
    if !report.in_git_repo {
        return render_event_only(file, &report);
    }

    if report.commits.is_empty() {
        println!("{}", no_commits(&report.file, lines));
        return Ok(());
    }

    if report.cold_catalog {
        eprintln!("(event catalog is empty — run `cv index` first to ingest tool events)");
    }

    println!(
        "{}\n",
        header(&report.file, lines, report.commits.len(), report.total_commits)
    );

    let mut best_overall: Option<(String, String, i64)> = None;
    for c in &report.commits {
        let summary = if c.summary.is_empty() {
            "(no message)"
        } else {
            &c.summary
        };
        println!("◆ {} {}  {summary}", c.short_sha, date(c.time));
        if c.matches.is_empty() {
            println!("  (no agent session found)");
            continue;
        }
        for m in &c.matches {
            print_attribution(m, c);
            best_overall.get_or_insert_with(|| (m.harness.clone(), m.session_id.clone(), m.msg_idx));
        }
    }

    println!(
        "\nprovenance: {} of {} commit(s) matched an agent session",
        report.matched_commits,
        report.commits.len()
    );

    if show {
        match &best_overall {
            None => println!("(--show: no agent session matched any commit)"),
            Some((harness, sid, idx)) => show_best(harness, sid, *idx)?,
        }
    }
    Ok(())
}

fn render_event_only(file: &str, report: &BlameReport) -> Result<()> {
    println!("(not inside a git repository — event-catalog matches only, no commit correlation)\n");
    if report.event_only_sessions.is_empty() {
        if report.cold_catalog {
            println!("no edit events found — run `cv index` first to ingest tool events");
        } else {
            println!("no sessions edited {file:?}");
        }
        return Ok(());
    }
    println!("{} session(s) edited {file:?}:\n", report.event_only_sessions.len());
    for e in &report.event_only_sessions {
        let dt = e.ts.map(date).unwrap_or_else(|| "----------".into());
        println!(
            "  {:8} {}  {}  {}  last edit at msg {}",
            e.harness,
            crate::util::short_id(&e.session_id),
            dt,
            e.title.as_deref().map(|t| truncate(t, 48)).unwrap_or_default(),
            e.msg_idx,
        );
        println!("{}", show_hint(&e.session_id, e.range));
    }
    Ok(())
}

fn header(rel: &str, lines: Option<&str>, shown: usize, total: usize) -> String {
    let more = if total > shown {
        format!(" (of {total}; showing the most recent)")
    } else {
        String::new()
    };
    match lines {
        Some(spec) => format!("✦ blame {rel} -L {spec} — {shown} commit(s) own those lines{more}"),
        None => format!("✦ blame {rel} — {shown} commit(s){more}"),
    }
}

fn no_commits(rel: &str, lines: Option<&str>) -> String {
    match lines {
        Some(spec) => format!(
            "no commit owns {rel} -L {spec} — are those lines committed? (event-catalog view: `cv touched {rel}`)"
        ),
        None => format!("no commits touch {rel} — is it committed? (event-catalog view: `cv touched {rel}`)"),
    }
}

fn print_attribution(m: &SessionAttribution, c: &CommitBlame) {
    let dt = m.ts.map(date).unwrap_or_else(|| "----------".into());
    let title = m
        .title
        .as_deref()
        .map(|t| format!("\"{}\"  ", truncate(t, 48)))
        .unwrap_or_default();

    if m.strength.is_exact_sha() {
        let how = match m.commit_evidence.as_deref() {
            Some("pushed") => "commit pushed here",
            _ => "commit created here",
        };
        println!(
            "  {:8} {}  {}  {}commit at msg {} ({} · {how})",
            m.harness,
            crate::util::short_id(&m.session_id),
            dt,
            title,
            m.msg_idx,
            m.label,
        );
        println!("    ↳ cv rewind {} --at {}", hint_id(&m.session_id), c.short_sha);
        println!("{}", show_hint(&m.session_id, m.range));
        return;
    }

    let detail = match m.strength {
        Strength::ContentHunk => match m.delta_secs {
            Some(d) => format!("({} · {})", m.label, fmt_delta(d)),
            None => format!("({})", m.label),
        },
        Strength::Timed => match m.delta_secs {
            Some(d) => format!("({} · {})", m.label, fmt_delta(d)),
            None => format!("({})", m.label),
        },
        Strength::SessionSpan => format!("({}; session active at commit time; no event timestamps)", m.label),
        Strength::CommitShaAndContentHunk | Strength::CommitSha => unreachable!(),
    };
    let elsewhere = if m.cwd_in_repo { "" } else { " · other checkout" };
    println!(
        "  {:8} {}  {}  {}edit at msg {} {detail}{elsewhere}",
        m.harness,
        crate::util::short_id(&m.session_id),
        dt,
        title,
        m.msg_idx,
    );
    println!("{}", show_hint(&m.session_id, m.range));
}

fn show_hint(session_id: &str, (s, end): (usize, usize)) -> String {
    format!("    ↳ cv show {} --range {s}..{end}", hint_id(session_id))
}

fn hint_id(id: &str) -> String {
    if id.starts_with("agent-") {
        id.to_string()
    } else {
        crate::util::short_id(id)
    }
}

fn show_best(harness: &str, session_id: &str, msg_idx: i64) -> Result<()> {
    let want = Harness::parse(harness);
    let (r, adapter) = cv_core::find(session_id, want)?
        .with_context(|| format!("session {harness}:{session_id} not found (deleted since indexing?)"))?;
    let (start, end) = blame::range_hint(msg_idx);
    println!(
        "\n── conversation around the edit · {harness} {} · msg {start}..{end} ──\n",
        crate::util::short_id(session_id)
    );
    let mut out = std::io::BufWriter::new(std::io::stdout().lock());
    crate::cmd::view::stream_session_render(
        adapter.as_ref(),
        &r,
        &mut out,
        crate::cmd::view::show_header,
        crate::cmd::view::show_message,
        crate::cmd::view::RenderOpts {
            range: Some(crate::util::Window::Range(start, Some(end))),
            max_bytes: None,
        },
    )?;
    use std::io::Write;
    out.flush()?;
    Ok(())
}

fn date(ts: i64) -> String {
    crate::util::fmt_local_ts(ts, "%Y-%m-%d").unwrap_or_else(|| "----------".into())
}

pub(crate) fn fmt_delta(delta: i64) -> String {
    let (mag, dir) = if delta >= 0 {
        (delta, "before")
    } else {
        (-delta, "after")
    };
    let (h, m, s) = (mag / 3600, (mag % 3600) / 60, mag % 60);
    let t = if h > 0 {
        if m > 0 {
            format!("{h}h{m}m")
        } else {
            format!("{h}h")
        }
    } else if m > 0 {
        format!("{m}m")
    } else {
        format!("{s}s")
    };
    format!("{t} {dir} commit")
}

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

    #[test]
    fn line_blame_reports_truncation_and_its_own_empty_case() {
        assert_eq!(
            header("a.rs", Some("3,9"), 20, 31),
            "✦ blame a.rs -L 3,9 — 20 commit(s) own those lines (of 31; showing the most recent)"
        );
        assert_eq!(
            header("a.rs", Some("3"), 2, 2),
            "✦ blame a.rs -L 3 — 2 commit(s) own those lines"
        );
        assert_eq!(
            header("a.rs", None, 20, 31),
            "✦ blame a.rs — 20 commit(s) (of 31; showing the most recent)"
        );
        assert!(no_commits("a.rs", Some("3")).starts_with("no commit owns a.rs -L 3 — are those lines committed?"));
        assert!(no_commits("a.rs", None).starts_with("no commits touch a.rs — is it committed?"));
    }

    #[test]
    fn every_show_hint_spells_a_sub_agent_in_full() {
        assert_eq!(
            show_hint("blame-e2e-session", blame::range_hint(1)),
            "    ↳ cv show blame-e2 --range 0..4"
        );
        assert_eq!(
            show_hint("agent-a1b2c3d4e5f6", blame::range_hint(142)),
            "    ↳ cv show agent-a1b2c3d4e5f6 --range 139..145"
        );
    }

    #[test]
    fn delta_humanizes() {
        assert_eq!(fmt_delta(8 * 60), "8m before commit");
        assert_eq!(fmt_delta(2 * 3600 + 600), "2h10m before commit");
        assert_eq!(fmt_delta(3 * 3600), "3h before commit");
        assert_eq!(fmt_delta(-30), "30s after commit");
    }
}