use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use vtcode_commons::fs::ensure_dir_exists_sync;
#[cfg(test)]
use vtcode_commons::fs::read_file_with_context_sync;
#[cfg(test)]
use vtcode_commons::preview::excerpt_text_lines;
#[derive(Debug, Clone)]
pub(crate) struct LargeOutputConfig {
pub base_dir: PathBuf,
pub threshold_bytes: usize,
pub session_id: Option<String>,
}
impl Default for LargeOutputConfig {
fn default() -> Self {
let home = std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."));
Self {
base_dir: home.join(".vtcode").join("tmp"),
threshold_bytes: 50_000, session_id: None,
}
}
}
impl LargeOutputConfig {
pub fn with_threshold(mut self, threshold_bytes: usize) -> Self {
self.threshold_bytes = threshold_bytes;
self
}
}
#[cfg(test)]
const PREVIEW_HEAD_LINES: usize = 20;
#[cfg(test)]
const PREVIEW_TAIL_LINES: usize = 10;
#[cfg(test)]
const METADATA_HEADER_LINES: usize = 5;
#[derive(Debug, Clone)]
pub(crate) struct SpoolResult {
pub(crate) file_path: PathBuf,
#[cfg(test)]
pub(crate) size_bytes: usize,
#[cfg(test)]
pub(crate) line_count: usize,
#[cfg(test)]
pub(crate) tool_name: String,
#[cfg(test)]
pub(crate) was_spooled: bool,
}
#[cfg(test)]
impl SpoolResult {
pub fn read_full_content(&self) -> Result<String> {
let content = read_file_with_context_sync(&self.file_path, "spooled output")?;
if let Some(idx) = content.find("---\n\n") {
Ok(content[idx + METADATA_HEADER_LINES..].to_string())
} else {
Ok(content)
}
}
pub fn read_lines(&self, start: usize, end: usize) -> Result<String> {
let content = self.read_full_content()?;
if start == 0 || end == 0 || start > end {
return Ok(String::new());
}
let mut out = String::new();
let mut idx = 0usize;
let start_idx = start.saturating_sub(1);
let end_idx = end;
for line in content.lines() {
if idx >= start_idx && idx < end_idx {
if !out.is_empty() {
out.push('\n');
}
out.push_str(line);
}
idx += 1;
if idx >= end_idx {
break;
}
}
Ok(out)
}
pub fn get_preview(&self) -> Result<String> {
let content = self.read_full_content()?;
let preview = excerpt_text_lines(&content, PREVIEW_HEAD_LINES, PREVIEW_TAIL_LINES);
if preview.hidden_count == 0 {
return Ok(content);
}
Ok(format!(
"{}\n\n[... {} lines omitted - full output in: {} ...]\n\n{}",
preview.head.join("\n"),
preview.hidden_count,
self.file_path.display(),
preview.tail.join("\n")
))
}
pub fn to_agent_response(&self) -> Result<String> {
let preview = self.get_preview()?;
Ok(format!(
r#"Output saved to file (source of truth): {}
Size: {} bytes ({} lines)
Tool: {}
--- Preview (first {} + last {} lines) ---
{}
--- End Preview ---
To read full content, use: unified_file({{"action":"read","path":"{}","offset":1,"limit":{}}})
To read specific lines, use: unified_file({{"action":"read","path":"{}","offset":<start>,"limit":<line_count>}})"#,
self.file_path.display(),
self.size_bytes,
self.line_count,
self.tool_name,
PREVIEW_HEAD_LINES,
PREVIEW_TAIL_LINES,
preview,
self.file_path.display(),
self.line_count,
self.file_path.display(),
))
}
}
pub(super) fn generate_session_hash(session_id: Option<&str>) -> String {
let mut hasher = Sha256::new();
if let Some(id) = session_id {
hasher.update(id.as_bytes());
}
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
hasher.update(timestamp.to_le_bytes());
hasher.update(std::process::id().to_le_bytes());
let result = hasher.finalize();
result.iter().fold(String::new(), |mut output, b| {
let _ = std::fmt::write(&mut output, format_args!("{:02x}", b));
output
})
}
fn generate_call_id() -> String {
let mut hasher = Sha256::new();
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
hasher.update(timestamp.to_le_bytes());
let random_val = std::process::id() as u64 ^ timestamp as u64;
hasher.update(random_val.to_le_bytes());
let result = hasher.finalize();
result[..12].iter().fold(String::new(), |mut output, b| {
let _ = std::fmt::write(&mut output, format_args!("{:02x}", b));
output
})
}
pub(crate) fn spool_large_output(
content: &str,
tool_name: &str,
config: &LargeOutputConfig,
) -> Result<Option<SpoolResult>> {
if content.len() < config.threshold_bytes {
return Ok(None);
}
let session_hash = generate_session_hash(config.session_id.as_deref());
let session_dir = config.base_dir.join(&session_hash);
ensure_dir_exists_sync(&session_dir).with_context(|| {
format!(
"Failed to create output spool directory: {}",
session_dir.display()
)
})?;
let call_id = generate_call_id();
let filename = format!("call_{}.output", call_id);
let file_path = session_dir.join(&filename);
let mut file = fs::File::create(&file_path)
.with_context(|| format!("Failed to create spool file: {}", file_path.display()))?;
let metadata = format!(
"# VT Code Tool Output\n# Tool: {}\n# Timestamp: {}\n# Size: {} bytes\n---\n\n",
tool_name,
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
content.len()
);
file.write_all(metadata.as_bytes())
.with_context(|| format!("Failed to write metadata to: {}", file_path.display()))?;
file.write_all(content.as_bytes())
.with_context(|| format!("Failed to write content to: {}", file_path.display()))?;
#[cfg(test)]
let line_count = content.lines().count();
Ok(Some(SpoolResult {
file_path,
#[cfg(test)]
size_bytes: content.len(),
#[cfg(test)]
line_count,
#[cfg(test)]
tool_name: tool_name.to_string(),
#[cfg(test)]
was_spooled: true,
}))
}
#[cfg(test)]
pub(crate) fn format_spool_notification(result: &SpoolResult) -> String {
let path_str = result.file_path.display().to_string();
let mut lines = Vec::new();
lines.push(format!(
"│ Output too long ({} bytes) and was saved to:",
result.size_bytes
));
if path_str.len() > 70 {
if let Some(idx) = path_str.rfind('/') {
let (dir, file) = path_str.split_at(idx + 1);
lines.push(format!("│ {}", dir));
lines.push(format!("│ {}", file));
} else {
lines.push(format!("│ {}", path_str));
}
} else {
lines.push(format!("│ {}", path_str));
}
lines.join("\n")
}