clustervision 0.19.2

clustervision CLI — search, read, and port AI coding-agent sessions across harnesses.
//! `cv cat <session> <tool_use_id>` — one tool call's full output, wherever it lives.
//!
//! A tool result has three homes: inline in the transcript (the common case), in a `cv prune`
//! sidecar (`<stem>.flat.jsonl`, when prune snipped it and left a `[PRUNED id=…]` marker), or in a
//! persisted-output file (Claude Code writes oversized outputs to `<session>/tool-results/<id>.txt`
//! and leaves a stub the model saw; the adapter records the path in `details.persistedOutput.path`).
//! `cat` checks all three so the caller never has to know which. `--input` prints the matching
//! tool call's arguments instead. An unknown id is a usage error (exit 2).

use crate::util::{parse_harness, resolve, short_id, usage};
use anyhow::{Context, Result};
use cv_core::ir::Block;
use cv_core::Demand;
use std::io::Write;
use std::path::{Path, PathBuf};

pub(crate) fn cmd_cat(session: &str, tool_use_id: &str, harness: Option<String>, input: bool) -> Result<()> {
    let want = parse_harness(&harness)?;
    let (r, adapter) = resolve(session, want)?;
    // Lazy stream to the first message holding the block, then stop: giant results stay on disk
    // until the one block we print is resolved, and nothing before or after it is kept.
    let mut found = First {
        id: tool_use_id,
        input,
        hit: None,
    };
    let mut parsed = cv_core::stream::stream_counted(
        adapter.as_ref(),
        &r,
        &Demand::LEAN.with_spans(),
        &mut found,
        cv_core::stream::Pass::RECORD,
    )?
    .session;
    parsed.messages = found.hit.into_iter().collect();
    let sidecar = sidecar_path(&r.path);
    let mut out = std::io::BufWriter::new(std::io::stdout().lock());

    if input {
        for m in &parsed.messages {
            for b in &m.content {
                if let Block::ToolUse { id, name, input, .. } = b {
                    if id == tool_use_id {
                        eprintln!("✦ {name} ({id}) — input");
                        writeln!(out, "{}", serde_json::to_string_pretty(input)?)?;
                        return Ok(());
                    }
                }
            }
        }
        return usage(format!(
            "no tool call {tool_use_id:?} in {} — `cv tools {} --timeline` lists its tool calls",
            short_id(&r.id),
            short_id(&r.id)
        ));
    }

    print_result(&parsed, &r.id, tool_use_id, &sidecar, &mut out)
}

fn print_result(
    parsed: &cv_core::Session,
    session_id: &str,
    tool_use_id: &str,
    sidecar: &Path,
    mut out: &mut impl Write,
) -> Result<()> {
    let resolver = parsed.resolver();
    for m in &parsed.messages {
        for b in &m.content {
            let Block::ToolResult {
                tool_use_id: tid,
                content,
                details,
                tool_name,
                ..
            } = b
            else {
                continue;
            };
            if tid != tool_use_id {
                continue;
            }
            // 1. A persisted-output file: the transcript holds only the stub the model saw.
            if let Some(p) = details
                .as_ref()
                .and_then(|d| d.pointer("/persistedOutput/path"))
                .and_then(|v| v.as_str())
            {
                let path = Path::new(p);
                if path.is_file() {
                    eprintln!("✦ persisted output: {p}");
                    let mut f = std::fs::File::open(path).with_context(|| format!("opening {p}"))?;
                    std::io::copy(&mut f, &mut out)?;
                    out.flush()?;
                    return Ok(());
                }
                eprintln!("⚠ persisted output {p} is missing — printing the transcript's stub instead");
            }
            let text = content
                .try_resolve(&resolver)
                .context("reading transcript tool result")?;
            // 2. A prune marker: the original lives in the sidecar next to the transcript.
            if text.trim_start().starts_with(cv_core::prune::MARKER_PREFIX) {
                if sidecar.try_exists()? {
                    let v = cv_core::prune::retrieve(sidecar, tool_use_id)
                        .with_context(|| format!("reading prune sidecar {}", sidecar.display()))?;
                    eprintln!("✦ from prune sidecar {}", sidecar.display());
                    return print_value(&mut out, &v);
                }
                eprintln!(
                    "⚠ {} was pruned but no sidecar holds it (hard-dropped with `--drop`?) — printing the marker",
                    tool_use_id
                );
            }
            // 3. Inline.
            if let Some(name) = tool_name {
                eprintln!("✦ {name} ({tool_use_id})");
            }
            writeln!(out, "{text}")?;
            out.flush()?;
            return Ok(());
        }
    }

    // Not in the transcript at all: a sidecar can still carry it (e.g. a `toolUseResult`-only
    // stash from a prune of a session whose result block was itself dropped).
    if sidecar.try_exists()? {
        let v = cv_core::prune::retrieve(sidecar, tool_use_id)
            .with_context(|| format!("reading prune sidecar {}", sidecar.display()))?;
        eprintln!("✦ from prune sidecar {}", sidecar.display());
        return print_value(&mut out, &v);
    }
    usage(format!(
        "no tool result {tool_use_id:?} in {} — `cv tools {} --timeline` lists its tool calls",
        short_id(session_id),
        short_id(session_id)
    ))
}

/// Keeps the first message carrying the wanted block — the `ToolUse` with that id under
/// `--input`, else the `ToolResult` for it — and stops the stream there.
struct First<'a> {
    id: &'a str,
    input: bool,
    hit: Option<cv_core::Message>,
}

impl cv_core::MessageSink for First<'_> {
    fn message(&mut self, m: cv_core::Message) -> cv_core::Flow {
        let wanted = m.content.iter().any(|b| match b {
            Block::ToolUse { id, .. } => self.input && id == self.id,
            Block::ToolResult { tool_use_id, .. } => !self.input && tool_use_id == self.id,
            _ => false,
        });
        if !wanted {
            return cv_core::Flow::Continue;
        }
        self.hit = Some(m);
        cv_core::Flow::Stop
    }
}

/// `<stem>.flat.jsonl` next to the transcript — where `cv prune` stashes snipped payloads.
///
/// `Path::parent` of a bare filename is `Some("")`, not `None`, so the empty parent has to be
/// filtered out explicitly or `abc.jsonl` would yield a bare `abc.flat.jsonl` sibling-of-nothing.
fn sidecar_path(transcript: &Path) -> PathBuf {
    let stem = transcript.file_stem().and_then(|s| s.to_str()).unwrap_or("session");
    transcript
        .parent()
        .filter(|p| !p.as_os_str().is_empty())
        .unwrap_or(Path::new("."))
        .join(format!("{stem}.flat.jsonl"))
}

/// Raw text verbatim; structured payloads (image blocks, `toolUseResult` mirrors) pretty-printed.
fn print_value(out: &mut impl Write, v: &serde_json::Value) -> Result<()> {
    match v.as_str() {
        Some(s) => writeln!(out, "{s}")?,
        None => writeln!(out, "{}", serde_json::to_string_pretty(v)?)?,
    }
    out.flush()?;
    Ok(())
}

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

    #[test]
    fn corrupt_sidecar_is_an_error_with_its_path_not_a_drop_hint() {
        let world = crate::test_world("cat-corrupt-sidecar");
        let sidecar = world.base.join("s.flat.jsonl");
        std::fs::write(&sidecar, [0xff, b'\n']).unwrap();
        let session = cv_core::Session::new("s", cv_core::Harness::Claude);
        let mut out = Vec::new();
        let error = print_result(&session, "s", "tool", &sidecar, &mut out).unwrap_err();
        let error = format!("{error:#}");
        assert!(error.contains("s.flat.jsonl"), "{error}");
        assert!(!error.contains("--drop"));
        assert!(out.is_empty());
    }

    #[test]
    fn retained_result_prints_exact_text_and_rejects_source_failure_without_output() {
        use cv_core::{Block, Flow, Harness, Message, MessageSink, Role, Session};
        let world = crate::test_world("cat-retained-result");
        let path = world.base.join("result.json");
        let body = "actual🌙\n\"\\".repeat(1024);
        let wire = serde_json::to_vec(&body).unwrap();
        std::fs::write(&path, &wire).unwrap();
        let content = cv_core::Json::source(&path, 0, wire.len() as u64)
            .unwrap()
            .with_view(|view| view.source_text())
            .unwrap()
            .unwrap();
        assert!(matches!(content, cv_core::Text::Source(_)));
        let mut message = Message::new(Role::Tool);
        message.content.push(Block::ToolResult {
            tool_use_id: "call".into(),
            content,
            is_error: false,
            tool_name: None,
            status: None,
            details: None,
        });
        let mut first = First {
            id: "call",
            input: false,
            hit: None,
        };
        assert_eq!(first.message(message), Flow::Stop);
        let mut session = Session::new("cat", Harness::Codex);
        session.messages = first.hit.into_iter().collect();
        let sidecar = world.base.join("absent.flat.jsonl");
        let mut output = Vec::new();
        print_result(&session, &session.id, "call", &sidecar, &mut output).unwrap();
        assert_eq!(output, format!("{body}\n").as_bytes());
        let mut owned = session.clone();
        let resolver = owned.resolver();
        owned.messages[0].materialize(&resolver).unwrap();
        let mut expected = Vec::new();
        print_result(&owned, &owned.id, "call", &sidecar, &mut expected).unwrap();
        assert_eq!(output, expected);
        std::fs::remove_file(path).unwrap();
        let mut rejected = Vec::new();
        assert!(print_result(&session, &session.id, "call", &sidecar, &mut rejected).is_err());
        assert!(rejected.is_empty());
    }

    #[test]
    fn sidecar_sits_next_to_the_transcript() {
        assert_eq!(
            sidecar_path(Path::new("/p/-work/abc.jsonl")),
            PathBuf::from("/p/-work/abc.flat.jsonl")
        );
        // A bare filename resolves relative to `.`.
        assert_eq!(sidecar_path(Path::new("abc.jsonl")), PathBuf::from("./abc.flat.jsonl"));
    }
}