use crate::{Tool, ToolOutputPhase, Workspace, util::UnwrapPoison};
use async_trait::async_trait;
use directories::UserDirs;
use regex::{Regex, RegexSet};
use serde_json::json;
use std::collections::HashSet;
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::LazyLock;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::util::scrub_credentials;
mod profiles;
mod readonly;
use self::profiles::{CARGO_COMPILE_PREFIXES, GEN_FALLBACK, PROFILES, Profile, ShortCircuit};
pub use self::readonly::ShellMode;
use self::readonly::check_command;
pub(super) const SHELL_PREFIXES: &[&str] = &[
"cd",
"pushd",
"popd",
"export",
"source",
".",
"sudo",
"time",
"command",
"builtin",
"env",
"nohup",
"exec",
"nice",
"noglob",
"nocorrect",
"eval",
];
pub(super) const GIT_GLOBAL_FLAGS: &[&str] = &["-C", "--git-dir", "--work-tree", "--bare", "-c"];
const DEFAULT_SHELL_TIMEOUT_SECS: u64 = 300;
const SHELL_PIPE_READ_CAP: usize = 256 * 1024;
const TIMEOUT_OUTPUT_TAIL_CHARS: usize = 2_000;
const MAX_OUTPUT_BYTES: usize = 1_048_576;
#[cfg(not(target_os = "windows"))]
const SAFE_ENV_VARS: &[&str] = &[
"PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR",
];
#[cfg(target_os = "windows")]
const SAFE_ENV_VARS: &[&str] = &[
"PATH",
"PATHEXT",
"HOME",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
"SYSTEMROOT",
"SYSTEMDRIVE",
"WINDIR",
"COMSPEC",
"TEMP",
"TMP",
"TERM",
"LANG",
"USERNAME",
];
fn apply_safe_env(cmd: &mut tokio::process::Command) {
cmd.env_clear();
for &name in SAFE_ENV_VARS {
if let Some(value) = baseline_env_value(name) {
cmd.env(name, value);
}
}
}
fn build_shell_command(command: &str, workspace_root: &Path) -> tokio::process::Command {
#[cfg(not(target_os = "windows"))]
{
let mut process = tokio::process::Command::new("sh");
process.arg("-c").arg(command).current_dir(workspace_root);
apply_safe_env(&mut process);
process
}
#[cfg(target_os = "windows")]
{
const CREATE_NO_WINDOW: u32 = 0x08000000;
let mut process = tokio::process::Command::new("cmd.exe");
process
.arg("/C")
.arg(command)
.current_dir(workspace_root)
.creation_flags(CREATE_NO_WINDOW);
apply_safe_env(&mut process);
process
}
}
#[derive(Debug)]
enum ShellRunResult {
Completed {
stdout: Vec<u8>,
stderr: Vec<u8>,
status: std::process::ExitStatus,
elapsed: Duration,
},
TimedOut {
stdout: Vec<u8>,
stderr: Vec<u8>,
pid: Option<u32>,
elapsed: Duration,
},
SpawnFailed(std::io::Error),
}
async fn read_stream_limited(
reader: &mut (impl tokio::io::AsyncRead + Unpin),
cap: usize,
shared: &Arc<Mutex<Vec<u8>>>,
) -> Vec<u8> {
use tokio::io::AsyncReadExt;
let mut chunk = [0u8; 8192];
loop {
let to_read = {
let guard = shared.lock().unwrap_poison();
if guard.len() >= cap {
chunk.len()
} else {
(cap - guard.len()).min(chunk.len())
}
};
match reader.read(&mut chunk[..to_read]).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let mut guard = shared.lock().unwrap_poison();
if guard.len() < cap {
let take = n.min(cap - guard.len());
guard.extend_from_slice(&chunk[..take]);
}
}
}
}
shared.lock().unwrap_poison().clone()
}
async fn run_command_with_timeout(
cmd: &mut tokio::process::Command,
timeout: Duration,
) -> ShellRunResult {
let start = std::time::Instant::now();
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return ShellRunResult::SpawnFailed(e),
};
let pid = child.id();
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let stdout_shared = Arc::new(Mutex::new(Vec::new()));
let stderr_shared = Arc::new(Mutex::new(Vec::new()));
let stdout_buf = Arc::clone(&stdout_shared);
let stdout_handle = tokio::spawn(async move {
if let Some(mut out) = stdout_pipe {
read_stream_limited(&mut out, SHELL_PIPE_READ_CAP, &stdout_buf).await
} else {
Vec::new()
}
});
let stderr_buf = Arc::clone(&stderr_shared);
let stderr_handle = tokio::spawn(async move {
if let Some(mut err) = stderr_pipe {
read_stream_limited(&mut err, SHELL_PIPE_READ_CAP, &stderr_buf).await
} else {
Vec::new()
}
});
match tokio::time::timeout(timeout, child.wait()).await {
Ok(Ok(status)) => {
let stdout = stdout_handle.await.unwrap_or_default();
let stderr = stderr_handle.await.unwrap_or_default();
ShellRunResult::Completed {
stdout,
stderr,
status,
elapsed: start.elapsed(),
}
}
Ok(Err(e)) => ShellRunResult::SpawnFailed(e),
Err(_) => {
let _ = child.kill().await;
let _ = tokio::time::timeout(Duration::from_secs(2), child.wait()).await;
let _ = tokio::time::timeout(Duration::from_secs(2), stdout_handle).await;
let _ = tokio::time::timeout(Duration::from_secs(2), stderr_handle).await;
let stdout = stdout_shared.lock().unwrap_poison().clone();
let stderr = stderr_shared.lock().unwrap_poison().clone();
ShellRunResult::TimedOut {
stdout,
stderr,
pid,
elapsed: start.elapsed(),
}
}
}
}
fn tail_chars(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let skip = s.chars().count().saturating_sub(max_chars);
s.chars().skip(skip).collect()
}
fn format_timeout_error(
command: &str,
elapsed: Duration,
pid: Option<u32>,
stdout: &[u8],
stderr: &[u8],
) -> String {
let mut msg = format!(
"Shell command timed out.\n\
command: {command}\n\
elapsed: {:.1}s\n\
timeout_limit: {DEFAULT_SHELL_TIMEOUT_SECS}s",
elapsed.as_secs_f64()
);
if let Some(p) = pid {
let _ = write!(msg, "\npid: {p}");
}
msg.push_str("\nreason: command was killed after exceeding the timeout");
if !stdout.is_empty() {
let scrubbed = scrub_credentials(&String::from_utf8_lossy(stdout));
let tail = tail_chars(&scrubbed, TIMEOUT_OUTPUT_TAIL_CHARS);
let _ = write!(
msg,
"\nstdout (last {} chars): {tail}",
tail.chars().count()
);
}
if !stderr.is_empty() {
let scrubbed = scrub_credentials(&String::from_utf8_lossy(stderr));
let tail = tail_chars(&scrubbed, TIMEOUT_OUTPUT_TAIL_CHARS);
let _ = write!(
msg,
"\nstderr (last {} chars): {tail}",
tail.chars().count()
);
}
msg
}
pub struct ShellTool {
pub mode: ShellMode,
}
impl ShellTool {
#[must_use]
pub const fn new(mode: ShellMode) -> Self {
Self { mode }
}
}
#[cfg(unix)]
fn extra_shell_path_prefixes() -> Vec<PathBuf> {
let mut v = Vec::new();
if let Some(dirs) = UserDirs::new() {
let home = dirs.home_dir();
v.push(home.join(".cargo").join("bin"));
v.push(home.join(".npm-global").join("bin"));
}
#[cfg(target_os = "macos")]
{
v.push(PathBuf::from("/opt/homebrew/bin"));
v.push(PathBuf::from("/usr/local/bin"));
}
v
}
#[cfg(windows)]
fn extra_shell_path_prefixes() -> Vec<PathBuf> {
let mut v = Vec::new();
if let Some(dirs) = UserDirs::new() {
v.push(dirs.home_dir().join(".cargo").join("bin"));
}
v
}
#[cfg(unix)]
const fn default_search_path_without_parent_env() -> &'static str {
"/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
}
#[cfg(windows)]
fn windows_system_root() -> String {
r"C:\Windows".to_string()
}
#[cfg(windows)]
fn default_search_path_without_parent_env() -> String {
let root = windows_system_root();
format!(r"{root}\System32;{root};{root}\System32\Wbem;{root}\System32\WindowsPowerShell\v1.0")
}
fn prepend_path_entries(base: &str, extras: &[PathBuf]) -> String {
let sep = if cfg!(windows) { ";" } else { ":" };
let mut seen = HashSet::<String>::new();
let mut parts = Vec::new();
let normalize = |s: &str| -> String {
if cfg!(windows) {
s.to_lowercase()
} else {
s.to_string()
}
};
for p in extras {
let s = p.to_string_lossy().to_string();
if s.is_empty() {
continue;
}
if seen.insert(normalize(&s)) {
parts.push(s);
}
}
for part in base.split(sep) {
if part.is_empty() {
continue;
}
if seen.insert(normalize(part)) {
parts.push(part.to_string());
}
}
parts.join(sep)
}
fn resolved_shell_path() -> String {
let base = default_search_path_without_parent_env();
prepend_path_entries(base, &extra_shell_path_prefixes())
}
fn baseline_env_value(name: &str) -> Option<String> {
match name {
"PATH" => Some(resolved_shell_path()),
"HOME" | "USERPROFILE" => {
UserDirs::new().map(|d| d.home_dir().to_string_lossy().into_owned())
}
"USER" | "USERNAME" => std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.ok()
.or_else(|| Some("user".into())),
"TERM" => Some("dumb".into()),
"LANG" | "LC_ALL" | "LC_CTYPE" => Some("C.UTF-8".into()),
"SHELL" => Some("/bin/sh".into()),
"TMPDIR" => Some("/tmp".into()),
_ => {
#[cfg(windows)]
if let Some(val) = windows_baseline_env_value(name) {
return Some(val);
}
None
}
}
}
#[cfg(windows)]
fn windows_baseline_env_value(name: &str) -> Option<String> {
match name {
"PATHEXT" => Some(".COM;.EXE;.BAT;.CMD;.VBS;.JS".into()),
"HOMEDRIVE" | "HOMEPATH" => UserDirs::new().and_then(|d| {
let s = d.home_dir().to_string_lossy().into_owned();
if s.len() >= 2 && s.as_bytes().get(1) == Some(&b':') {
match name {
"HOMEDRIVE" => Some(s[..2].to_string()),
_ => Some(s[2..].to_string()),
}
} else {
None
}
}),
"SYSTEMROOT" | "WINDIR" => Some(windows_system_root()),
"SYSTEMDRIVE" => Some("C:".into()),
"COMSPEC" => {
let root = windows_system_root();
Some(format!(r"{root}\System32\cmd.exe"))
}
"TEMP" | "TMP" => {
let root = windows_system_root();
Some(format!(r"{root}\Temp"))
}
_ => None,
}
}
#[async_trait]
impl Tool for ShellTool {
fn name(&self) -> &'static str {
"shell"
}
fn description(&self) -> String {
match self.mode {
ShellMode::ReadOnly => {
const RESTRICTION: &str = "\
⚠️ READ-ONLY MODE: You are not permitted to modify the workspace. \
Commands that write files, delete files, or mutate git state will be rejected before execution. \
Writing to the OS temp directory is allowed. \
Use this tool only for inspection: reading files, listing directories, running cargo check/test/clippy, git status/log/diff, searching, etc.\n\n";
let base = crate::prompt::load_prompt(&format!("tool/{}.md", self.name()));
format!("{RESTRICTION}{base}")
}
ShellMode::Full => crate::prompt::load_prompt(&format!("tool/{}.md", self.name())),
}
}
fn parameters_schema(&self) -> serde_json::Value {
super::tool_params_schema(
&json!({
"command": {
"type": "string",
"description": "The shell command to execute"
},
}),
&["command"],
)
}
fn side_effects(&self, _args: &serde_json::Value) -> bool {
self.mode != ShellMode::ReadOnly
}
async fn execute(&self, ws: &Workspace, args: serde_json::Value) -> anyhow::Result<String> {
let command_str = super::get_str(&args, "command")?;
if self.mode == ShellMode::ReadOnly
&& let Err(rejection) = check_command(command_str)
{
anyhow::bail!("{rejection}");
}
let mut cmd = build_shell_command(command_str, ws.as_path());
let result =
run_command_with_timeout(&mut cmd, Duration::from_secs(DEFAULT_SHELL_TIMEOUT_SECS))
.await;
match result {
ShellRunResult::Completed {
stdout,
stderr,
status,
elapsed,
} => {
let raw_hint = save_raw_output_if_large(&stdout, &stderr, command_str);
let stdout_str = String::from_utf8_lossy(&stdout);
let cleaned_stdout = strip_ansi_escapes(&stdout_str);
let stdout =
crate::util::truncate_sandwich(&cleaned_stdout, MAX_OUTPUT_BYTES, "output");
let stderr_str = String::from_utf8_lossy(&stderr);
let stderr =
crate::util::truncate_sandwich(&stderr_str, MAX_OUTPUT_BYTES, "stderr");
let (exit_code, exit_note) = match status.code() {
Some(c) => (c, format!("[exit status: {c}]")),
None => (-1, "[exit status: terminated by signal]".to_string()),
};
let processed =
process_shell_output(command_str, &stdout, &stderr, exit_code, elapsed);
let mut combined = processed;
if let Some(hint) = &raw_hint {
combined.push('\n');
combined.push_str(hint);
}
if exit_code != 0 {
combined.push_str("\n\n");
combined.push_str(&exit_note);
}
Ok(combined)
}
ShellRunResult::TimedOut {
stdout,
stderr,
pid,
elapsed,
} => {
tracing::warn!(
command = command_str,
elapsed_secs = elapsed.as_secs_f64(),
?pid,
stdout_bytes = stdout.len(),
stderr_bytes = stderr.len(),
"Shell command timed out"
);
let msg = format_timeout_error(command_str, elapsed, pid, &stdout, &stderr);
anyhow::bail!("{msg}");
}
ShellRunResult::SpawnFailed(e) => anyhow::bail!(
"Failed to start shell command.\n\
command: {command_str}\n\
reason: {e}"
),
}
}
fn debug_output(
&self,
phase: ToolOutputPhase,
args: &serde_json::Value,
outcome: Option<&crate::tools::ToolExecutionOutcome>,
) -> Option<String> {
match phase {
ToolOutputPhase::Before => {
let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("?");
Some(cmd.to_owned())
}
ToolOutputPhase::After => {
let outcome = outcome?;
let trimmed = outcome.output.trim();
if trimmed.is_empty() {
return None;
}
Some(crate::util::truncate_sandwich(trimmed, 2000, "debug"))
}
}
}
}
const SPILL_THRESHOLD_BYTES: usize = 5_000;
static SPILL_DIR_CLEANED: AtomicBool = AtomicBool::new(false);
const fn check_outside_quotes(c: char, in_single: &mut bool, in_double: &mut bool) -> bool {
match c {
'\'' if !*in_double => {
*in_single = !*in_single;
false
}
'"' if !*in_single => {
*in_double = !*in_double;
false
}
_ => !*in_single && !*in_double,
}
}
fn extract_command_segments(command: &str) -> Vec<String> {
let mut segments = Vec::new();
let mut current = String::new();
let mut in_single = false;
let mut in_double = false;
let mut chars = command.chars().peekable();
let mut flush = |current: &mut String| {
if !current.trim().is_empty() {
segments.push(current.trim().to_string());
}
current.clear();
};
while let Some(c) = chars.next() {
if c == '\\' && !in_single {
if let Some(next) = chars.next() {
current.push(next);
} else {
current.push(c); }
continue;
}
if check_outside_quotes(c, &mut in_single, &mut in_double) {
match c {
'&' if chars.peek() == Some(&'&') => {
chars.next(); flush(&mut current);
continue;
}
'|' => {
if chars.peek() == Some(&'|') {
chars.next();
}
flush(&mut current);
continue;
}
';' => {
flush(&mut current);
continue;
}
_ => {}
}
}
current.push(c);
}
flush(&mut current);
segments
}
pub(super) fn find_first_non_flag_index(words: &[&str], is_git: bool) -> Option<usize> {
let mut i = 0;
while i < words.len() {
let w = words[i];
if is_git && GIT_GLOBAL_FLAGS.contains(&w) {
i += 2; continue;
}
if w.starts_with('-') {
i += 1;
continue;
}
return Some(i);
}
None
}
fn find_first_command_word_index(words: &[&str]) -> Option<usize> {
words
.iter()
.position(|w| !SHELL_PREFIXES.contains(w) && !w.starts_with('-') && !is_env_assignment(w))
}
fn command_word_and_index<'a>(words: &[&'a str]) -> Option<(usize, &'a str)> {
let cmd_idx = find_first_command_word_index(words)?;
let basename = words[cmd_idx]
.rsplit('/')
.next()
.expect("rsplit always yields at least one element");
Some((cmd_idx, basename))
}
pub(super) fn first_command_word(segment: &str) -> &str {
let trimmed = segment.trim();
let words: Vec<&str> = trimmed.split_whitespace().collect();
let Some((_, cmd)) = command_word_and_index(&words) else {
return "";
};
cmd
}
pub(super) fn canonical_command(segment: &str) -> String {
let trimmed = segment.trim();
let words: Vec<&str> = trimmed.split_whitespace().collect();
let Some((cmd_idx, cmd)) = command_word_and_index(&words) else {
return String::new();
};
let remaining = &words[cmd_idx + 1..];
if remaining.is_empty() {
return cmd.to_string();
}
let is_git = cmd == "git";
if let Some(sub_idx) = find_first_non_flag_index(remaining, is_git) {
format!("{} {}", cmd, remaining[sub_idx])
} else {
cmd.to_string()
}
}
pub(super) fn is_env_assignment(word: &str) -> bool {
if let Some(eq_pos) = word.find('=')
&& eq_pos > 0
{
let prefix = &word[..eq_pos];
return prefix
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& prefix
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_');
}
false
}
fn select_profile(segments: &[String], is_chained: bool) -> &'static Profile {
for segment in segments {
let canonical = canonical_command(segment);
if canonical.is_empty() {
continue;
}
for p in PROFILES.iter() {
if is_chained && p.standalone_only {
continue;
}
if p.match_command.is_match(&canonical) {
return p;
}
}
}
&GEN_FALLBACK
}
fn combine_output(
stdout: &str,
stderr: &str,
exit_code: i32,
keep_stderr: Option<&RegexSet>,
) -> String {
let exit_ok = exit_code == 0;
if stderr.trim().is_empty() {
return stdout.to_string();
}
if exit_ok {
if let Some(patterns) = keep_stderr {
let relevant: Vec<&str> = stderr.lines().filter(|l| patterns.is_match(l)).collect();
if !relevant.is_empty() {
if stdout.is_empty() {
return format!("stderr:\n{}", relevant.join("\n"));
}
return format!("{stdout}\nstderr:\n{}", relevant.join("\n"));
}
}
return stdout.to_string();
}
if stdout.is_empty() {
return format!("stderr:\n{}", stderr.trim());
}
format!("{stdout}\nstderr:\n{}", stderr.trim())
}
fn finish_shell_output(
mut combined: String,
elapsed: Duration,
full_output_for_spill: Option<&str>,
) -> String {
if elapsed.as_secs_f64() >= 1.0 {
let _ = write!(combined, "\n[took {:.1}s]", elapsed.as_secs_f64());
}
if let Some(pre) = full_output_for_spill
&& pre.len() > SPILL_THRESHOLD_BYTES
{
let scrubbed = scrub_credentials(pre);
let byte_count = scrubbed.len();
let line_count = scrubbed.lines().count();
if let Some(path) = spill_output(&scrubbed) {
let hint = format_spill_header(&path, byte_count, line_count);
combined.push('\n');
combined.push_str(&hint);
}
return combined;
}
try_spill_to_file(combined, SPILL_THRESHOLD_BYTES)
}
fn collapse_blank_lines(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut blank_run = 0usize;
for line in input.lines() {
if line.trim().is_empty() {
blank_run += 1;
if blank_run > 2 {
continue; }
} else {
blank_run = 0;
}
if !result.is_empty() {
result.push('\n');
}
result.push_str(line);
}
result
}
pub(super) fn filter_cargo_test_output(output: &str, exit_code: i32) -> String {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Section {
Normal,
InFailures,
}
struct CargoTestFilter {
section: Section,
has_failures: bool,
has_compile_errors: bool,
summary_lines: Vec<String>,
output_lines: Vec<String>,
}
let exit_ok = exit_code == 0;
let mut f = CargoTestFilter {
section: Section::Normal,
has_failures: false,
has_compile_errors: false,
summary_lines: Vec::new(),
output_lines: Vec::new(),
};
for line in output.lines() {
let trimmed = line.trim_start();
if CARGO_COMPILE_PREFIXES
.iter()
.any(|p| *p != "Running" && trimmed.starts_with(p))
{
continue;
}
if trimmed.starts_with("test ") && trimmed.contains("... ok") {
continue;
}
if trimmed.starts_with("running ") {
continue;
}
if trimmed.starts_with("error[") || trimmed.starts_with("error:") {
f.has_compile_errors = true;
}
if trimmed == "failures:" {
f.section = Section::InFailures;
continue;
}
if trimmed.starts_with("test result:") {
f.summary_lines.push(line.to_string());
f.section = Section::Normal;
continue;
}
if f.section != Section::Normal {
f.has_failures = true;
f.output_lines.push(line.to_string());
continue;
}
f.output_lines.push(line.to_string());
}
if f.has_failures {
let mut result = f.output_lines.join("\n");
if !f.summary_lines.is_empty() {
if !result.is_empty() {
result.push('\n');
}
result.push_str(&f.summary_lines.join("\n"));
}
return result;
}
if f.has_compile_errors && !exit_ok {
let lines: Vec<&str> = f
.output_lines
.iter()
.map(String::as_str)
.filter(|l| !l.trim().is_empty())
.collect();
let last = lines
.iter()
.rev()
.take(15)
.rev()
.copied()
.collect::<Vec<_>>();
return last.join("\n");
}
if !f.summary_lines.is_empty() {
return f.summary_lines.join("\n");
}
let result = output.to_string();
if exit_ok && result.trim().is_empty() {
"[cargo test: ok]".to_string()
} else {
result
}
}
fn parse_ls_line(line: &str) -> Option<(char, String, String)> {
if line.starts_with("total ") || line.trim().is_empty() {
return None;
}
let mut parts = line.split_whitespace();
let permissions = parts.next()?;
if permissions.len() < 10
|| !(permissions.starts_with('-')
|| permissions.starts_with('d')
|| permissions.starts_with('l'))
{
return None;
}
let file_type = permissions.chars().next()?;
parts.next(); parts.next(); parts.next(); let size = parts.next()?.to_string();
parts.next(); parts.next(); parts.next(); let name = parts.collect::<Vec<_>>().join(" ").trim().to_string();
if name.is_empty() || name == "." || name == ".." {
return None;
}
let name = name
.split(" -> ")
.next()
.expect("split always yields at least one element")
.to_string();
if name.is_empty() {
return None;
}
Some((file_type, size, name))
}
#[allow(clippy::cast_precision_loss)]
fn human_readable_size(size: &str) -> String {
if let Ok(bytes) = size.parse::<u64>() {
if bytes >= 1_000_000_000 {
format!("{:.1}G", bytes as f64 / 1_000_000_000.0)
} else if bytes >= 1_000_000 {
format!("{:.1}M", bytes as f64 / 1_000_000.0)
} else if bytes >= 1_000 {
format!("{:.1}K", bytes as f64 / 1_000.0)
} else {
format!("{bytes}B")
}
} else {
size.to_string() }
}
pub(super) fn compact_ls(output: &str, _exit_code: i32) -> String {
if !output.lines().any(|line| line.starts_with("total ")) {
return output.to_string();
}
let mut dirs: Vec<String> = Vec::new();
let mut files: Vec<(String, String)> = Vec::new();
let mut ext_counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut lines_seen = 0usize;
for line in output.lines() {
if line.starts_with("total ") || line.trim().is_empty() {
continue;
}
lines_seen += 1;
let Some((file_type, size, name)) = parse_ls_line(line) else {
continue;
};
if file_type == 'd' {
dirs.push(name);
} else {
let ext = if let Some((_, e)) = name.rsplit_once('.') {
format!(".{e}")
} else {
"no ext".to_string()
};
*ext_counts.entry(ext).or_insert(0) += 1;
let human = human_readable_size(&size);
files.push((name, human));
}
}
if dirs.is_empty() && files.is_empty() {
if lines_seen > 0 {
return "(empty)\n".to_string();
}
return output.to_string();
}
let mut entries = String::new();
for d in &dirs {
let _ = writeln!(entries, "{d}/");
}
for (name, size) in &files {
let _ = writeln!(entries, "{name} {size}");
}
let _ = write!(
entries,
"Summary: {} files, {} dirs",
files.len(),
dirs.len()
);
if !ext_counts.is_empty() {
let mut sorted: Vec<_> = ext_counts.iter().collect();
sorted.sort_by(|a, b| b.1.cmp(a.1));
let parts: Vec<String> = sorted
.iter()
.take(5)
.map(|(ext, count)| format!("{count} {ext}"))
.collect();
let _ = write!(entries, " ({})", parts.join(", "));
if sorted.len() > 5 {
let _ = write!(entries, ", +{} more", sorted.len() - 5);
}
}
entries.push('\n');
entries
}
fn process_shell_output(
command: &str,
stdout: &str,
stderr: &str,
exit_code: i32,
elapsed: Duration,
) -> String {
let segments = extract_command_segments(command);
let is_chained = segments.len() > 1;
let profile = select_profile(&segments, is_chained);
apply_profile_pipeline(profile, stdout, stderr, exit_code, elapsed, is_chained)
}
fn match_short_circuit<'a>(output: &str, short_circuits: &'a [ShortCircuit]) -> Option<&'a str> {
let blob = output.trim();
for sc in short_circuits {
if sc.pattern.is_match(blob)
&& !sc
.unless
.as_ref()
.is_some_and(|unless_re| unless_re.is_match(blob))
{
return Some(sc.message);
}
}
None
}
fn apply_strip_lines(output: &str, profile: &Profile) -> String {
output
.lines()
.filter(|l| {
if let Some(ref set) = profile.strip_lines
&& set.is_match(l)
{
return false;
}
true
})
.collect::<Vec<_>>()
.join("\n")
}
fn split_head_tail(
output: &str,
head_count: usize,
tail_count: usize,
) -> (Vec<String>, usize, Vec<String>) {
let lines: Vec<&str> = output.lines().collect();
let total = lines.len();
if total <= head_count + tail_count {
(
lines.iter().map(ToString::to_string).collect(),
0,
Vec::new(),
)
} else {
let head = lines[..head_count.min(total)]
.iter()
.map(ToString::to_string)
.collect();
let tail = lines[total.saturating_sub(tail_count)..]
.iter()
.map(ToString::to_string)
.collect();
(head, total - head_count - tail_count, tail)
}
}
fn apply_line_truncation(output: &str, profile: &Profile) -> (String, Option<String>) {
let head = profile.head_lines.unwrap_or(0);
let tail = profile.tail_lines.unwrap_or(0);
let max = profile.max_lines;
if head == 0 && tail == 0 && max.is_none() {
return (output.to_string(), None);
}
let line_count = output.lines().count();
let should_sandwich =
(head > 0 || tail > 0) && line_count > head + tail && output.len() > SPILL_THRESHOLD_BYTES;
let pre_truncation = if should_sandwich {
Some(output.to_string())
} else {
None
};
let mut result = if should_sandwich {
let (head_lines, omitted, tail_lines) = split_head_tail(output, head, tail);
let mut v = head_lines;
v.push(format!("... ({omitted} lines omitted)"));
v.extend(tail_lines);
v.join("\n")
} else if let Some(max) = max {
cap_at_max_lines(output, max)
} else {
output.to_string()
};
if should_sandwich
&& let Some(max) = max
&& result.lines().count() > max
{
result = cap_at_max_lines(&result, max);
}
(result, pre_truncation)
}
fn cap_at_max_lines(output: &str, max: usize) -> String {
let lines: Vec<&str> = output.lines().collect();
if lines.len() > max {
let truncated = lines.len() - max;
let mut capped = lines[..max].join("\n");
let _ = write!(capped, "\n... ({truncated} lines truncated)");
capped
} else {
output.to_string()
}
}
fn apply_profile_pipeline(
profile: &Profile,
output: &str,
stderr: &str,
exit_code: i32,
elapsed: Duration,
is_chained: bool,
) -> String {
if let Some(json_preview) = try_json_preview(output) {
return combine_output(
&json_preview,
stderr,
exit_code,
profile.keep_stderr.as_ref(),
);
}
if !is_chained && let Some(msg) = match_short_circuit(output, &profile.short_circuits) {
return combine_output(msg, stderr, exit_code, profile.keep_stderr.as_ref());
}
let mut processed = output.to_string();
processed = apply_strip_lines(&processed, profile);
processed = collapse_blank_lines(&processed);
processed = collapse_consecutive_lines(&processed);
if let Some(max) = profile.max_line_len {
processed = truncate_line_width(&processed, max);
}
let (truncated, pre_head_tail) = apply_line_truncation(&processed, profile);
processed = truncated;
if processed.trim().is_empty()
&& let Some(msg) = profile.on_empty
{
let exit_note = if exit_code == 0 { "" } else { " (failed)" };
let secs = elapsed.as_secs_f64();
return combine_output(
&format!("{msg}{exit_note} ({secs:.1}s)"),
stderr,
exit_code,
profile.keep_stderr.as_ref(),
);
}
if let Some(transform) = profile.output_transform {
processed = transform(&processed, exit_code);
}
let combined = combine_output(&processed, stderr, exit_code, profile.keep_stderr.as_ref());
finish_shell_output(combined, elapsed, pre_head_tail.as_deref())
}
fn strip_ansi_escapes(input: &str) -> String {
static RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"\x1B\[[0-9;]*[a-zA-Z]|\x1B\][0-9;]*[^\x1B]*\x1B\\|\x1B[\(\)\[\]KM]|\x1B\][0-9;]*\x07",
)
.unwrap()
});
RE.replace_all(input, "").to_string()
}
fn try_json_preview(input: &str) -> Option<String> {
let trimmed = input.trim();
if trimmed.is_empty() || (!trimmed.starts_with('[') && !trimmed.starts_with('{')) {
return None;
}
if let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(trimmed) {
let count = arr.len();
let preview = if arr.is_empty() {
String::from("[] (empty array)")
} else {
let sample = arr.iter().take(3).collect::<Vec<_>>();
let types = infer_json_types(&sample);
let entries = sample
.iter()
.map(|v| serde_json::to_string(v).unwrap_or_default())
.collect::<Vec<_>>()
.join("\n");
format!(
"[JSON array: {count} items, schema: {types}]\n{entries}\n(total: {} chars)",
input.len()
)
};
return Some(preview);
}
if let Ok(obj) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(trimmed) {
let fields: Vec<String> = obj
.iter()
.map(|(k, v)| {
let t = json_value_type(v);
format!(" {k}: {t}")
})
.collect();
let preview = format!(
"[JSON object: {} fields]\n{}\n(total: {} chars)",
fields.len(),
fields.join("\n"),
input.len()
);
return Some(preview);
}
None
}
fn infer_json_types(values: &[&serde_json::Value]) -> String {
use std::collections::BTreeMap;
let mut fields: BTreeMap<&str, Vec<String>> = BTreeMap::new();
for v in values {
if let Some(obj) = v.as_object() {
for (k, val) in obj {
fields
.entry(k)
.or_default()
.push(json_value_type(val).to_string());
}
}
}
if fields.is_empty() {
return json_value_type(values.first().copied().unwrap_or(&serde_json::Value::Null))
.to_string();
}
fields
.iter()
.map(|(k, types)| {
let unique: Vec<&str> = {
let mut v: Vec<&str> = types.iter().map(String::as_str).collect();
v.sort_unstable();
v.dedup();
v
};
format!("{k}: {}", unique.join(" | "))
})
.collect::<Vec<_>>()
.join(", ")
}
fn json_value_type(v: &serde_json::Value) -> &'static str {
match v {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "bool",
serde_json::Value::Number(n) => {
if n.is_f64() {
"float"
} else {
"int"
}
}
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
fn collapse_consecutive_lines(input: &str) -> String {
const THRESHOLD: usize = 5;
let mut result = String::with_capacity(input.len());
let lines: Vec<&str> = input.lines().collect();
let mut i = 0;
while i < lines.len() {
let current = lines[i];
let mut count = 1;
while i + count < lines.len() && lines[i + count] == current {
count += 1;
}
if count >= THRESHOLD {
if !result.is_empty() {
result.push('\n');
}
result.push_str(current);
let _ = write!(result, "\n[repeated {count} times]");
i += count;
} else {
for _ in 0..count {
if !result.is_empty() {
result.push('\n');
}
result.push_str(current);
}
i += count;
}
}
result
}
fn truncate_line_width(input: &str, max_line_len: usize) -> String {
let mut result = String::with_capacity(input.len());
for line in input.lines() {
if !result.is_empty() {
result.push('\n');
}
if line.len() > max_line_len {
let cut = line.floor_char_boundary(max_line_len);
result.push_str(&line[..cut]);
let _ = write!(
result,
"\n... ({} more chars on this line)",
line.len() - cut
);
} else {
result.push_str(line);
}
}
result
}
fn format_spill_header(path: &Path, byte_count: usize, line_count: usize) -> String {
format!(
"[Output saved to {} ({} bytes, {} lines)]\n\
[view with: read {}]\n",
path.display(),
byte_count,
line_count,
path.display(),
)
}
fn format_spill_preview(output: &str, path: &Path) -> String {
let line_count = output.lines().count();
let byte_count = output.len();
let header = format_spill_header(path, byte_count, line_count);
let (head_lines, omitted, tail_lines) = split_head_tail(output, 5, 5);
if omitted == 0 {
format!("{header}{output}")
} else {
format!(
"{}{}\n... ({} lines omitted)\n{}",
header,
head_lines.join("\n"),
omitted,
tail_lines.join("\n"),
)
}
}
fn agent_temp_dir() -> Option<std::path::PathBuf> {
let dir = std::env::temp_dir().join(".agent");
if !SPILL_DIR_CLEANED.swap(true, std::sync::atomic::Ordering::Relaxed) {
let _ = cleanup_temp_dir(&dir);
}
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
fn cleanup_temp_dir(dir: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let _ = std::fs::remove_file(&path);
}
}
Ok(())
}
fn write_to_spill(content: &str, filename: &str) -> Option<std::path::PathBuf> {
let dir = agent_temp_dir()?;
let path = dir.join(filename);
std::fs::write(&path, content).ok()?;
Some(path)
}
fn spill_output(output: &str) -> Option<std::path::PathBuf> {
let filename = format!("spill_{:04x}.txt", rand::random::<u16>());
write_to_spill(output, &filename)
}
fn try_spill_to_file(output: String, threshold_bytes: usize) -> String {
if output.len() <= threshold_bytes {
return output;
}
let scrubbed = scrub_credentials(&output);
match spill_output(&scrubbed) {
Some(path) => format_spill_preview(&scrubbed, &path),
None => crate::util::format_tool_output(&scrubbed),
}
}
fn save_raw_output_if_large(
stdout_bytes: &[u8],
stderr_bytes: &[u8],
command: &str,
) -> Option<String> {
if stdout_bytes.len() <= MAX_OUTPUT_BYTES && stderr_bytes.len() <= MAX_OUTPUT_BYTES {
return None;
}
let slug: String = command
.chars()
.take(40)
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
let filename = format!("{epoch}_{slug}.raw.log");
let raw = format!(
"stdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(stdout_bytes),
String::from_utf8_lossy(stderr_bytes)
);
let scrubbed = scrub_credentials(&raw);
let line_count = scrubbed.lines().count();
let byte_count = scrubbed.len();
let path = write_to_spill(&scrubbed, &filename)?;
Some(format_spill_header(&path, byte_count, line_count))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workspace::test_ws;
use tempfile::TempDir;
#[test]
fn shell_safe_env_vars() {
for var in SAFE_ENV_VARS {
let lower = var.to_lowercase();
assert!(
!lower.contains("key") && !lower.contains("secret") && !lower.contains("token")
);
}
assert!(SAFE_ENV_VARS.contains(&"PATH"));
assert!(SAFE_ENV_VARS.contains(&"HOME") || SAFE_ENV_VARS.contains(&"USERPROFILE"));
assert!(SAFE_ENV_VARS.contains(&"TERM"));
}
#[cfg(unix)]
#[tokio::test]
async fn build_shell_command_isolates_environment() {
let tmp = TempDir::new().expect("tempdir");
let mut cmd = build_shell_command("env", tmp.path());
let output = cmd.output().await.expect("env should run");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("HOME="), "HOME must be in safe env");
assert!(stdout.contains("PATH="), "PATH must be in safe env");
assert!(
!stdout.contains("CARGO_HOME="),
"CARGO_HOME must not leak into subprocess env"
);
}
#[tokio::test]
async fn shell_executes_allowed_command() {
let tmp = TempDir::new().expect("tempdir");
let result = ShellTool::new(ShellMode::Full)
.execute(&test_ws(tmp.path()), json!({"command": "echo hello"}))
.await;
assert!(
result.is_ok(),
"echo command execution should succeed: {result:?}"
);
let result = result.unwrap();
assert!(result.trim().contains("hello"));
}
#[cfg(unix)]
#[tokio::test]
async fn shell_nonzero_exit_with_stdout_counts_as_success() {
let tmp = TempDir::new().expect("tempdir");
let result = ShellTool::new(ShellMode::Full)
.execute(
&test_ws(tmp.path()),
json!({"command": "echo partial; test -f nonexistent_file_xyz"}),
)
.await;
assert!(
result.is_ok(),
"shell should return Ok(String) when stdout present: {result:?}"
);
let result = result.unwrap();
assert!(result.contains("partial"));
assert!(
result.contains("[exit status: 1]"),
"model should still see real exit status, got {result:?}",
);
}
#[tokio::test]
async fn shell_captures_exit_code() {
let tmp = TempDir::new().expect("tempdir");
let result = ShellTool::new(ShellMode::Full)
.execute(
&test_ws(tmp.path()),
json!({"command": "ls nonexistent_dir_xyz"}),
)
.await;
assert!(
result.is_ok(),
"command with nonexistent path should return ok: {result:?}"
);
let output = result.unwrap();
assert!(
output.contains("[exit status: 1]"),
"output should contain exit status: {output:?}",
);
assert!(
output.contains("nonexistent_dir_xyz"),
"output should contain the error: {output:?}"
);
}
#[tokio::test]
async fn run_command_with_timeout_kills_long_sleep() {
let mut cmd = tokio::process::Command::new("sh");
cmd.arg("-c").arg("sleep 10");
let result = run_command_with_timeout(&mut cmd, Duration::from_secs(1)).await;
match result {
ShellRunResult::TimedOut { elapsed, .. } => {
assert!(
elapsed < Duration::from_secs(3),
"expected ~1s timeout, got {elapsed:?}"
);
}
other => panic!("expected TimedOut, got {other:?}"),
}
}
#[tokio::test]
async fn run_command_with_timeout_captures_partial_stdout() {
let mut cmd = tokio::process::Command::new("sh");
cmd.arg("-c").arg("echo started; sleep 60");
let result = run_command_with_timeout(&mut cmd, Duration::from_secs(2)).await;
match result {
ShellRunResult::TimedOut { stdout, .. } => {
let s = String::from_utf8_lossy(&stdout);
assert!(
s.contains("started"),
"stdout should contain partial output: {s}"
);
}
other => panic!("expected TimedOut, got {other:?}"),
}
}
#[tokio::test]
async fn shell_timeout_error_includes_diagnostics() {
let tmp = TempDir::new().expect("tempdir");
let mut cmd = build_shell_command("echo before-timeout; sleep 30", tmp.path());
let result = run_command_with_timeout(&mut cmd, Duration::from_secs(1)).await;
let ShellRunResult::TimedOut {
stdout,
stderr,
pid,
elapsed,
} = result
else {
panic!("expected timeout");
};
let msg = format_timeout_error("echo test", elapsed, pid, &stdout, &stderr);
assert!(msg.contains("elapsed:"), "msg: {msg}");
assert!(msg.contains("timeout_limit:"), "msg: {msg}");
assert!(msg.contains("before-timeout"), "msg: {msg}");
}
#[test]
fn ansi_escape_stripping() {
let input = "\x1B[31mred\x1B[0m \x1B[1mbold\x1B[22m";
assert_eq!(strip_ansi_escapes(input), "red bold");
}
#[test]
fn ansi_escape_no_op_for_clean_input() {
let input = "hello world";
assert_eq!(strip_ansi_escapes(input), input);
}
#[test]
fn json_array_preview() {
let input = r#"[{"name": "alice", "age": 30}, {"name": "bob", "age": 25}]"#;
let result = try_json_preview(input);
assert!(result.is_some(), "should detect JSON array");
let output = result.unwrap();
assert!(output.contains("2 items"), "should show item count");
assert!(
output.contains("name: string"),
"should infer string schema"
);
assert!(output.contains("age: int"), "should infer int schema");
}
#[test]
fn json_object_preview() {
let input = r#"{"status": "ok", "count": 42}"#;
let result = try_json_preview(input);
assert!(result.is_some(), "should detect JSON object");
let output = result.unwrap();
assert!(output.contains("2 fields"), "should show field count");
assert!(output.contains("status"), "should show field name");
assert!(output.contains("count"), "should show field name");
}
#[test]
fn non_json_passes_through() {
let input = "hello world\nthis is not json";
let result = try_json_preview(input);
assert!(result.is_none(), "should not detect JSON");
}
#[test]
fn collapse_consecutive_lines_cases() {
let cases: &[(&str, &str)] = &[
("a\nb\nb\nb\nb\nb\nb\nc", "a\nb\n[repeated 6 times]\nc"),
("a\nb\nb\nb\nc", "a\nb\nb\nb\nc"),
];
for (input, expected) in cases {
let result = collapse_consecutive_lines(input);
assert_eq!(result, *expected, "input: {input:?}");
}
}
#[test]
fn cargo_build_compiling_lines_stripped() {
let input = "Compiling foo v1.0.0 (/tmp)\nCompiling bar v2.0.0 (/tmp)\n Compiling baz v3.0.0 (/tmp)\nerror[E0425]: cannot find value\n\nFor more information about this error, try `rustc --explain E0425`.\nerror: could not compile `foo` due to 1 previous error";
let result = process_shell_output("cargo build", input, "", 1, Duration::ZERO);
assert!(
!result.contains("Compiling foo"),
"compiling lines should be stripped"
);
assert!(result.contains("error[E0425]"), "error info preserved");
assert!(
result.contains("could not compile"),
"build failure preserved"
);
}
#[test]
fn cargo_check_short_circuit_on_success() {
let input = " Checking foo v1.0.0\n Checking bar v2.0.0\n warning: unused import\n\nwarning: 1 warning emitted\n\n Finished `dev` profile [unoptimized] target\n";
let result = process_shell_output("cargo check", input, "", 0, Duration::ZERO);
assert!(
!result.contains("Checking"),
"checking lines should be stripped"
);
}
#[test]
fn long_lines_truncated() {
let long = "a".repeat(500);
let result = truncate_line_width(&long, 100);
assert!(result.len() < long.len() + 100, "should truncate");
assert!(
result.contains("more chars on this line"),
"should show continuation marker on separate line"
);
let lines: Vec<&str> = result.lines().collect();
assert_eq!(
lines.len(),
2,
"original truncated line + continuation marker"
);
assert_eq!(
lines[0].len(),
100,
"first line should be exactly max_chars"
);
assert!(
!lines[0].contains("..."),
"first line should not contain truncation marker"
);
}
#[test]
fn short_lines_preserved() {
let input = "hello\nworld";
let result = truncate_line_width(input, 500);
assert_eq!(result, input, "short lines should pass through");
}
#[test]
fn spill_writes_file_for_large_output() {
let large = "x".repeat(10_000);
let result = try_spill_to_file(large, 5_000);
assert!(
result.contains("[Output saved to"),
"should contain spill path"
);
assert!(
result.contains("[view with: read "),
"should contain actionable read hint"
);
assert!(result.contains("10000 bytes"), "should mention byte count");
assert!(
std::fs::read_dir(std::env::temp_dir().join(".agent")).is_ok(),
"spill dir should exist"
);
}
#[test]
fn spill_truncates_multi_line_large_output() {
let lines: Vec<String> = (0..800).map(|i| format!("line_{i:04}")).collect();
let large = lines.join("\n");
let large_len = large.len();
assert!(
large_len > 5_000,
"test data {large_len} must exceed spill threshold",
);
let result = try_spill_to_file(large, 5_000);
assert!(
result.contains("[Output saved to"),
"should contain spill path"
);
assert!(
result.contains("[view with: read "),
"should contain actionable read hint"
);
assert!(
result.contains("line_0000"),
"should show first line {result:?}"
);
assert!(result.contains("line_0799"), "should show last line");
assert!(
result.len() < large_len,
"inline preview should be truncated"
);
}
#[test]
fn spill_returns_short_output_as_is() {
let short = "hello".to_string();
let result = try_spill_to_file(short.clone(), 5_000);
assert_eq!(result, short, "short output should pass through unchanged");
}
#[test]
fn compress_shell_output_pipeline_full() {
let input = "Compiling foo v1.0.0 (/tmp)\nCompiling bar v2.0.0 (/tmp)\nresult: ok\nline1\nline2\nline3\nline3\nline3\nline3\nline3\nline3\nline3\n";
let result = process_shell_output("unknown", input, "", 0, Duration::ZERO);
assert!(
!result.contains("\x1B["),
"ANSI escapes should be stripped (input pre-stripped)"
);
assert!(
result.contains("Compiling"),
"generic fallback preserves cargo lines"
);
assert!(
result.contains("[repeated"),
"repeated lines should be collapsed"
);
assert!(
result.contains("result: ok"),
"non-pattern content preserved"
);
}
#[cfg(unix)]
#[test]
fn resolved_shell_path_includes_npm_global_bin() {
let path = resolved_shell_path();
assert!(
path.contains(".npm-global/bin"),
"PATH should include ~/.npm-global/bin for globally installed npm tools: {path}"
);
}
#[cfg(unix)]
#[test]
fn resolved_shell_path_includes_cargo_bin() {
let path = resolved_shell_path();
assert!(
path.contains(".cargo/bin"),
"PATH should include ~/.cargo/bin: {path}"
);
}
#[cfg(target_os = "macos")]
#[test]
fn resolved_shell_path_includes_homebrew() {
let path = resolved_shell_path();
assert!(
path.contains("/opt/homebrew/bin"),
"PATH should include Homebrew bin on macOS: {path}"
);
}
#[test]
fn collapse_blank_lines_cases() {
let cases: &[(&str, &str)] = &[
("a\n\n\n\nb\n\n\nc", "a\n\n\nb\n\n\nc"),
("a\n\nb\n\n\nc\n\n\n\nd", "a\n\nb\n\n\nc\n\n\nd"),
("a\nb\nc", "a\nb\nc"),
("\n\n\n\n\n", ""),
];
for (input, expected) in cases {
let result = collapse_blank_lines(input);
assert_eq!(result, *expected, "input: {input:?}");
}
}
#[test]
fn collapse_blank_lines_then_consecutive_no_marker() {
let input = "a\n\n\n\n\n\nb"; let result = collapse_blank_lines(input);
let result = collapse_consecutive_lines(&result);
assert_eq!(
result, "a\n\n\nb",
"6 blank lines → 2 blanks, no [repeated] marker"
);
assert!(
!result.contains("[repeated"),
"should not contain repeated marker for blank lines"
);
}
#[test]
fn collapse_consecutive_non_blank_still_collapses() {
let input = "x\nx\nx\nx\nx\nx"; let result = collapse_blank_lines(input); let result = collapse_consecutive_lines(&result);
assert!(
result.contains("[repeated 6 times]"),
"6 identical non-blank lines should produce [repeated] marker"
);
assert!(
!result.contains("x\nx\nx\nx\nx\nx"),
"should not keep individual lines"
);
}
#[test]
fn cargo_test_failure_block_capture() {
let output = "\n\
Compiling foo v1.0.0\n\
test test1 ... ok\n\
test test2 ... FAILED\n\
\n\
failures:\n\
\n\
---- test2 stdout ----\n\
thread 'test2' panicked at src/lib.rs:42:\n\
assertion failed\n\
\n\
\n\
failures:\n\
test2\n\
\n\
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out\n\
";
let result = filter_cargo_test_output(output, 1);
assert!(!result.contains("Compiling"), "compiling stripped");
assert!(!result.contains("test1 ... ok"), "passing tests stripped");
assert!(result.contains("test2 ... FAILED"), "failure preserved");
assert!(
result.contains("assertion failed"),
"panic message preserved"
);
assert!(result.contains("test result:"), "summary preserved");
}
#[test]
fn cargo_test_all_pass_returns_summary() {
let output = "\
Compiling foo v1.0.0\n\
Checking bar v2.0.0\n\
test test1 ... ok\n\
test test2 ... ok\n\
\n\
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n\
";
let result = filter_cargo_test_output(output, 0);
assert!(!result.contains("Compiling"), "compiling stripped");
assert!(!result.contains("Checking"), "checking stripped");
assert!(!result.contains("test1 ... ok"), "passing stripped");
assert!(!result.contains("test2 ... ok"), "passing stripped");
assert!(result.contains("test result:"), "summary preserved");
}
#[test]
fn cargo_test_compile_error_fallback() {
let output = "\
Compiling foo v1.0.0\n\
error[E0425]: cannot find value `bar` in this scope\n\
--> src/lib.rs:1:5\n\
\n\
error: could not compile `foo` due to 1 previous error\n\
";
let result = filter_cargo_test_output(output, 1);
assert!(!result.contains("Compiling"), "compiling stripped");
assert!(result.contains("error[E0425]"), "error preserved");
assert!(
result.contains("could not compile"),
"build error preserved"
);
}
#[test]
fn cargo_test_running_preserved() {
let output = "\
Compiling foo v1.0.0\n\
Running unittests src/lib.rs\n\
test test1 ... ok\n\
test test2 ... FAILED\n\
\n\
failures:\n\
\n\
---- test2 stdout ----\n\
assertion failed\n\
\n\
failures:\n\
test2\n\
\n\
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out\n\
";
let result = filter_cargo_test_output(output, 1);
assert!(!result.contains("Compiling"), "compiling stripped");
assert!(
result.contains("Running unittests"),
"Running preserved in test output (not cargo noise)"
);
assert!(result.contains("test2 ... FAILED"), "failure preserved");
assert!(result.contains("test result:"), "summary preserved");
}
#[test]
fn git_diff_no_changes() {
let result = process_shell_output("git diff", "", "", 0, Duration::ZERO);
assert!(result.contains("no changes"), "short-circuit on empty diff");
}
#[test]
fn docker_build_ok_short_circuit() {
let input = "Step 1/3 : FROM alpine\n ---> abc123\nStep 2/3 : RUN echo hi\n ---> Using cache\nStep 3/3 : CMD [\"sh\"]\n ---> def456\nSuccessfully built abc123\nSuccessfully tagged myimage:latest\n";
let result =
process_shell_output("docker build -t myimage .", input, "", 0, Duration::ZERO);
assert!(
result.contains("[docker"),
"docker build should short-circuit to ok message"
);
}
#[test]
fn git_log_filter() {
let input = "commit abc123\nAuthor: test\nDate: Mon Jan 1\n\n initial commit\n\ncommit def456\nAuthor: test\nDate: Tue Jan 2\n\n second commit\n\n";
let result = process_shell_output("git log --oneline", input, "", 0, Duration::ZERO);
assert!(result.contains("commit"), "git log content preserved");
assert!(result.contains("Author"), "Author field preserved");
}
#[test]
fn extract_segments_cases() {
let cases: &[(&str, &[&str])] = &[
("cargo build", &["cargo build"]),
("cd project && cargo build", &["cd project", "cargo build"]),
(
"npm run build 2>&1 | tee build.log",
&["npm run build 2>&1", "tee build.log"],
),
("echo 'foo && bar' | cat", &["echo 'foo && bar'", "cat"]),
("cargo build ; cargo test", &["cargo build", "cargo test"]),
("echo 'foo && bar'", &["echo 'foo && bar'"]),
("echo \"pipe | test\"", &["echo \"pipe | test\""]),
];
for (input, expected) in cases {
let result = extract_command_segments(input);
assert_eq!(
result.iter().map(String::as_str).collect::<Vec<_>>(),
*expected,
"input: {input:?}"
);
}
}
#[test]
fn canonical_command_cases() {
let cases: &[(&str, &str)] = &[
("/usr/local/bin/cargo build", "cargo build"),
("git -C /repo diff", "git diff"),
("git -c user.name=me log", "git log"),
("git -- diff", "git diff"), ("sudo cargo build", "cargo build"),
("sudo -E cargo build", "cargo build"), ("sudo --preserve-env cargo build", "cargo build"), ("sudo -E git -C /repo diff", "git diff"), ("time -v cargo test", "cargo test"), ("cd", ""), ("cd ..", ".."), ("pnpm install", "pnpm install"),
("yarn add foo", "yarn add"),
("cargo test --lib", "cargo test"),
("cargo --release build", "cargo build"),
("cargo --release --verbose build", "cargo build"),
("CC=gcc make", "make"), ("VAR=val cargo check", "cargo check"),
("CC=gcc CXX=g++ make -j4", "make"), ("CC=gcc", ""), ("sudo CC=gcc make", "make"), ("python -m pytest tests/", "python pytest"),
("poetry run pytest tests/", "poetry run"),
];
for &(input, expected) in cases {
assert_eq!(
canonical_command(input),
expected,
"canonical_command({input:?})",
);
}
}
#[test]
fn first_command_word_consistent_with_canonical() {
let inputs: &[&str] = &[
"/usr/local/bin/cargo build",
"git -C /repo diff",
"git -c user.name=me log",
"git -- diff",
"sudo cargo build",
"sudo -E cargo build",
"sudo --preserve-env cargo build",
"sudo -E git -C /repo diff",
"time -v cargo test",
"cd",
"cd ..",
"pnpm install",
"yarn add foo",
"cargo test --lib",
"cargo --release build",
"cargo --release --verbose build",
"CC=gcc make",
"VAR=val cargo check",
"CC=gcc CXX=g++ make -j4",
"CC=gcc",
"sudo CC=gcc make",
"",
" ",
"ls",
"cat file.txt",
"/bin/echo hello",
];
for &input in inputs {
let canonical = canonical_command(input);
let first = first_command_word(input);
if canonical.is_empty() {
assert!(
first.is_empty(),
"first_command_word({input:?}) should be empty when canonical_command is empty",
);
} else {
let expected_first = canonical.split_whitespace().next().unwrap_or("");
assert_eq!(
first, expected_first,
"first_command_word({input:?}) should match first word of canonical_command({input:?}) = {canonical:?}",
);
}
}
}
#[test]
fn select_profile_cargo_test_with_flags_triggers_state_machine() {
let result = process_shell_output("cargo --release test", "", "", 0, Duration::ZERO);
assert_eq!(result.trim(), "[cargo test: ok]");
}
#[test]
fn select_profile_chained_cargo_build_strips_compiling() {
let output =
" Compiling foo v1.0.0\n Compiling bar v2.0.0\nerror[E0425]: cannot find value\n";
let result =
process_shell_output("cd project && cargo build", output, "", 1, Duration::ZERO);
assert!(
!result.contains("Compiling"),
"chained cargo build: compiling lines stripped"
);
assert!(
result.contains("error[E0425]"),
"chained cargo build: errors preserved"
);
}
#[test]
fn select_profile_absolute_cargo_strips_compiling() {
let output = " Compiling foo v1.0.0\nwarning: unused import\n";
let result =
process_shell_output("/usr/local/bin/cargo check", output, "", 0, Duration::ZERO);
assert!(
!result.contains("Compiling"),
"absolute cargo: compiling lines stripped"
);
}
#[test]
fn select_profile_git_with_c_flag_triggers_git_diff() {
let result = process_shell_output("git -C /repo diff", "", "", 0, Duration::ZERO);
assert_eq!(result.trim(), "[git diff: no changes]");
}
#[test]
fn select_profile_fallback_for_unknown_uses_generic() {
let output = "some\nrandom\noutput\n";
let result =
process_shell_output("some_obscure_tool --flag", output, "", 0, Duration::ZERO);
assert!(result.contains("some"), "generic: output passes through");
assert!(result.contains("output"), "generic: output passes through");
}
#[test]
fn chained_command_matches_correct_profile() {
let output = "Already up to date\nsome output\n";
let result = process_shell_output(
"cd frontend && pnpm install && pnpm build",
output,
"",
0,
Duration::ZERO,
);
assert!(
!result.contains("Already up to date"),
"pnpm install profile matched and stripped noise line"
);
}
#[test]
fn empty_command_uses_fallback() {
let result = process_shell_output("", "hello world", "", 0, Duration::ZERO);
assert!(result.contains("hello"));
}
#[test]
fn only_shell_builtins_use_fallback() {
let result = process_shell_output("cd .. && cd /tmp", "some output", "", 0, Duration::ZERO);
assert!(
result.contains("some output"),
"builtins-only falls through to generic"
);
}
#[test]
fn chained_cargo_test_uses_state_machine() {
let output = "Compiling foo v1.0.0\ntest test1 ... ok\ntest test2 ... FAILED\n\nfailures:\n\n---- test2 stdout ----\npanic!\n\nfailures:\n test2\n\ntest result: FAILED. 1 passed; 1 failed\n";
let result =
process_shell_output("cd project && cargo test", output, "", 1, Duration::ZERO);
assert!(
!result.contains("Compiling"),
"cargo test: compiling stripped"
);
assert!(!result.contains("test1 ... ok"), "passing tests stripped");
assert!(
result.contains("test2 ... FAILED"),
"failures preserved in chained cargo test"
);
}
#[test]
fn chained_git_log_preserves_content() {
let input = "commit abc123\nAuthor: test\nDate: Mon Jan 1\n\n initial commit\n";
let result =
process_shell_output("cd repo && git log --oneline", input, "", 0, Duration::ZERO);
assert!(result.contains("commit"), "git log content preserved");
assert!(result.contains("Author"), "Author field preserved");
}
#[test]
fn test_all_profiles_have_valid_configs() {
let profiles = PROFILES.iter().collect::<Vec<_>>();
assert!(
!profiles.is_empty(),
"should have at least the generic fallback"
);
for p in &profiles {
assert!(
!p.match_command.as_str().is_empty(),
"match_command should not be empty"
);
if let (Some(head), Some(tail), Some(max)) = (p.head_lines, p.tail_lines, p.max_lines) {
assert!(
head + tail < max,
"head+tail ({head}+{tail}) should be strictly less than max_lines ({max}) — omission marker would overflow"
);
}
}
}
#[test]
fn profile_df_caps_at_20_lines() {
let input = (0..50)
.map(|i| format!("filesystem{i} used avail capacity mounted_on"))
.collect::<Vec<_>>()
.join("\n");
let result = process_shell_output("df -h", &input, "", 0, Duration::ZERO);
let lines = result.lines().count();
assert!(lines <= 50, "df should cap at ~21 lines, got {lines}");
assert!(lines >= 19, "df should have around 20 lines, got {lines}");
}
#[test]
fn profile_du_strips_blank_lines() {
let input = "1.0K\t./file1\n\n2.0K\t./file2\n\n\n3.0K\t./file3";
let result = process_shell_output("du -sh", input, "", 0, Duration::ZERO);
assert!(
!result.contains("\n\n"),
"should not have consecutive blank lines"
);
}
#[test]
fn profile_make_strips_directory_noise() {
let input = "make[1]: Entering directory `/tmp'\nmake[1]: Leaving directory `/tmp'\ncc -c file.c\nNothing to be done";
let result = process_shell_output("make", input, "", 0, Duration::ZERO);
assert!(
!result.contains("Entering directory"),
"make noise stripped"
);
assert!(
!result.contains("Nothing to be done"),
"'nothing to be done' stripped"
);
}
#[test]
fn profile_rsync_short_circuits_on_success() {
let input = "building file list ... done\nsent 100 bytes received 50 bytes\n\ntotal size is 98765 speedup is 658.43\n";
let result = process_shell_output("rsync -avz source/ dest/", input, "", 0, Duration::ZERO);
assert_eq!(
result.trim(),
"ok (synced)",
"rsync should short-circuit on 'total size is'"
);
}
#[test]
fn profile_cargo_build_strips_noise() {
let input =
" Compiling foo v1.0.0\n Compiling bar v2.0.0\n Finished dev [unoptimized]\n";
let result = process_shell_output("cargo build", input, "", 0, Duration::ZERO);
assert!(
!result.contains("Compiling"),
"cargo build strips Compiling lines"
);
assert!(
!result.contains("Finished"),
"cargo build strips Finished lines"
);
}
#[test]
fn profile_tsc_on_empty_returns_ok() {
let result = process_shell_output("tsc --noEmit", "", "", 0, Duration::ZERO);
assert_eq!(result.trim(), "[tsc: ok] (0.0s)");
}
#[test]
fn profile_docker_strips_build_steps() {
let input = "Step 1/10 : FROM node:18\nStep 2/10 : WORKDIR /app\n ---> Using cache\nSuccessfully built abc123\nSuccessfully tagged myapp:latest\n";
let result = process_shell_output("docker build -t myapp .", input, "", 0, Duration::ZERO);
assert!(!result.contains("Step "), "docker strips step lines");
assert!(
result.contains("[docker build: ok]"),
"docker short-circuits on success"
);
}
#[test]
fn profile_pytest_strips_collected() {
let input = "============================= test session starts ==============================\ncollected 5 items\n\n.test..\n\n============================== 5 passed ==============================\n";
let result = process_shell_output("pytest", input, "", 0, Duration::ZERO);
assert!(
!result.contains("collected"),
"pytest strips collected count"
);
}
#[test]
fn profile_pytest_python_m_falls_through_to_generic() {
let input = "============================= test session starts ==============================\ncollected 5 items\n\n.test..\n\n============================== 5 passed ==============================\n";
let result = process_shell_output("python -m pytest tests/", input, "", 0, Duration::ZERO);
assert!(
result.contains("collected"),
"python -m pytest falls through to GEN_FALLBACK (collected preserved)"
);
}
#[test]
fn profile_pytest_poetry_run_falls_through_to_generic() {
let input = "============================= test session starts ==============================\ncollected 5 items\n\n.test..\n\n============================== 5 passed ==============================\n";
let result = process_shell_output("poetry run pytest tests/", input, "", 0, Duration::ZERO);
assert!(
result.contains("collected"),
"poetry run pytest falls through to GEN_FALLBACK (collected preserved)"
);
}
#[test]
fn profile_keep_stderr_warnings_on_success() {
let stderr = "warning: unused import: `std::fs`\n --> src/main.rs:1:5\n";
let result = process_shell_output(
"cargo build",
" Compiling foo v1.0.0\n Finished\n",
stderr,
0,
Duration::ZERO,
);
assert!(
result.contains("warning:"),
"cargo build warnings shown on success"
);
}
#[test]
fn compact_ls_empty_directory() {
let input = "total 0\ndrwxr-xr-x 2 user group 64 May 21 10:00 .\ndrwxr-xr-x 3 user group 96 May 21 10:00 ..\n";
let result = process_shell_output("ls -la", input, "", 0, Duration::ZERO);
assert_eq!(
result.trim(),
"(empty)",
"empty ls output should show (empty)"
);
}
#[test]
fn compact_ls_mixed_files_and_dirs() {
let input = "total 32\ndrwxr-xr-x 5 user group 160 May 21 10:00 .\ndrwxr-xr-x 3 user group 96 May 21 10:00 ..\n-rw-r--r-- 1 user group 2048 May 21 10:00 main.rs\n-rw-r--r-- 1 user group 4096 May 21 10:00 lib.rs\ndrwxr-xr-x 2 user group 64 May 21 10:00 src\nlrwxr-xr-x 1 user group 5 May 21 10:00 link -> target\n";
let result = process_shell_output("ls -la", input, "", 0, Duration::ZERO);
assert!(result.contains("src/"), "directory should end with slash");
assert!(result.contains("main.rs"), "file name preserved");
assert!(result.contains("lib.rs"), "file name preserved");
assert!(result.contains("Summary:"), "should have summary");
assert!(
!result.contains("link -> target"),
"symlink target stripped"
);
}
#[test]
fn compact_ls_dotless_files() {
let input = "total 16\n-rw-r--r-- 1 user group 1024 May 21 10:00 Makefile\n-rw-r--r-- 1 user group 2048 May 21 10:00 README\n-rw-r--r-- 1 user group 512 May 21 10:00 .gitignore\n-rw-r--r-- 1 user group 1024 May 21 10:00 main.rs\n";
let result = process_shell_output("ls -la", input, "", 0, Duration::ZERO);
assert!(result.contains("Makefile"), "dotless file preserved");
assert!(result.contains("README"), "dotless file preserved");
assert!(
!result.contains(".Makefile"),
"dotless file should not get fake extension"
);
assert!(
!result.contains(".README"),
"dotless file should not get fake extension"
);
assert!(
result.contains("no ext"),
"summary should include 'no ext' for dotless files"
);
assert!(
result.contains(".rs"),
"main.rs should be classified as .rs"
);
}
#[test]
fn compact_ls_plain_ls_passes_through() {
let input = "Cargo.toml\nCargo.lock\nsrc\ntarget\nREADME.md\n";
let result = process_shell_output("ls", input, "", 0, Duration::ZERO);
assert!(
result.contains("Cargo.toml"),
"plain ls output should show filenames unchanged"
);
assert!(
result.contains("src"),
"plain ls output should show filenames unchanged"
);
assert!(
!result.contains("(empty)"),
"plain ls output should NOT show (empty)"
);
assert!(
!result.contains("Summary:"),
"plain ls output should NOT be compacted — no Summary header"
);
}
#[test]
fn chained_ls_skips_compact_ls() {
let input = "total 8\n-rw-r--r-- 1 user group 1024 May 21 10:00 foo\n-rw-r--r-- 1 user group 2048 May 21 10:00 bar\ndone\n";
let result = process_shell_output("ls -l && echo done", input, "", 0, Duration::ZERO);
assert!(
result.contains("done"),
"chained ls: later segments' output preserved"
);
assert!(
!result.contains("Summary:"),
"chained ls: compact_ls should not be applied"
);
}
#[test]
fn save_raw_output_if_large_skips_small_output() {
let result = save_raw_output_if_large(b"hello", b"", "echo hello");
assert!(result.is_none(), "should skip saving for small output");
}
#[test]
fn chained_ls_with_pipe_skips_compact_ls() {
let input = "total 8\n-rw-r--r-- 1 user group 1024 May 21 10:00 foo\n-rw-r--r-- 1 user group 2048 May 21 10:00 bar\n";
let result = process_shell_output("ls -l | head -5", input, "", 0, Duration::ZERO);
assert!(
!result.contains("Summary:"),
"piped ls: compact_ls should not be applied"
);
assert!(
result.contains("total 8"),
"piped ls: raw -l format should be preserved"
);
}
#[test]
fn save_raw_output_if_large_saves_large_output() {
let large = vec![b'a'; MAX_OUTPUT_BYTES + 1];
let result = save_raw_output_if_large(&large, b"", "large-test");
assert!(result.is_some(), "should save for oversized output");
let hint = result.unwrap();
assert!(
hint.contains("[Output saved to"),
"should mention saved file"
);
assert!(
hint.contains("[view with: read"),
"should provide read hint"
);
}
#[test]
fn profile_on_empty_shows_timing() {
let result = process_shell_output("tsc --noEmit", "", "", 0, Duration::from_secs_f64(3.2));
assert!(
result.contains("(3.2s)"),
"timing should appear in on_empty message"
);
}
#[test]
fn profile_gh_strips_noise() {
let input = " \n - some detail\nwarning: consider updating gh\n✓ Created pull request\n";
let result = process_shell_output("gh pr create --fill", input, "", 0, Duration::ZERO);
assert!(!result.contains("warning:"), "gh: warning stripped");
assert!(result.contains("[gh: ok]"), "gh: short-circuit on success");
}
#[test]
fn profile_terraform_short_circuits() {
let input = "data.aws_region.current: Refreshing state...\nNo changes. Your infrastructure matches the configuration.\n";
let result = process_shell_output("terraform plan", input, "", 0, Duration::ZERO);
assert!(
result.contains("[terraform: no changes]"),
"terraform: short-circuits on 'No changes'"
);
}
type QuoteStep = (char, bool, bool, bool);
#[test]
fn check_outside_quotes_cases() {
let cases: &[(&str, &[QuoteStep])] = &[
("normal char outside", &[('a', true, false, false)]),
(
"single quote blocks",
&[
('\'', false, true, false),
('>', false, true, false),
('\'', false, false, false),
('>', true, false, false),
],
),
(
"double quote blocks",
&[
('"', false, false, true),
('>', false, false, true),
('"', false, false, false),
('>', true, false, false),
],
),
(
"single inside double",
&[('"', false, false, true), ('\'', false, false, true)],
),
(
"double inside single",
&[('\'', false, true, false), ('"', false, true, false)],
),
];
for (name, steps) in cases {
let (mut s, mut d) = (false, false);
for (i, &(ch, exp_out, exp_s, exp_d)) in steps.iter().enumerate() {
let result = check_outside_quotes(ch, &mut s, &mut d);
assert_eq!(
result, exp_out,
"{name} step {i}: check_outside_quotes({ch:?}) returned {result}, expected {exp_out}",
);
assert_eq!(
s, exp_s,
"{name} step {i}: after {ch:?}, in_single={s}, expected {exp_s}",
);
assert_eq!(
d, exp_d,
"{name} step {i}: after {ch:?}, in_double={d}, expected {exp_d}",
);
}
}
}
#[test]
fn truncate_no_config_passthrough() {
let p = Profile::new("passthrough");
let output = "line1\nline2\nline3";
let (result, pre) = apply_line_truncation(output, &p);
assert_eq!(result, output);
assert_eq!(pre, None);
}
#[test]
fn truncate_head_tail_only_small_output_no_sandwich() {
let p = Profile::new("test").head(2).tail(2);
let output = "line1\nline2\nline3\nline4\nline5";
let (result, pre) = apply_line_truncation(output, &p);
assert_eq!(result, output, "small output passes through");
assert_eq!(pre, None, "no pre-truncation for small output");
}
#[test]
fn truncate_head_tail_triggers_sandwich_large_output() {
let p = Profile::new("test").head(2).tail(2);
let lines: Vec<String> = (0..100)
.map(|i| {
format!(
"line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
)
})
.collect();
let output = lines.join("\n");
assert!(
output.len() > SPILL_THRESHOLD_BYTES,
"test output must exceed threshold (got {} bytes)",
output.len()
);
let (result, pre) = apply_line_truncation(&output, &p);
assert!(pre.is_some(), "should capture pre-truncation output");
assert!(
result.contains("... (96 lines omitted)"),
"should have omission marker"
);
assert!(
result.starts_with("line 0 aaaaaaaa"),
"should start with head"
);
assert!(
result.ends_with("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
"should end with tail"
);
}
#[test]
fn truncate_max_only_caps_at_limit() {
let p = Profile::new("test").max(3);
let output = "a\nb\nc\nd\ne";
let (result, pre) = apply_line_truncation(output, &p);
assert_eq!(pre, None, "no pre-truncation for max-only");
assert_eq!(
result.lines().count(),
4, "should have max+1 lines (3 data + marker)"
);
assert!(result.contains("... (2 lines truncated)"));
}
#[test]
fn truncate_max_only_fits_no_truncation() {
let p = Profile::new("test").max(10);
let output = "a\nb\nc";
let (result, pre) = apply_line_truncation(output, &p);
assert_eq!(result, output, "fits within max, passthrough");
assert_eq!(pre, None);
}
#[test]
fn truncate_head_tail_plus_max_byte_threshold_exceeded() {
let p = Profile::new("test").head(2).tail(2).max(100);
let lines: Vec<String> = (0..100)
.map(|i| {
format!(
"line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
)
})
.collect();
let output = lines.join("\n");
assert!(
output.len() > SPILL_THRESHOLD_BYTES,
"test output must exceed threshold (got {} bytes)",
output.len()
);
let (result, pre) = apply_line_truncation(&output, &p);
assert!(pre.is_some(), "should capture pre-truncation");
assert!(
result.contains("... (96 lines omitted)"),
"should have sandwich omission marker"
);
assert!(
!result.contains("lines truncated"),
"defensive cap should not fire when head+tail+1 <= max"
);
}
#[test]
fn truncate_head_tail_plus_max_byte_threshold_not_exceeded() {
let p = Profile::new("test").head(2).tail(2).max(3);
let output = "a\nb\nc\nd\ne";
let (result, pre) = apply_line_truncation(output, &p);
assert_eq!(pre, None, "no pre-truncation for small output");
assert_eq!(
result.lines().count(),
4, "max cap should apply"
);
assert!(result.contains("... (2 lines truncated)"));
}
#[test]
fn truncate_head_tail_fits_when_under_limit() {
let p = Profile::new("test").head(5).tail(3);
let output = "a\nb\nc\nd";
let (result, pre) = apply_line_truncation(output, &p);
assert_eq!(result, output, "not enough lines to truncate");
assert_eq!(pre, None);
}
}