use std::path::PathBuf;
use bamboo_compression::TokenCounter;
use bamboo_infrastructure::paths::bamboo_dir;
const MAX_SLUG_LEN: usize = 60;
const MIN_SAVINGS_BYTES: usize = 500;
pub(crate) async fn tee_save_if_needed(
session_id: &str,
args_json: &str,
full_output: &str,
compressed_output: &str,
) -> Option<String> {
let savings = full_output.len().saturating_sub(compressed_output.len());
if savings < MIN_SAVINGS_BYTES {
return None;
}
match tee_save(session_id, args_json, full_output).await {
Ok(path) => {
let display_path = bamboo_infrastructure::paths::path_to_display_string(&path);
let counter = bamboo_compression::TiktokenTokenCounter::default();
let tokens = counter.count_text(full_output);
let token_display = if tokens >= 1000 {
format!("{:.1}K", tokens as f64 / 1000.0)
} else {
tokens.to_string()
};
Some(format!(
"[full output saved: {} ({} bytes, ~{} tokens). Use Read tool to inspect details.]",
display_path,
full_output.len(),
token_display,
))
}
Err(e) => {
tracing::warn!("Failed to tee-save output: {}", e);
None
}
}
}
async fn tee_save(
session_id: &str,
args_json: &str,
full_output: &str,
) -> Result<PathBuf, std::io::Error> {
let tee_dir = bamboo_dir().join("tee").join(session_id);
tokio::fs::create_dir_all(&tee_dir).await?;
let command = extract_command_for_slug(args_json);
let slug = sanitize_filename(&command);
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let filename = format!("{}_{}.log", timestamp, slug);
let path = tee_dir.join(&filename);
tokio::fs::write(&path, full_output).await?;
tracing::debug!("Tee-saved {} bytes to {:?}", full_output.len(), path);
Ok(path)
}
fn extract_command_for_slug(args_json: &str) -> String {
serde_json::from_str::<serde_json::Value>(args_json)
.ok()
.and_then(|v| v.get("command").and_then(|c| c.as_str()).map(String::from))
.unwrap_or_else(|| "unknown".to_string())
}
fn sanitize_filename(input: &str) -> String {
let slug: String = input
.chars()
.take(MAX_SLUG_LEN)
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
slug.trim_end_matches('_').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_basic() {
assert_eq!(sanitize_filename("cargo test"), "cargo_test");
}
#[test]
fn sanitize_special_chars() {
assert_eq!(
sanitize_filename("cd /foo/bar && cargo test --lib"),
"cd__foo_bar____cargo_test_--lib"
);
}
#[test]
fn sanitize_truncates() {
let long = "a".repeat(200);
let result = sanitize_filename(&long);
assert!(result.len() <= MAX_SLUG_LEN);
}
#[test]
fn extract_command_valid_json() {
let args = r#"{"command": "cargo test --workspace"}"#;
assert_eq!(extract_command_for_slug(args), "cargo test --workspace");
}
#[test]
fn extract_command_invalid_json() {
assert_eq!(extract_command_for_slug("not json"), "unknown");
}
#[test]
fn extract_command_missing_field() {
let args = r#"{"other": "value"}"#;
assert_eq!(extract_command_for_slug(args), "unknown");
}
#[tokio::test]
async fn tee_save_if_needed_small_savings() {
let full = "short output";
let compressed = "short out";
let result =
tee_save_if_needed("test-session", r#"{"command":"echo"}"#, full, compressed).await;
assert!(result.is_none());
}
#[tokio::test]
async fn tee_save_if_needed_includes_read_hint() {
let full = "x".repeat(2000);
let compressed = "y";
let result =
tee_save_if_needed("test-session", r#"{"command":"echo"}"#, &full, compressed).await;
assert!(result.is_some());
let note = result.unwrap();
assert!(note.contains("Use Read tool"));
assert!(note.contains("tokens"));
assert!(note.contains("bytes"));
}
}