#[cfg(test)]
#[allow(unused_imports)]
use crate::sync_util::LockExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
pub const DEFAULT_INLINE_MAX_BYTES: usize = 8 * 1024;
pub const DEFAULT_LINE_THRESHOLD: usize = 200;
const HEAD_LINES: usize = 50;
const TAIL_LINES: usize = 50;
const CLEANUP_MAX_AGE_SECS: u64 = 24 * 60 * 60;
static BASH_INLINE_MAX: AtomicUsize = AtomicUsize::new(0);
static WEBFETCH_INLINE_MAX: AtomicUsize = AtomicUsize::new(0);
static TASK_INLINE_MAX: AtomicUsize = AtomicUsize::new(0);
pub fn set_thresholds(bash: Option<usize>, webfetch: Option<usize>, task: Option<usize>) {
if let Some(n) = bash {
BASH_INLINE_MAX.store(n.max(1), Ordering::Relaxed);
}
if let Some(n) = webfetch {
WEBFETCH_INLINE_MAX.store(n.max(1), Ordering::Relaxed);
}
if let Some(n) = task {
TASK_INLINE_MAX.store(n.max(1), Ordering::Relaxed);
}
}
pub fn inline_max_bytes_for(tool: &str) -> usize {
let n = match tool {
"bash" => BASH_INLINE_MAX.load(Ordering::Relaxed),
"webfetch" => WEBFETCH_INLINE_MAX.load(Ordering::Relaxed),
"task" => TASK_INLINE_MAX.load(Ordering::Relaxed),
_ => 0,
};
if n == 0 { DEFAULT_INLINE_MAX_BYTES } else { n }
}
fn fits_inline(output: &str, inline_max_bytes: usize) -> bool {
if output.len() > inline_max_bytes {
return false;
}
let line_count = if output.is_empty() {
0
} else {
let nl = output.bytes().filter(|b| *b == b'\n').count();
if output.ends_with('\n') { nl } else { nl + 1 }
};
line_count <= DEFAULT_LINE_THRESHOLD
}
pub fn transient_base() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".dirge").join("transient")
} else {
std::env::temp_dir().join("dirge-transient")
}
}
fn cleanup_aged(base: &Path) {
let cutoff = match std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(CLEANUP_MAX_AGE_SECS))
{
Some(t) => t,
None => return, };
let entries = match std::fs::read_dir(base) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let inner = match std::fs::read_dir(&path) {
Ok(e) => e,
Err(_) => continue,
};
let mut still_has_children = false;
for f in inner.flatten() {
let fp = f.path();
let too_old = match f.metadata().and_then(|m| m.modified()) {
Ok(m) => m < cutoff,
Err(_) => false,
};
if too_old {
let _ = std::fs::remove_file(&fp);
} else {
still_has_children = true;
}
}
if !still_has_children {
let _ = std::fs::remove_dir(&path);
}
}
}
fn build_transient_path(tool: &str) -> PathBuf {
static SEQ: AtomicUsize = AtomicUsize::new(0);
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let ts = crate::time_util::now_unix_secs();
transient_base()
.join(format!("{pid}"))
.join(format!("{tool}-{ts}-{seq}.txt"))
}
fn format_summary(tool: &str, full: &str, path: &Path, header_note: &str) -> String {
let lines: Vec<&str> = full.split_inclusive('\n').collect();
let total = lines.len();
let head_end = HEAD_LINES.min(total);
let head: String = lines[..head_end].concat();
let tail_start = total.saturating_sub(TAIL_LINES).max(head_end);
let tail: String = lines[tail_start..].concat();
let elided = tail_start.saturating_sub(head_end);
let path_display = path.display();
let mut out = String::new();
if !header_note.is_empty() {
out.push_str(header_note);
if !header_note.ends_with('\n') {
out.push('\n');
}
}
out.push_str(&head);
if !head.ends_with('\n') && !head.is_empty() {
out.push('\n');
}
if elided > 0 {
out.push_str(&format!("[… {elided} lines elided …]\n"));
}
out.push_str(&tail);
if !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&format!(
"\n[{tool} output relayed: {total} lines, {bytes} bytes total. \
Full output stored at {path_display}. \
Use the `read` tool with `offset`/`limit` to inspect specific portions.]",
bytes = full.len(),
));
out
}
#[derive(Debug)]
pub struct RelayOutcome {
pub text: String,
#[allow(dead_code)]
pub relayed_to: Option<PathBuf>,
}
pub fn relay_if_large(tool: &str, output: String, header_note: &str) -> RelayOutcome {
let inline_max = inline_max_bytes_for(tool);
if fits_inline(&output, inline_max) {
let mut text = output;
if !header_note.is_empty() {
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(header_note);
}
return RelayOutcome {
text,
relayed_to: None,
};
}
let path = build_transient_path(tool);
let mut wrote_ok = false;
if let Some(parent) = path.parent()
&& std::fs::create_dir_all(parent).is_ok()
{
wrote_ok = std::fs::write(&path, output.as_bytes()).is_ok();
}
cleanup_aged(&transient_base());
let summary_path: &Path = &path;
let text = format_summary(tool, &output, summary_path, header_note);
RelayOutcome {
text,
relayed_to: if wrote_ok { Some(path) } else { None },
}
}
#[cfg(test)]
mod tests {
use super::*;
static THRESHOLD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn short_output_passes_through() {
let out = "hello\nworld\n".to_string();
let outcome = relay_if_large("bash", out.clone(), "");
assert!(outcome.relayed_to.is_none(), "expected no relay");
assert_eq!(outcome.text, out);
}
#[test]
fn short_output_appends_header_note() {
let outcome = relay_if_large("bash", "ok\n".to_string(), "Exit code: 0");
assert!(outcome.relayed_to.is_none());
assert!(outcome.text.contains("Exit code: 0"));
assert!(outcome.text.contains("ok"));
}
#[test]
fn at_byte_threshold_passes_through() {
let inline_max = inline_max_bytes_for("nonexistent-tool");
let payload: String = "x".repeat(inline_max);
let outcome = relay_if_large("nonexistent-tool", payload, "");
assert!(
outcome.relayed_to.is_none(),
"exact-threshold output must not relay; got relay at {:?}",
outcome.relayed_to,
);
}
#[test]
fn one_byte_over_threshold_relays() {
let inline_max = inline_max_bytes_for("nonexistent-tool");
let payload: String = "x".repeat(inline_max + 1);
let outcome = relay_if_large("nonexistent-tool", payload, "");
assert!(
outcome.relayed_to.is_some(),
"over-threshold output must relay",
);
let path = outcome.relayed_to.unwrap();
assert!(path.exists(), "transient file must exist on disk");
let meta = std::fs::metadata(&path).expect("read meta");
assert_eq!(
meta.len() as usize,
inline_max + 1,
"transient file should hold the full payload",
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn line_threshold_trips_relay() {
let lines: Vec<String> = (0..DEFAULT_LINE_THRESHOLD + 5)
.map(|i| format!("line {i}"))
.collect();
let payload = lines.join("\n");
let outcome = relay_if_large("bash", payload.clone(), "");
assert!(
outcome.relayed_to.is_some(),
"line-threshold should trip relay even at low byte count",
);
assert!(
outcome.text.contains("read"),
"summary should mention `read` tool"
);
assert!(
outcome.text.contains("elided"),
"summary should mention elided lines",
);
if let Some(p) = outcome.relayed_to {
let _ = std::fs::remove_file(&p);
}
}
#[test]
fn summary_contains_head_tail_and_hint() {
let mut lines: Vec<String> = Vec::new();
for i in 0..500 {
lines.push(format!("LINE{i}"));
}
let payload = lines.join("\n");
let outcome = relay_if_large("bash", payload, "");
let text = &outcome.text;
assert!(text.contains("LINE0"), "summary missing head line");
assert!(text.contains("LINE499"), "summary missing tail line");
assert!(!text.contains("LINE250"), "middle line should be elided");
assert!(text.contains("`read`"), "hint missing `read` reference");
assert!(
text.contains("~/.dirge")
|| text.contains(".dirge/transient")
|| text.contains("/transient/")
|| text.contains("dirge-transient"),
"hint should reference the transient directory: {text}",
);
if let Some(p) = outcome.relayed_to {
let _ = std::fs::remove_file(&p);
}
}
#[test]
fn relay_prepends_header_note() {
let _guard = THRESHOLD_LOCK.lock_ignore_poison();
let payload: String = "x".repeat(inline_max_bytes_for("bash") + 1);
let outcome = relay_if_large("bash", payload, "Exit code: 137");
assert!(outcome.text.starts_with("Exit code: 137"));
if let Some(p) = outcome.relayed_to {
let _ = std::fs::remove_file(&p);
}
}
#[test]
fn config_override_changes_threshold() {
let _guard = THRESHOLD_LOCK.lock_ignore_poison();
let prev = BASH_INLINE_MAX.load(Ordering::Relaxed);
BASH_INLINE_MAX.store(16, Ordering::Relaxed);
let payload = "x".repeat(32); let outcome = relay_if_large("bash", payload, "");
assert!(
outcome.relayed_to.is_some(),
"16-byte threshold should relay 32-byte payload",
);
if let Some(p) = outcome.relayed_to {
let _ = std::fs::remove_file(&p);
}
BASH_INLINE_MAX.store(prev, Ordering::Relaxed);
}
#[test]
fn aged_cleanup_removes_stale_files() {
let base = transient_base();
let pid_dir = base.join("test-aged-cleanup-pid-9999");
let _ = std::fs::remove_dir_all(&pid_dir);
std::fs::create_dir_all(&pid_dir).expect("mkdir");
let stale = pid_dir.join("bash-1-1.txt");
std::fs::write(&stale, b"old").expect("write");
let two_days_ago =
std::time::SystemTime::now() - std::time::Duration::from_secs(48 * 60 * 60);
if let Err(_) = std::fs::File::open(&stale).and_then(|f| {
f.set_modified(two_days_ago)?;
Ok(())
}) {
let _ = std::fs::remove_dir_all(&pid_dir);
return;
}
cleanup_aged(&base);
assert!(
!stale.exists(),
"stale file should have been removed by cleanup_aged",
);
assert!(
!pid_dir.exists(),
"empty pid dir should have been removed by cleanup_aged",
);
}
}