use std::cmp::min;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
pub struct SpillConfig {
pub threshold_chars: usize,
pub head_lines: usize,
pub tail_lines: usize,
}
pub const DEFAULT_SPILL: SpillConfig = SpillConfig {
threshold_chars: 500,
head_lines: 15,
tail_lines: 5,
};
pub fn spill_if_needed(
content: &str,
call_id: &str,
spill_dir: &Path,
config: &SpillConfig,
) -> io::Result<Option<String>> {
if content.chars().count() <= config.threshold_chars {
return Ok(None);
}
fs::create_dir_all(spill_dir)?;
let spill_path = spill_dir.join(format!("{call_id}.txt"));
fs::write(&spill_path, content)?;
let absolute = match spill_path.canonicalize() {
Ok(p) => p,
Err(e) => {
tracing::warn!(
path = %spill_path.display(),
error = %e,
"spill: canonicalize failed, using original path (model may not be able to read the file)"
);
spill_path
}
};
Ok(Some(build_truncated(content, &absolute, config)))
}
pub fn spill_dir_for_tape(tapes_dir: &Path, tape_name: &str) -> PathBuf {
tapes_dir.join(format!("{tape_name}.d"))
}
fn build_truncated(content: &str, path: &Path, config: &SpillConfig) -> String {
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len();
let total_chars = content.chars().count();
let head_end = min(config.head_lines, total_lines);
let tail_start = total_lines.saturating_sub(config.tail_lines);
let line_based_keeps_all = tail_start <= head_end;
if line_based_keeps_all {
return char_level_truncate(content, total_chars, path, config);
}
let head = lines[..head_end].join("\n");
let tail = lines[tail_start..].join("\n");
let omitted_lines = tail_start - head_end;
let omitted_chars = total_chars.saturating_sub(head.chars().count() + tail.chars().count());
format!(
"{head}\n\n[{omitted_lines} lines, {omitted_chars} chars omitted — full output: {}]\n\n{tail}",
path.display()
)
}
fn char_level_truncate(
content: &str,
total_chars: usize,
path: &Path,
config: &SpillConfig,
) -> String {
let head_chars = config.threshold_chars / 2;
let tail_chars = config.threshold_chars / 4;
let head_byte_end = char_offset_to_byte(content, head_chars);
let tail_byte_start = char_offset_from_end(content, tail_chars);
let head = &content[..head_byte_end];
let tail = if tail_byte_start > head_byte_end {
&content[tail_byte_start..]
} else {
""
};
let kept_chars = head.chars().count() + tail.chars().count();
let omitted = total_chars.saturating_sub(kept_chars);
if tail.is_empty() {
format!(
"{head}\n\n[{omitted} chars omitted — full output: {}]",
path.display()
)
} else {
format!(
"{head}\n\n[{omitted} chars omitted — full output: {}]\n\n{tail}",
path.display()
)
}
}
fn char_offset_to_byte(s: &str, n: usize) -> usize {
s.char_indices()
.nth(n)
.map(|(byte_idx, _)| byte_idx)
.unwrap_or(s.len())
}
fn char_offset_from_end(s: &str, n: usize) -> usize {
let total = s.chars().count();
if n >= total {
return 0;
}
char_offset_to_byte(s, total - n)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn below_threshold_returns_none() {
let dir = tempdir().unwrap();
let result = spill_if_needed("short", "call1", dir.path(), &DEFAULT_SPILL).unwrap();
assert!(result.is_none());
}
#[test]
fn above_threshold_spills_to_file() {
let dir = tempdir().unwrap();
let content: String = (0..100)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let result = spill_if_needed(&content, "call_abc", dir.path(), &DEFAULT_SPILL).unwrap();
assert!(result.is_some());
let spill_path = dir.path().join("call_abc.txt");
assert!(spill_path.exists());
assert_eq!(fs::read_to_string(&spill_path).unwrap(), content);
let truncated = result.unwrap();
assert!(truncated.contains("line 0"));
assert!(truncated.contains("line 14")); assert!(truncated.contains("line 99")); assert!(truncated.contains("lines,"));
assert!(truncated.contains("chars omitted"));
assert!(truncated.contains("call_abc.txt"));
}
#[test]
fn head_tail_no_overlap() {
let dir = tempdir().unwrap();
let content: String = (0..25)
.map(|i| format!("L{i}"))
.collect::<Vec<_>>()
.join("\n");
let config = SpillConfig {
threshold_chars: 10,
head_lines: 15,
tail_lines: 5,
};
let truncated = spill_if_needed(&content, "c1", dir.path(), &config)
.unwrap()
.unwrap();
assert!(truncated.contains("L0"));
assert!(truncated.contains("L14"));
assert!(truncated.contains("L20"));
assert!(truncated.contains("L24"));
assert!(truncated.contains("5 lines,"));
}
#[test]
fn threshold_counts_chars_not_bytes() {
let dir = tempdir().unwrap();
let content: String = "你".repeat(200);
assert_eq!(content.len(), 600); assert_eq!(content.chars().count(), 200); let result = spill_if_needed(&content, "cjk", dir.path(), &DEFAULT_SPILL).unwrap();
assert!(
result.is_none(),
"200 chars < 500 threshold, should not spill"
);
}
#[test]
fn threshold_spills_cjk_over_limit() {
let dir = tempdir().unwrap();
let content: String = "你".repeat(501);
assert_eq!(content.chars().count(), 501);
let result = spill_if_needed(&content, "cjk_over", dir.path(), &DEFAULT_SPILL).unwrap();
assert!(result.is_some(), "501 chars > 500 threshold, should spill");
let recovered = fs::read_to_string(dir.path().join("cjk_over.txt")).unwrap();
assert_eq!(recovered, content);
}
#[test]
fn single_line_large_content_uses_char_level_truncation() {
let dir = tempdir().unwrap();
let content: String = "x".repeat(1000);
let config = SpillConfig {
threshold_chars: 100,
head_lines: 5,
tail_lines: 2,
};
let truncated = spill_if_needed(&content, "single", dir.path(), &config)
.unwrap()
.unwrap();
assert!(
truncated.chars().count() < content.chars().count(),
"truncated ({}) should be shorter than original ({})",
truncated.chars().count(),
content.chars().count()
);
assert!(truncated.contains("chars omitted"));
assert!(truncated.contains("single.txt"));
assert!(truncated.starts_with(&"x".repeat(50)));
let recovered = fs::read_to_string(dir.path().join("single.txt")).unwrap();
assert_eq!(recovered, content);
}
#[test]
fn single_line_json_blob_truncates() {
let dir = tempdir().unwrap();
let json_blob = format!(
r#"{{"data":[{}]}}"#,
(0..200)
.map(|i| format!(r#"{{"id":{i},"value":"item_{i}"}}"#))
.collect::<Vec<_>>()
.join(",")
);
assert!(json_blob.chars().count() > 500);
assert_eq!(json_blob.lines().count(), 1);
let truncated = spill_if_needed(&json_blob, "json", dir.path(), &DEFAULT_SPILL)
.unwrap()
.unwrap();
assert!(truncated.chars().count() < json_blob.chars().count());
assert!(truncated.contains("chars omitted"));
let recovered = fs::read_to_string(dir.path().join("json.txt")).unwrap();
assert_eq!(recovered, json_blob);
}
#[test]
fn cjk_single_line_splits_at_char_boundary() {
let dir = tempdir().unwrap();
let content: String = (0..600)
.map(|i| char::from_u32('一' as u32 + (i % 100)).unwrap_or('?'))
.collect();
let config = SpillConfig {
threshold_chars: 100,
head_lines: 5,
tail_lines: 2,
};
let truncated = spill_if_needed(&content, "cjk_line", dir.path(), &config)
.unwrap()
.unwrap();
let head_part: &str = truncated.split("\n\n[").next().unwrap();
assert_eq!(head_part.chars().count(), 50);
assert!(std::str::from_utf8(truncated.as_bytes()).is_ok());
let recovered = fs::read_to_string(dir.path().join("cjk_line.txt")).unwrap();
assert_eq!(recovered, content);
}
#[test]
fn emoji_content_splits_correctly() {
let dir = tempdir().unwrap();
let content: String = "🎉".repeat(200);
assert_eq!(content.len(), 800); assert_eq!(content.chars().count(), 200); let config = SpillConfig {
threshold_chars: 50,
head_lines: 5,
tail_lines: 2,
};
let truncated = spill_if_needed(&content, "emoji", dir.path(), &config)
.unwrap()
.unwrap();
assert!(std::str::from_utf8(truncated.as_bytes()).is_ok());
let head_part: &str = truncated.split("\n\n[").next().unwrap();
assert_eq!(head_part.chars().count(), 25);
assert!(head_part.chars().all(|c| c == '🎉'));
let recovered = fs::read_to_string(dir.path().join("emoji.txt")).unwrap();
assert_eq!(recovered, content);
}
#[test]
fn mixed_ascii_and_cjk_multiline() {
let dir = tempdir().unwrap();
let content: String = (0..100)
.map(|i| {
if i % 2 == 0 {
format!("english line {i}")
} else {
format!("中文行 {i} 内容测试")
}
})
.collect::<Vec<_>>()
.join("\n");
let truncated = spill_if_needed(&content, "mixed", dir.path(), &DEFAULT_SPILL)
.unwrap()
.unwrap();
assert!(truncated.contains("english line 0"));
assert!(truncated.contains("中文行 1 内容测试"));
assert!(truncated.contains("lines,"));
assert!(truncated.contains("chars omitted"));
let recovered = fs::read_to_string(dir.path().join("mixed.txt")).unwrap();
assert_eq!(recovered, content);
}
#[test]
fn exact_threshold_does_not_spill() {
let dir = tempdir().unwrap();
let config = SpillConfig {
threshold_chars: 10,
head_lines: 5,
tail_lines: 2,
};
let content = "0123456789"; let result = spill_if_needed(content, "exact", dir.path(), &config).unwrap();
assert!(result.is_none());
}
#[test]
fn one_over_threshold_spills() {
let dir = tempdir().unwrap();
let config = SpillConfig {
threshold_chars: 10,
head_lines: 5,
tail_lines: 2,
};
let content = "01234567890"; let result = spill_if_needed(content, "over", dir.path(), &config).unwrap();
assert!(result.is_some());
let recovered = fs::read_to_string(dir.path().join("over.txt")).unwrap();
assert_eq!(recovered, content);
}
#[test]
fn head_covers_all_lines_falls_back_to_char_level() {
let dir = tempdir().unwrap();
let content: String = (0..5)
.map(|i| format!("line {i}: {}", "x".repeat(200)))
.collect::<Vec<_>>()
.join("\n");
let config = SpillConfig {
threshold_chars: 100,
head_lines: 10, tail_lines: 3,
};
let truncated = spill_if_needed(&content, "c2", dir.path(), &config)
.unwrap()
.unwrap();
assert!(truncated.contains("chars omitted"));
let head_part: &str = truncated.split("\n\n[").next().unwrap();
assert_eq!(head_part.chars().count(), 50);
let recovered = fs::read_to_string(dir.path().join("c2.txt")).unwrap();
assert_eq!(recovered, content);
}
#[test]
fn spill_path_in_output_is_absolute() {
let dir = tempdir().unwrap();
let content = "x".repeat(600);
let truncated = spill_if_needed(&content, "abs_test", dir.path(), &DEFAULT_SPILL)
.unwrap()
.unwrap();
let path_str = truncated
.split("full output: ")
.nth(1)
.unwrap()
.split(']')
.next()
.unwrap();
let path = std::path::Path::new(path_str);
assert!(path.is_absolute(), "path should be absolute: {path_str}");
assert!(path.exists(), "file should exist at: {path_str}");
assert_eq!(
fs::read_to_string(path).unwrap(),
content,
"file should be readable with full content"
);
}
#[test]
fn spill_path_in_line_based_output_is_absolute() {
let dir = tempdir().unwrap();
let content: String = (0..100)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let truncated = spill_if_needed(&content, "lines_abs", dir.path(), &DEFAULT_SPILL)
.unwrap()
.unwrap();
let path_str = truncated
.split("full output: ")
.nth(1)
.unwrap()
.split(']')
.next()
.unwrap();
let path = std::path::Path::new(path_str);
assert!(path.is_absolute(), "path should be absolute: {path_str}");
assert!(path.exists(), "file should exist at: {path_str}");
assert_eq!(fs::read_to_string(path).unwrap(), content);
}
#[test]
fn spill_path_readable_by_model_tool() {
let dir = tempdir().unwrap();
let original = "重要内容\n".repeat(200);
let truncated = spill_if_needed(&original, "model_read", dir.path(), &DEFAULT_SPILL)
.unwrap()
.unwrap();
assert!(truncated.contains("full output:"));
let path_str = truncated
.split("full output: ")
.nth(1)
.unwrap()
.split(']')
.next()
.unwrap();
let recovered = fs::read_to_string(path_str).unwrap();
assert_eq!(recovered, original);
}
}