use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::tools::RecoverableError;
use anyhow::Result;
use regex::Regex;
use tempfile::NamedTempFile;
#[derive(Debug, Clone)]
pub struct BufferEntry {
pub command: String,
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub timestamp: u64,
pub source_path: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct PendingAckCommand {
pub command: String,
pub cwd: Option<String>,
pub timeout_secs: u64,
}
pub struct OutputBuffer {
inner: Mutex<BufferInner>,
}
struct BufferInner {
entries: HashMap<String, BufferEntry>,
order: Vec<String>,
max_entries: usize,
counter: u64,
pending_acks: HashMap<String, PendingAckCommand>,
pending_order: Vec<String>,
max_pending: usize,
background_jobs: HashMap<String, PathBuf>,
background_order: Vec<String>,
}
impl OutputBuffer {
pub fn new(max_entries: usize) -> Self {
Self {
inner: Mutex::new(BufferInner {
entries: HashMap::new(),
order: Vec::new(),
max_entries,
counter: 0,
pending_acks: HashMap::new(),
pending_order: Vec::new(),
max_pending: 20,
background_jobs: HashMap::new(),
background_order: Vec::new(),
}),
}
}
pub fn store(&self, command: String, stdout: String, stderr: String, exit_code: i32) -> String {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
inner.counter = inner.counter.wrapping_add(1);
let id = format!("@cmd_{:08x}", now.wrapping_add(inner.counter) as u32);
if inner.entries.len() >= inner.max_entries {
if let Some(oldest_id) = inner.order.first().cloned() {
inner.order.remove(0);
inner.entries.remove(&oldest_id);
}
}
let entry = BufferEntry {
command,
stdout,
stderr,
exit_code,
timestamp: now,
source_path: None,
};
inner.entries.insert(id.clone(), entry);
inner.order.push(id.clone());
id
}
pub fn get(&self, id: &str) -> Option<BufferEntry> {
self.get_with_refresh_flag(id).map(|(entry, _)| entry)
}
pub fn get_with_refresh_flag(&self, id: &str) -> Option<(BufferEntry, bool)> {
let canonical = id.strip_suffix(".err").unwrap_or(id);
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
if !inner.entries.contains_key(canonical) {
return None;
}
let needs_refresh = if let Some(entry) = inner.entries.get(canonical) {
if let Some(ref path) = entry.source_path {
match std::fs::metadata(path) {
Err(_) => {
inner.order.retain(|k| k != canonical);
inner.entries.remove(canonical);
return None;
}
Ok(meta) => {
let mtime_ms = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
mtime_ms > entry.timestamp
}
}
} else {
false
}
} else {
false
};
if needs_refresh {
let path = inner.entries[canonical].source_path.clone().unwrap();
match std::fs::read_to_string(&path) {
Ok(content) => {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
if let Some(entry) = inner.entries.get_mut(canonical) {
entry.stdout = content;
entry.timestamp = now;
}
}
Err(_) => {
inner.order.retain(|k| k != canonical);
inner.entries.remove(canonical);
return None;
}
}
}
if let Some(pos) = inner.order.iter().position(|k| k == canonical) {
inner.order.remove(pos);
inner.order.push(canonical.to_string());
}
inner
.entries
.get(canonical)
.cloned()
.map(|e| (e, needs_refresh))
}
pub fn store_file(&self, path: String, content: String) -> String {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
inner.counter = inner.counter.wrapping_add(1);
let id = format!("@file_{:08x}", now.wrapping_add(inner.counter) as u32);
if inner.entries.len() >= inner.max_entries {
if let Some(oldest_id) = inner.order.first().cloned() {
inner.order.remove(0);
inner.entries.remove(&oldest_id);
}
}
let source_path = if path.starts_with('@') {
None
} else {
Some(PathBuf::from(&path))
};
let entry = BufferEntry {
command: path.clone(),
stdout: content,
stderr: String::new(),
exit_code: 0,
timestamp: now,
source_path,
};
inner.entries.insert(id.clone(), entry);
inner.order.push(id.clone());
id
}
pub fn store_tool(&self, tool_name: &str, content: String) -> String {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
inner.counter = inner.counter.wrapping_add(1);
let id = format!("@tool_{:08x}", now.wrapping_add(inner.counter) as u32);
if inner.entries.len() >= inner.max_entries {
if let Some(oldest_id) = inner.order.first().cloned() {
inner.order.remove(0);
inner.entries.remove(&oldest_id);
}
}
let entry = BufferEntry {
command: tool_name.to_string(),
stdout: content,
stderr: String::new(),
exit_code: 0,
timestamp: now,
source_path: None,
};
inner.entries.insert(id.clone(), entry);
inner.order.push(id.clone());
id
}
pub fn store_dangerous(
&self,
command: String,
cwd: Option<String>,
timeout_secs: u64,
) -> String {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
inner.counter = inner.counter.wrapping_add(1);
let id = format!("@ack_{:08x}", now.wrapping_add(inner.counter) as u32);
if inner.pending_acks.len() >= inner.max_pending {
if let Some(oldest) = inner.pending_order.first().cloned() {
inner.pending_order.remove(0);
inner.pending_acks.remove(&oldest);
}
}
inner.pending_acks.insert(
id.clone(),
PendingAckCommand {
command,
cwd,
timeout_secs,
},
);
inner.pending_order.push(id.clone());
id
}
pub fn get_dangerous(&self, handle: &str) -> Option<PendingAckCommand> {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.pending_acks.get(handle).cloned()
}
pub fn store_background(&self, log_path: PathBuf) -> String {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.counter = inner.counter.wrapping_add(1);
let id = format!("@bg_{:08x}", inner.counter as u32);
if inner.background_jobs.len() >= inner.max_pending {
if let Some(oldest) = inner.background_order.first().cloned() {
inner.background_order.remove(0);
if let Some(log_path) = inner.background_jobs.remove(&oldest) {
if let Err(e) = std::fs::remove_file(&log_path) {
tracing::debug!(
"failed to clean up evicted bg log {}: {}",
log_path.display(),
e
);
}
}
}
}
inner.background_jobs.insert(id.clone(), log_path);
inner.background_order.push(id.clone());
id
}
pub fn get_background(&self, id: &str) -> Option<PathBuf> {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.background_jobs.get(id).cloned()
}
pub fn resolve_refs(&self, command: &str) -> Result<(String, Vec<PathBuf>, bool, Vec<String>)> {
static ACK_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
if ACK_RE
.get_or_init(|| Regex::new(r"@ack_[0-9a-f]{8}").expect("valid regex"))
.is_match(command)
{
return Err(RecoverableError::with_hint(
"ack handle cannot be used for interpolation",
"Use run_command(\"@ack_<id>\") directly to execute a pending acknowledgment.",
)
.into());
}
static REF_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let re = REF_RE.get_or_init(|| {
Regex::new(r"@(?:cmd|file|tool|bg)_[0-9a-f]{8}(\.err)?").expect("valid regex")
});
let refs: Vec<&str> = re.find_iter(command).map(|m| m.as_str()).collect();
if refs.is_empty() {
return Ok((command.to_string(), vec![], false, vec![]));
}
let mut seen = std::collections::HashSet::new();
let unique_refs: Vec<&str> = refs.iter().filter(|r| seen.insert(**r)).copied().collect();
let mut result = command.to_string();
let mut temp_paths: Vec<PathBuf> = Vec::new();
let mut temp_path_strings: Vec<String> = Vec::new();
let mut refreshed_handles: Vec<String> = Vec::new();
for token in &unique_refs {
let is_stderr = token.ends_with(".err");
let base_id = if is_stderr {
&token[..token.len() - 4] } else {
token
};
if base_id.starts_with("@bg_") {
if is_stderr {
return Err(RecoverableError::with_hint(
format!("@bg_* refs do not support the .err suffix: {}", token),
"Background job logs capture stdout and stderr in one file. Use the bare handle (without .err).",
)
.into());
}
let log_path = self.get_background(base_id).ok_or_else(|| {
RecoverableError::with_hint(
format!("background job ref not found: {}", token),
"Buffer refs expire when the session resets. Re-run the original command to get a fresh handle.",
)
})?;
let content = std::fs::read_to_string(&log_path).map_err(|e| {
RecoverableError::with_hint(
format!("background job log unavailable: {}", e),
format!(
"Check if the process is still running. Log path: {}",
log_path.display()
),
)
})?;
let mut tmp = NamedTempFile::new()?;
tmp.write_all(content.as_bytes())?;
tmp.flush()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o444);
std::fs::set_permissions(tmp.path(), perms)?;
}
let (_, path) = tmp
.keep()
.map_err(|e| anyhow::anyhow!("failed to persist temp file: {e}"))?;
let path_str = path.to_string_lossy().to_string();
result = result.replace(token, &path_str);
temp_path_strings.push(path_str);
temp_paths.push(path);
continue;
}
let (entry, was_refreshed) = self
.get_with_refresh_flag(base_id)
.ok_or_else(|| RecoverableError::with_hint(
format!("buffer reference not found: {}", token),
"Buffer refs expire when the session resets. Re-run the command to get a fresh ref.",
))?;
if was_refreshed {
refreshed_handles.push(token.to_string());
}
let content = if is_stderr {
&entry.stderr
} else {
&entry.stdout
};
let pretty_content: String;
let write_content: &str = if base_id.starts_with("@tool_") && !is_stderr {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(content) {
pretty_content =
serde_json::to_string_pretty(&v).unwrap_or_else(|_| content.to_string());
&pretty_content
} else {
content
}
} else {
content
};
let mut tmp = NamedTempFile::new()?;
tmp.write_all(write_content.as_bytes())?;
tmp.flush()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o444);
std::fs::set_permissions(tmp.path(), perms)?;
}
let (_, path) = tmp
.keep()
.map_err(|e| anyhow::anyhow!("failed to persist temp file: {e}"))?;
let path_str = path.to_string_lossy().to_string();
result = result.replace(token, &path_str);
temp_path_strings.push(path_str);
temp_paths.push(path);
}
let is_buffer_only = !shell_words(&result).iter().any(|word| {
let is_temp = temp_path_strings
.iter()
.any(|tp| word.contains(tp.as_str()));
if is_temp {
return false;
}
if word.starts_with('/') || word.starts_with("./") {
return true;
}
!word.starts_with('-') && word.contains('/')
});
Ok((result, temp_paths, is_buffer_only, refreshed_handles))
}
pub fn cleanup_temp_files(paths: &[PathBuf]) {
for path in paths {
let _ = std::fs::remove_file(path);
}
}
pub fn is_buffer_only(command: &str) -> bool {
if !command.contains("@cmd_") && !command.contains("@file_") && !command.contains("@tool_")
{
return false;
}
for word in command.split_whitespace() {
if word.starts_with('/') || word.starts_with("./") || word.starts_with("../") {
return false;
}
if !word.starts_with('-') && word.contains('/') {
return false;
}
}
true
}
}
fn shell_words(s: &str) -> Vec<String> {
let mut words = Vec::new();
let mut current = String::new();
let mut in_single = false;
let mut in_double = false;
let mut escape_next = false;
for ch in s.chars() {
if escape_next {
current.push(ch);
escape_next = false;
continue;
}
match ch {
'\\' if !in_single => escape_next = true,
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
c if c.is_whitespace() && !in_single && !in_double => {
if !current.is_empty() {
words.push(std::mem::take(&mut current));
}
}
c => current.push(c),
}
}
if !current.is_empty() {
words.push(current);
}
words
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn store_and_get() {
let buf = OutputBuffer::new(10);
let id = buf.store("echo hello".into(), "hello\n".into(), String::new(), 0);
assert!(id.starts_with("@cmd_"));
assert_eq!(id.len(), "@cmd_".len() + 8);
let entry = buf.get(&id).expect("entry should exist");
assert_eq!(entry.command, "echo hello");
assert_eq!(entry.stdout, "hello\n");
assert_eq!(entry.stderr, "");
assert_eq!(entry.exit_code, 0);
}
#[test]
fn get_missing_returns_none() {
let buf = OutputBuffer::new(10);
assert!(buf.get("@cmd_deadbeef").is_none());
}
#[test]
fn lru_eviction() {
let buf = OutputBuffer::new(3);
let id1 = buf.store("cmd1".into(), "out1".into(), String::new(), 0);
let id2 = buf.store("cmd2".into(), "out2".into(), String::new(), 0);
let _id3 = buf.store("cmd3".into(), "out3".into(), String::new(), 0);
let _id4 = buf.store("cmd4".into(), "out4".into(), String::new(), 0);
assert!(buf.get(&id1).is_none(), "oldest entry should be evicted");
assert!(buf.get(&id2).is_some(), "second entry should survive");
}
#[test]
fn get_refreshes_lru_order() {
let buf = OutputBuffer::new(3);
let id1 = buf.store("cmd1".into(), "out1".into(), String::new(), 0);
let _id2 = buf.store("cmd2".into(), "out2".into(), String::new(), 0);
let _id3 = buf.store("cmd3".into(), "out3".into(), String::new(), 0);
buf.get(&id1);
let _id4 = buf.store("cmd4".into(), "out4".into(), String::new(), 0);
assert!(buf.get(&id1).is_some(), "refreshed entry should survive");
assert!(
buf.get(&_id2).is_none(),
"un-refreshed oldest should be evicted"
);
}
#[test]
fn stderr_suffix() {
let buf = OutputBuffer::new(10);
let id = buf.store("failing".into(), String::new(), "error msg".into(), 1);
let via_err = buf
.get(&format!("{id}.err"))
.expect(".err suffix should work");
assert_eq!(via_err.stderr, "error msg");
assert_eq!(via_err.command, "failing");
}
#[test]
fn resolve_refs_no_refs() {
let buf = OutputBuffer::new(20);
let (cmd, files, is_buffer_only, _refreshed) = buf.resolve_refs("cargo test").unwrap();
assert_eq!(cmd, "cargo test");
assert!(files.is_empty());
assert!(!is_buffer_only);
OutputBuffer::cleanup_temp_files(&files);
}
#[test]
fn resolve_refs_single_ref() {
let buf = OutputBuffer::new(20);
let id = buf.store("prev".into(), "hello world\n".into(), "".into(), 0);
let (cmd, files, is_buffer_only, _refreshed) =
buf.resolve_refs(&format!("grep hello {}", id)).unwrap();
assert!(!cmd.contains(&id));
assert!(cmd.contains("/")); assert_eq!(files.len(), 1);
assert!(is_buffer_only);
let content = std::fs::read_to_string(&files[0]).unwrap();
assert_eq!(content, "hello world\n");
OutputBuffer::cleanup_temp_files(&files);
}
#[test]
fn resolve_refs_stderr_suffix() {
let buf = OutputBuffer::new(20);
let id = buf.store("prev".into(), "stdout".into(), "stderr_content".into(), 1);
let err_ref = format!("{}.err", id);
let (cmd, files, _, _refreshed) = buf
.resolve_refs(&format!("grep error {}", err_ref))
.unwrap();
assert!(!cmd.contains(&err_ref));
let content = std::fs::read_to_string(&files[0]).unwrap();
assert_eq!(content, "stderr_content");
OutputBuffer::cleanup_temp_files(&files);
}
#[test]
fn resolve_refs_multiple_refs() {
let buf = OutputBuffer::new(20);
let id1 = buf.store("cmd1".into(), "out1".into(), "".into(), 0);
let id2 = buf.store("cmd2".into(), "out2".into(), "".into(), 0);
let (cmd, files, is_buffer_only, _refreshed) =
buf.resolve_refs(&format!("diff {} {}", id1, id2)).unwrap();
assert_eq!(files.len(), 2);
assert!(is_buffer_only);
assert!(!cmd.contains(&id1));
assert!(!cmd.contains(&id2));
OutputBuffer::cleanup_temp_files(&files);
}
#[test]
fn resolve_refs_missing_ref_errors() {
let buf = OutputBuffer::new(20);
let result = buf.resolve_refs("grep hello @cmd_deadbeef");
assert!(result.is_err());
}
#[test]
fn resolve_refs_not_buffer_only_with_real_paths() {
let buf = OutputBuffer::new(20);
let id = buf.store("prev".into(), "data".into(), "".into(), 0);
let (_, files, is_buffer_only, _refreshed) = buf
.resolve_refs(&format!("diff {} /etc/passwd", id))
.unwrap();
assert!(!is_buffer_only);
OutputBuffer::cleanup_temp_files(&files);
}
#[test]
fn resolve_refs_temp_files_are_readonly() {
let buf = OutputBuffer::new(20);
let id = buf.store("prev".into(), "data".into(), "".into(), 0);
let (_, files, _, _refreshed) = buf.resolve_refs(&format!("cat {}", id)).unwrap();
assert_eq!(files.len(), 1);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::metadata(&files[0]).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o444);
}
OutputBuffer::cleanup_temp_files(&files);
}
#[test]
fn store_file_uses_file_prefix() {
let buf = OutputBuffer::new(20);
let id = buf.store_file("src/main.rs".into(), "fn main() {}\n".into());
assert!(id.starts_with("@file_"), "got: {}", id);
let entry = buf.get(&id).unwrap();
assert_eq!(entry.stdout, "fn main() {}\n");
assert_eq!(entry.stderr, "");
}
#[test]
fn store_file_with_buffer_ref_path_survives_get() {
let buf = OutputBuffer::new(20);
let derived_id = buf.store_file("@file_abc123".into(), "derived content".into());
let entry = buf.get(&derived_id);
assert!(
entry.is_some(),
"buffer-ref-sourced entry must survive get() (was immediately evicted before fix)"
);
assert_eq!(entry.unwrap().stdout, "derived content");
}
#[test]
fn resolve_refs_substitutes_cmd_ref() {
let buf = OutputBuffer::new(20);
let id = buf.store("echo hi".into(), "hello\n".into(), "".into(), 0);
let (resolved, files, _, _refreshed) =
buf.resolve_refs(&format!("grep hello {}", id)).unwrap();
assert!(!resolved.contains('@'), "got: {}", resolved);
assert!(resolved.starts_with("grep hello /"));
OutputBuffer::cleanup_temp_files(&files);
}
#[test]
fn resolve_refs_substitutes_file_ref() {
let buf = OutputBuffer::new(20);
let id = buf.store_file("README.md".into(), "# Hello\n".into());
let (resolved, files, _, _refreshed) = buf.resolve_refs(&format!("wc -l {}", id)).unwrap();
assert!(!resolved.contains('@'), "got: {}", resolved);
OutputBuffer::cleanup_temp_files(&files);
}
#[test]
fn resolve_refs_err_suffix_writes_stderr() {
let buf = OutputBuffer::new(20);
let id = buf.store("cmd".into(), "out".into(), "err_text".into(), 0);
let err_ref = format!("{}.err", id);
let (resolved, files, _, _refreshed) =
buf.resolve_refs(&format!("grep x {}", err_ref)).unwrap();
let tmp_path = resolved.split_whitespace().last().unwrap();
let content = std::fs::read_to_string(tmp_path).unwrap();
assert_eq!(content, "err_text");
OutputBuffer::cleanup_temp_files(&files);
}
#[test]
fn resolve_refs_unknown_ref_returns_error() {
let buf = OutputBuffer::new(20);
let result = buf.resolve_refs("grep foo @cmd_deadbeef");
assert!(result.is_err());
}
#[test]
fn is_buffer_only_true_for_unix_tools_on_refs() {
let buf = OutputBuffer::new(20);
let id = buf.store("cmd".into(), "data".into(), "".into(), 0);
let cmd = format!("grep foo {}", id);
let (resolved, files, is_buf_only, _refreshed) = buf.resolve_refs(&cmd).unwrap();
assert!(is_buf_only, "resolved: {}", resolved);
OutputBuffer::cleanup_temp_files(&files);
}
#[test]
fn is_buffer_only_false_for_plain_commands() {
assert!(!OutputBuffer::is_buffer_only("grep foo /etc/passwd"));
}
#[test]
fn is_buffer_only_false_for_relative_path_without_dot_prefix() {
assert!(
OutputBuffer::is_buffer_only("grep pattern @cmd_abc1234"),
"pure buffer command must be classified as buffer-only"
);
assert!(
!OutputBuffer::is_buffer_only("grep pattern @cmd_abc1234 src/main.rs"),
"relative path without ./ prefix must NOT be classified as buffer-only"
);
assert!(
!OutputBuffer::is_buffer_only("grep pattern @cmd_abc1234 ./src/main.rs"),
"./ prefix must NOT be classified as buffer-only"
);
assert!(
!OutputBuffer::is_buffer_only("grep pattern @cmd_abc1234 /tmp/file.rs"),
"absolute path must NOT be classified as buffer-only"
);
}
#[test]
fn resolve_refs_not_buffer_only_when_relative_path_arg_present() {
let buf = OutputBuffer::new(20);
let id = buf.store("cmd".into(), "output".into(), "".into(), 0);
let (_, files, is_buf_only, _) = buf.resolve_refs(&format!("cat {id}")).unwrap();
OutputBuffer::cleanup_temp_files(&files);
assert!(
is_buf_only,
"command with only buffer refs must be buffer-only"
);
let cmd = format!("grep foo {id} src/main.rs");
let (_, files, is_buf_only, _) = buf.resolve_refs(&cmd).unwrap();
OutputBuffer::cleanup_temp_files(&files);
assert!(
!is_buf_only,
"relative path arg must not classify command as buffer-only"
);
let cmd = format!("diff {id} /etc/passwd");
let (_, files, is_buf_only, _) = buf.resolve_refs(&cmd).unwrap();
OutputBuffer::cleanup_temp_files(&files);
assert!(
!is_buf_only,
"absolute path arg must not classify command as buffer-only"
);
}
#[test]
fn store_tool_generates_tool_ref() {
let buf = OutputBuffer::new(10);
let id = buf.store_tool("symbols", "{\"symbols\":[]}".to_string());
assert!(
id.starts_with("@tool_"),
"expected @tool_ prefix, got {}",
id
);
}
#[test]
fn store_tool_stores_as_stdout_no_stderr() {
let buf = OutputBuffer::new(10);
let json = "{\"symbols\":[1,2,3]}".to_string();
let id = buf.store_tool("symbols", json.clone());
let entry = buf.get(&id).unwrap();
assert_eq!(entry.stdout, json);
assert_eq!(entry.stderr, "");
assert_eq!(entry.exit_code, 0);
assert_eq!(entry.command, "symbols");
}
#[test]
fn resolve_refs_substitutes_tool_ref() {
let buf = OutputBuffer::new(10);
let json = "{\"symbols\":[]}".to_string();
let id = buf.store_tool("symbols", json);
let cmd = format!("jq '.symbols' {}", id);
let (resolved, _paths, _is_buf_only, _refreshed) = buf.resolve_refs(&cmd).unwrap();
assert!(
!resolved.contains("@tool_"),
"ref should be substituted, got: {}",
resolved
);
}
#[test]
fn store_dangerous_returns_ack_handle() {
let buf = OutputBuffer::new(10);
let handle = buf.store_dangerous(
"rm -rf /dist".to_string(),
Some("frontend/".to_string()),
30,
);
assert!(
handle.starts_with("@ack_"),
"handle should start with @ack_, got: {handle}"
);
}
#[test]
fn get_dangerous_returns_stored_command() {
let buf = OutputBuffer::new(10);
let handle = buf.store_dangerous(
"rm -rf /dist".to_string(),
Some("frontend/".to_string()),
10,
);
let cmd = buf
.get_dangerous(&handle)
.expect("should find stored command");
assert_eq!(cmd.command, "rm -rf /dist");
assert_eq!(cmd.cwd, Some("frontend/".to_string()));
assert_eq!(cmd.timeout_secs, 10);
}
#[test]
fn get_dangerous_returns_none_for_unknown_handle() {
let buf = OutputBuffer::new(10);
assert!(buf.get_dangerous("@ack_deadbeef").is_none());
}
#[test]
fn pending_acks_lru_eviction() {
let buf = OutputBuffer::new(10);
let mut handles = Vec::new();
for i in 0..21u64 {
handles.push(buf.store_dangerous(format!("cmd_{}", i), None, 30));
}
assert!(
buf.get_dangerous(&handles[0]).is_none(),
"oldest ack should be evicted"
);
assert!(
buf.get_dangerous(&handles[20]).is_some(),
"newest ack should survive"
);
}
#[test]
fn resolve_refs_rejects_ack_handle_interpolation() {
let buf = OutputBuffer::new(10);
let handle = buf.store_dangerous("rm -rf /dist".to_string(), None, 30);
let result = buf.resolve_refs(&format!("grep pattern {handle}"));
assert!(
result.is_err(),
"interpolating an @ack_ handle should return an error"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("ack handle"),
"error should mention 'ack handle', got: {msg}"
);
}
#[test]
fn store_file_sets_source_path() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("foo.rs");
fs::write(&file_path, "content").unwrap();
let buf = OutputBuffer::new(10);
let path_str = file_path.to_string_lossy().to_string();
let id = buf.store_file(path_str.clone(), "content".to_string());
let entry = buf.get(&id).unwrap();
assert_eq!(entry.source_path, Some(file_path));
}
#[test]
fn store_cmd_has_no_source_path() {
let buf = OutputBuffer::new(10);
let id = buf.store("echo hi".to_string(), "hi".to_string(), "".to_string(), 0);
let entry = buf.get(&id).unwrap();
assert_eq!(entry.source_path, None);
}
#[test]
fn store_tool_has_no_source_path() {
let buf = OutputBuffer::new(10);
let id = buf.store_tool("my_tool", "output".to_string());
let entry = buf.get(&id).unwrap();
assert_eq!(entry.source_path, None);
}
#[test]
fn get_file_handle_refreshes_when_file_modified() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.txt");
fs::write(&file_path, "original content").unwrap();
let buf = OutputBuffer::new(10);
let id = buf.store_file(
file_path.to_string_lossy().to_string(),
"original content".to_string(),
);
assert_eq!(buf.get(&id).unwrap().stdout, "original content");
fs::write(&file_path, "updated content").unwrap();
let past = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1);
filetime::set_file_mtime(&file_path, filetime::FileTime::from_system_time(past)).unwrap();
assert_eq!(
buf.get(&id).unwrap().stdout,
"original content",
"expected cached content when mtime has not advanced"
);
let future = std::time::SystemTime::now() + std::time::Duration::from_secs(2);
filetime::set_file_mtime(&file_path, filetime::FileTime::from_system_time(future)).unwrap();
let entry = buf.get(&id).unwrap();
assert_eq!(entry.stdout, "updated content");
}
#[test]
fn get_file_handle_returns_none_when_file_deleted() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.txt");
fs::write(&file_path, "hello").unwrap();
let buf = OutputBuffer::new(10);
let id = buf.store_file(file_path.to_string_lossy().to_string(), "hello".to_string());
assert!(buf.get(&id).is_some());
fs::remove_file(&file_path).unwrap();
assert!(buf.get(&id).is_none());
}
#[test]
fn get_file_handle_unmodified_returns_cached() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.txt");
fs::write(&file_path, "stable content").unwrap();
let buf = OutputBuffer::new(10);
let id = buf.store_file(
file_path.to_string_lossy().to_string(),
"stable content".to_string(),
);
assert_eq!(buf.get(&id).unwrap().stdout, "stable content");
assert_eq!(buf.get(&id).unwrap().stdout, "stable content");
}
#[test]
fn get_cmd_handle_not_affected_by_refresh_logic() {
let buf = OutputBuffer::new(10);
let id = buf.store("echo hi".to_string(), "hi".to_string(), "".to_string(), 0);
let entry = buf.get(&id).unwrap();
assert_eq!(entry.stdout, "hi");
}
#[test]
fn get_with_refresh_flag_returns_true_when_file_changed() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.txt");
fs::write(&path, "original").unwrap();
let buf = OutputBuffer::new(10);
let id = buf.store_file(path.to_string_lossy().to_string(), "original".to_string());
fs::write(&path, "modified").unwrap();
let future = std::time::SystemTime::now() + std::time::Duration::from_secs(2);
filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(future)).unwrap();
let (entry, was_refreshed) = buf.get_with_refresh_flag(&id).unwrap();
assert!(was_refreshed, "should report refresh when file changed");
assert_eq!(entry.stdout, "modified");
}
#[test]
fn get_with_refresh_flag_returns_false_when_file_unchanged() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.txt");
fs::write(&path, "content").unwrap();
let buf = OutputBuffer::new(10);
let id = buf.store_file(path.to_string_lossy().to_string(), "content".to_string());
let (entry, was_refreshed) = buf.get_with_refresh_flag(&id).unwrap();
assert!(
!was_refreshed,
"should not report refresh when file unchanged"
);
assert_eq!(entry.stdout, "content");
}
#[test]
fn get_with_refresh_flag_returns_false_for_cmd_entries() {
let buf = OutputBuffer::new(10);
let id = buf.store("echo hi".to_string(), "hi".to_string(), String::new(), 0);
let (entry, was_refreshed) = buf.get_with_refresh_flag(&id).unwrap();
assert!(!was_refreshed, "cmd entries never refresh");
assert_eq!(entry.stdout, "hi");
}
#[test]
fn resolve_refs_reports_refreshed_file_handle() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data.txt");
fs::write(&path, "original").unwrap();
let buf = OutputBuffer::new(10);
let id = buf.store_file(path.to_string_lossy().to_string(), "original".to_string());
fs::write(&path, "updated").unwrap();
let future = std::time::SystemTime::now() + std::time::Duration::from_secs(2);
filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(future)).unwrap();
let cmd = format!("cat {}", id);
let (_resolved, _temps, _buffer_only, refreshed) = buf.resolve_refs(&cmd).unwrap();
assert_eq!(refreshed, vec![id], "should report the refreshed handle");
}
#[test]
fn resolve_refs_no_refresh_for_unchanged_file() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data.txt");
fs::write(&path, "content").unwrap();
let buf = OutputBuffer::new(10);
let id = buf.store_file(path.to_string_lossy().to_string(), "content".to_string());
let cmd = format!("cat {}", id);
let (_resolved, _temps, _buffer_only, refreshed) = buf.resolve_refs(&cmd).unwrap();
assert!(refreshed.is_empty(), "no refresh when file unchanged");
}
#[test]
fn resolve_refs_no_refresh_for_cmd_handle() {
let buf = OutputBuffer::new(10);
let id = buf.store("cmd".to_string(), "output".to_string(), String::new(), 0);
let cmd = format!("grep foo {}", id);
let (_resolved, _temps, _buffer_only, refreshed) = buf.resolve_refs(&cmd).unwrap();
assert!(refreshed.is_empty(), "cmd handles never refresh");
}
#[test]
fn store_background_returns_bg_prefix() {
let buf = OutputBuffer::new(10);
let path = std::path::PathBuf::from("/tmp/test-codescout.log");
let id = buf.store_background(path.clone());
assert!(id.starts_with("@bg_"), "expected @bg_ prefix, got {id}");
assert_eq!(buf.get_background(&id), Some(path));
}
#[test]
fn get_background_missing_returns_none() {
let buf = OutputBuffer::new(10);
assert_eq!(buf.get_background("@bg_00000000"), None);
}
#[test]
fn resolve_refs_bg_reads_fresh_from_disk() {
use std::io::Write;
let buf = OutputBuffer::new(10);
let mut tmp = tempfile::NamedTempFile::new().unwrap();
write!(tmp, "first content").unwrap();
tmp.flush().unwrap();
let log_path = tmp.path().to_path_buf();
let id = buf.store_background(log_path.clone());
let (resolved1, temps1, _, _) = buf.resolve_refs(&id).unwrap();
let got1 = std::fs::read_to_string(&resolved1).unwrap();
OutputBuffer::cleanup_temp_files(&temps1);
assert_eq!(got1.trim(), "first content");
std::fs::write(&log_path, "second content").unwrap();
let (resolved2, temps2, _, _) = buf.resolve_refs(&id).unwrap();
let got2 = std::fs::read_to_string(&resolved2).unwrap();
OutputBuffer::cleanup_temp_files(&temps2);
assert_eq!(got2.trim(), "second content");
}
#[test]
fn resolve_refs_bg_missing_file_errors() {
let buf = OutputBuffer::new(10);
let id = buf.store_background(std::path::PathBuf::from(
"/tmp/nonexistent-codescout-bg-test-xyz.log",
));
let err = buf.resolve_refs(&id).unwrap_err();
assert!(
err.to_string().contains("background job log unavailable"),
"unexpected error: {err}"
);
}
#[test]
fn resolve_refs_bg_rejects_err_suffix() {
let buf = OutputBuffer::new(10);
let id = buf.store_background(std::path::PathBuf::from("/tmp/fake.log"));
let err_ref = format!("{}.err", id);
let err = buf.resolve_refs(&err_ref).unwrap_err();
assert!(
err.to_string().contains("do not support the .err suffix"),
"unexpected error: {err}"
);
}
#[test]
fn resolve_refs_bg_stale_ref_returns_recoverable_error() {
let buf = OutputBuffer::new(10);
let result = buf.resolve_refs("tail -20 @bg_00000007");
assert!(result.is_err(), "stale @bg_ ref must error");
let err = result.unwrap_err();
let rec = err
.downcast_ref::<crate::tools::RecoverableError>()
.expect("must be RecoverableError, not a panic or anyhow");
assert!(
rec.message.contains("not found"),
"error should mention 'not found', got: {}",
rec.message
);
assert!(
rec.hint().unwrap_or("").contains("session resets"),
"hint should mention session resets"
);
}
}