pub const DEFAULT_MAX_OUTPUT_LENGTH: usize = 40_000;
pub const MAX_OUTPUT_LENGTH: usize = 1_000_000;
pub fn spill_dir() -> std::path::PathBuf {
std::env::temp_dir().join(format!("nano-coder-{}", std::process::id()))
}
pub fn bound_and_spill(text: &str, limit: usize, dir: &std::path::Path, name: &str) -> String {
if text.chars().count() <= limit {
return text.to_string();
}
let saved = std::fs::create_dir_all(dir).ok().and_then(|()| reserve_and_write(dir, name, text));
let display = saved.as_ref().map(|p| p.display().to_string());
bound_parts(text, Some(text), text.len() as u64, limit, display.as_deref()).0
}
fn reserve_and_write(dir: &std::path::Path, name: &str, text: &str) -> Option<std::path::PathBuf> {
use std::io::Write;
let (stem, ext) = match name.rsplit_once('.') {
Some((stem, ext)) => (stem, format!(".{ext}")),
None => (name, String::new()),
};
for attempt in 1..=10_000u32 {
let candidate = if attempt == 1 { dir.join(name) } else { dir.join(format!("{stem}-{attempt}{ext}")) };
match std::fs::File::create_new(&candidate) {
Ok(mut file) => return file.write_all(text.as_bytes()).ok().map(|()| candidate),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(_) => return None,
}
}
None
}
pub fn bound_output(text: &str, limit: usize) -> (String, bool) {
bound_parts(text, None, text.len() as u64, limit, None)
}
pub fn bound_parts(head: &str, tail: Option<&str>, full_size: u64, limit: usize, path: Option<&str>) -> (String, bool) {
let tail = match tail {
Some(tail) => tail,
None => {
if head.chars().count() <= limit {
return (head.to_string(), false);
}
head
}
};
let head_part = take_head(head, limit / 2);
let tail_part = take_tail(tail, limit - limit / 2);
let skipped = full_size.saturating_sub((head_part.len() + tail_part.len()) as u64);
let mut marker = format!("...{skipped} bytes truncated");
if let Some(path) = path {
marker.push_str("; complete output in ");
marker.push_str(path);
}
(format!("{head_part}{marker}...{tail_part}"), true)
}
fn take_head(text: &str, chars: usize) -> &str {
match text.char_indices().nth(chars) {
Some((end, _)) => &text[..end],
None => text,
}
}
fn take_tail(text: &str, chars: usize) -> &str {
if chars == 0 {
return "";
}
match text.char_indices().rev().nth(chars - 1) {
Some((start, _)) => &text[start..],
None => text,
}
}
pub fn parse_max_output_length(value: Option<&serde_json::Value>) -> Result<usize, String> {
let Some(value) = value.filter(|v| !v.is_null()) else {
return Ok(DEFAULT_MAX_OUTPUT_LENGTH);
};
let limit = value.as_i64().ok_or_else(|| "max_output_length must be a positive integer".to_string())?;
if limit <= 0 {
return Err("max_output_length must be a positive integer".into());
}
if limit as usize > MAX_OUTPUT_LENGTH {
return Err(format!("max_output_length must not exceed {MAX_OUTPUT_LENGTH}"));
}
Ok(limit as usize)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn leaves_short_output_alone() {
assert_eq!(bound_output("hello", 5), ("hello".to_string(), false));
}
#[test]
fn keeps_head_and_tail() {
let (bounded, truncated) = bound_output("abcdefghij", 4);
assert!(truncated);
assert_eq!(bounded, "ab...6 bytes truncated...ij");
}
#[test]
fn is_utf8_safe_and_mentions_path() {
let text = "é".repeat(10);
let (bounded, truncated) = bound_parts(&text, Some(&text), 1000, 3, Some("/tmp/out"));
assert!(truncated);
assert_eq!(bounded, "é...994 bytes truncated; complete output in /tmp/out...éé");
}
#[test]
fn spills_long_output_to_a_file() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(bound_and_spill("short", 10, dir.path(), "a.txt"), "short");
assert!(!dir.path().join("a.txt").exists());
let bounded = bound_and_spill("abcdefghij", 4, dir.path(), "b.txt");
let path = dir.path().join("b.txt");
assert_eq!(bounded, format!("ab...6 bytes truncated; complete output in {}...ij", path.display()));
assert_eq!(std::fs::read_to_string(path).unwrap(), "abcdefghij");
}
#[test]
fn spill_never_overwrites_an_existing_file() {
let dir = tempfile::tempdir().unwrap();
let first = bound_and_spill("abcdefghij", 4, dir.path(), "tool-x.txt");
assert!(first.contains("tool-x.txt"));
assert_eq!(std::fs::read_to_string(dir.path().join("tool-x.txt")).unwrap(), "abcdefghij");
let second = bound_and_spill("klmnopqrst", 4, dir.path(), "tool-x.txt");
assert!(second.contains("tool-x-2.txt"));
assert_eq!(std::fs::read_to_string(dir.path().join("tool-x.txt")).unwrap(), "abcdefghij");
assert_eq!(std::fs::read_to_string(dir.path().join("tool-x-2.txt")).unwrap(), "klmnopqrst");
}
#[test]
fn validates_limits() {
assert_eq!(parse_max_output_length(None), Ok(DEFAULT_MAX_OUTPUT_LENGTH));
assert_eq!(parse_max_output_length(Some(&json!(10))), Ok(10));
assert!(parse_max_output_length(Some(&json!(0))).is_err());
assert!(parse_max_output_length(Some(&json!("5"))).is_err());
assert!(parse_max_output_length(Some(&json!(MAX_OUTPUT_LENGTH + 1))).is_err());
}
}