use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use std::time::Duration;
use lingshu_types::{ToolError, ToolSchema};
use crate::describe_execution_filesystem;
use crate::registry::{ToolContext, ToolHandler};
use crate::tools::backend_pool::{get_or_create_backend, resolve_workdir};
#[cfg(test)]
use crate::tools::backends::ExecutionBackend;
use crate::tools::backends::redact_output;
use crate::tools::checkpoint::ensure_checkpoint;
fn validate_backend_workdir_visibility(
backend: &crate::tools::backends::BackendKind,
cwd: &str,
) -> Result<(), ToolError> {
let cwd_path = std::path::Path::new(cwd);
if !cwd_path.is_absolute() {
return Ok(());
}
match backend {
crate::tools::backends::BackendKind::Modal
| crate::tools::backends::BackendKind::Daytona
if cwd_path.exists() =>
{
let cfg = crate::config_ref::AppConfigRef {
terminal_backend: backend.clone(),
..Default::default()
};
let fs = describe_execution_filesystem(&cfg, cwd_path);
let allowed_roots = fs.file_roots_display();
return Err(ToolError::ExecutionFailed {
tool: "terminal".into(),
message: format!(
"The {} backend cannot access host workspace path '{}'. File tools in this session are rooted at {}. Use the local or docker backend for local files, or sync the workspace into the remote sandbox first.",
backend, cwd, allowed_roots
),
});
}
_ => {}
}
Ok(())
}
fn terminal_result_header(
backend: &crate::tools::backends::BackendKind,
cwd: &str,
exit_code: i32,
) -> String {
let status = if exit_code == 0 { "success" } else { "error" };
format!(
"[terminal_result status={status} backend={} cwd={} exit_code={exit_code}]",
backend, cwd
)
}
pub async fn cleanup_all_backends() -> usize {
crate::tools::backend_pool::cleanup_all_backends().await
}
pub async fn cleanup_backend_for_task(task_id: &str) -> bool {
crate::tools::backend_pool::cleanup_backend_for_task(task_id).await
}
pub async fn cleanup_inactive_backends(max_idle: Duration) -> usize {
crate::tools::backend_pool::cleanup_inactive_backends(max_idle).await
}
pub struct TerminalTool;
#[derive(Deserialize)]
struct Args {
command: String,
#[serde(default)]
background: bool,
#[serde(default = "default_timeout")]
timeout_seconds: u64,
#[serde(default)]
timeout: Option<u64>,
#[serde(default)]
pty: bool,
workdir: Option<String>,
}
fn default_timeout() -> u64 {
120
}
fn effective_timeout_seconds(args: &Args) -> u64 {
args.timeout.unwrap_or(args.timeout_seconds)
}
#[async_trait]
impl ToolHandler for TerminalTool {
fn name(&self) -> &'static str {
"terminal"
}
fn toolset(&self) -> &'static str {
"terminal"
}
fn emoji(&self) -> &'static str {
"💻"
}
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "terminal".into(),
description: "Execute a shell command and return combined stdout+stderr output. \
Commands run in a persistent bash shell, so state like environment \
variables, `cd`, and shell functions persist across consecutive calls. \
Do not use cat/head/tail to read files — use `read_file` instead. \
Do not use grep/rg/find/ls for repo discovery — use `search_files` instead. \
Do not use sed/awk for file edits — use `patch`/`apply_patch` instead. \
Use `workdir` to override the working directory for a single call. \
Supports legacy background=true calls, which delegate to the shared background process path. \
For long-running processes (servers, watchers) use background=true or `run_process`. \
Do not use shell heredocs (`<<EOF`) in terminal. For file creation or edits, \
use `write_file`, `patch`, or `apply_patch` instead of embedding file content \
inside the command string. \
PTY mode is available on the local backend for line-oriented interactive CLIs that need TTY semantics or prompt-style output. \
PTY calls are per-command and do not reuse the persistent shell. Full-screen terminal UIs remain unsupported because the agent does not observe screen state. \
The exit code is appended to the output as `exit code: N`; non-zero \
means the command failed."
.into(),
parameters: json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Shell command to execute. Multi-line scripts are supported."
},
"background": {
"type": "boolean",
"description": "Background mode for servers/watchers. Returns immediately with a process id."
},
"timeout_seconds": {
"type": "integer",
"description": "Maximum seconds to wait for the command to finish (default: 120, max: 600)."
},
"timeout": {
"type": "integer",
"description": "Alias for `timeout_seconds`. Takes precedence over `timeout_seconds` when both are set (default: 120, max: 600)."
},
"pty": {
"type": "boolean",
"description": "Allocate a PTY for local interactive CLI commands that need TTY semantics or prompt-style output. Local backend only. Full-screen TUIs remain unsupported."
},
"workdir": {
"type": "string",
"description": "Override working directory for this command only. \
Absolute paths are used as-is; relative paths are \
resolved from the task working directory."
}
},
"required": ["command"]
}),
strict: None,
}
}
async fn execute(
&self,
args: serde_json::Value,
ctx: &ToolContext,
) -> Result<String, ToolError> {
let args: Args = serde_json::from_value(args).map_err(|e| ToolError::InvalidArgs {
tool: "terminal".into(),
message: e.to_string(),
})?;
let antipattern_warning = detect_file_io_antipattern(&args.command);
if args.background {
let started = crate::tools::process::start_background_process(
"terminal",
&args.command,
args.workdir.as_deref(),
args.pty,
Vec::new(), ctx,
)
.await?;
return Ok(
if args.timeout.is_some() || args.timeout_seconds != default_timeout() {
if let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&started) {
if let Some(obj) = v.as_object_mut() {
obj.insert(
"note".to_string(),
serde_json::Value::String(
"background mode ignores timeout; use wait_for_process to block for completion".to_string(),
),
);
}
serde_json::to_string(&v).unwrap_or(started)
} else {
started
}
} else {
started
},
);
}
if is_destructive_command(&args.command) {
ensure_checkpoint(ctx, &destructive_checkpoint_label(&args.command));
}
if let Some(reasons) = crate::approval_runtime::command_approval_reasons(ctx, &args.command)
{
crate::approval_runtime::request_command_approval(ctx, &args.command, reasons).await?;
}
crate::command_interaction::guard_terminal_command(
&args.command,
&ctx.config.terminal_backend,
args.pty,
)?;
let timeout = Duration::from_secs(effective_timeout_seconds(&args).min(600));
if args.pty && ctx.config.terminal_backend != crate::tools::backends::BackendKind::Local {
return Err(
ToolError::capability_denied(
"terminal",
"pty_backend_unsupported",
format!(
"PTY mode is only available on the local terminal backend. The active backend is {}.",
ctx.config.terminal_backend
),
)
.with_suggested_action(
"Switch to the local backend for PTY commands, or rerun the command without PTY."
.to_string(),
),
);
}
let cwd = resolve_workdir(ctx, args.workdir.as_deref());
validate_backend_workdir_visibility(&ctx.config.terminal_backend, &cwd)?;
let (effective_command, sudo_prefix) =
crate::tools::backends::transform_sudo(&args.command);
let final_command = if let Some(prefix) = sudo_prefix {
let escaped = prefix.trim_end_matches('\n').replace('\'', "'\\''");
format!(
"{{ printf '%s\\n' '{}'; }} | {}",
escaped, effective_command
)
} else {
effective_command
};
const MAX_RETRIES: u32 = 3;
let mut attempt = 0u32;
let (progress_reporter, exec_options) =
crate::tool_progress_tail::ToolProgressTail::reporter_and_options(ctx);
let exec_output = if args.pty {
crate::local_pty::execute_foreground(
"terminal",
&final_command,
&cwd,
timeout,
ctx.cancel.clone(),
progress_reporter.clone(),
)
.await?
} else {
let backend = get_or_create_backend(ctx).await?;
loop {
let result = backend
.execute(
&final_command,
&cwd,
timeout,
ctx.cancel.clone(),
exec_options.clone(),
)
.await;
if let Err(ref e) = result
&& attempt < MAX_RETRIES
&& is_backend_retryable(e)
{
let wait = Duration::from_secs(1u64 << (attempt + 1)); tracing::warn!(
task_id = %ctx.task_id,
attempt = attempt + 1,
wait_secs = wait.as_secs(),
error = %e,
"terminal backend error; retrying",
);
attempt += 1;
tokio::time::sleep(wait).await;
continue;
}
match result {
Ok(out) => break out,
Err(e) => return Err(e),
}
}
};
if let Some(reporter) = progress_reporter.as_ref() {
reporter.flush();
}
crate::command_interaction::rewrite_terminal_exec_result(
&args.command,
&ctx.config.terminal_backend,
timeout,
&exec_output,
)?;
let max_stdout = ctx.config.max_terminal_output;
let max_stderr = ctx.config.max_terminal_output / 4;
let was_truncated =
exec_output.stdout.len() > max_stdout || exec_output.stderr.len() > max_stderr;
let total_output_chars = exec_output.stdout.len() + exec_output.stderr.len();
let mut result = exec_output.format(max_stdout, max_stderr);
result = strip_ansi_escapes::strip_str(&result);
result = redact_output(&result);
let header =
terminal_result_header(&ctx.config.terminal_backend, &cwd, exec_output.exit_code);
let header = if was_truncated {
format!(
"{} truncated=true output_chars={}]",
&header[..header.len() - 1],
total_output_chars
)
} else {
header
};
result = if result.is_empty() {
header
} else {
format!("{header}\n{result}")
};
if let Some(warning) = antipattern_warning {
result = format!("[NOTE: {warning}]\n\n{result}");
}
Ok(result)
}
}
fn is_destructive_command(cmd: &str) -> bool {
const DESTRUCTIVE_PREFIXES: &[&str] = &[
"rm ",
"rm\t",
"rmdir ",
"mv ",
"mv\t",
"shred ",
"truncate ",
"git reset",
"git clean",
"git checkout",
"git restore",
"git rebase",
"git merge",
"git cherry-pick",
"git revert",
"sed -i",
"sed -i'",
"awk -i",
];
let has_overwrite_redirect = cmd.contains(" > ")
|| cmd.starts_with('>')
|| cmd.contains("\t> ")
|| (cmd.contains('>') && !cmd.contains(">>"));
let cmd_lower = cmd.to_lowercase();
DESTRUCTIVE_PREFIXES.iter().any(|p| {
cmd_lower.starts_with(p)
|| cmd_lower.contains(&format!("; {p}"))
|| cmd_lower.contains(&format!("&& {p}"))
}) || has_overwrite_redirect
}
fn destructive_checkpoint_label(command: &str) -> String {
let normalized = command.split_whitespace().collect::<Vec<_>>().join(" ");
format!("before terminal: {}", crate::safe_truncate(&normalized, 80))
}
fn is_backend_retryable(err: &ToolError) -> bool {
matches!(err, ToolError::Unavailable { .. } | ToolError::Other(_))
}
use regex::Regex;
use std::sync::LazyLock;
static FILE_IO_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
vec![
(
Regex::new(r"^cat\s+").expect("valid cat anti-pattern regex"),
"Use `read_file` instead of `cat` — it handles encoding and large files safely.",
),
(
Regex::new(r"^head\s+").expect("valid head anti-pattern regex"),
"Use `read_file` with line range instead of `head`.",
),
(
Regex::new(r"^tail\s+").expect("valid tail anti-pattern regex"),
"Use `read_file` with line range instead of `tail`.",
),
(
Regex::new(r"python3?\s+-c\s+.*open\(").expect("valid python open anti-pattern regex"),
"Use `read_file` instead of python file I/O — it's faster and path-safe.",
),
(
Regex::new(r"^less\s+|^more\s+").expect("valid pager anti-pattern regex"),
"Use `read_file` instead of `less`/`more`.",
),
(
Regex::new(r#"^echo\s+.*>\s*\S+"#).expect("valid echo redirect anti-pattern regex"),
"Use `write_file` instead of `echo >` — it creates parent dirs and validates paths.",
),
(
Regex::new(r"^sed\s+-i").expect("valid sed anti-pattern regex"),
"Use `patch` instead of `sed -i` — it has fuzzy matching and creates backups.",
),
]
});
fn detect_file_io_antipattern(command: &str) -> Option<&'static str> {
let trimmed = command.trim();
for (pattern, suggestion) in FILE_IO_PATTERNS.iter() {
if pattern.is_match(trimmed) {
return Some(suggestion);
}
}
if let Some(first_cmd) = trimmed.split('|').next() {
let first_trimmed = first_cmd.trim();
if first_trimmed != trimmed {
for (pattern, suggestion) in FILE_IO_PATTERNS.iter() {
if pattern.is_match(first_trimmed) {
return Some(suggestion);
}
}
}
}
None
}
inventory::submit!(&TerminalTool as &dyn ToolHandler);
#[cfg(test)]
mod tests {
use super::*;
use crate::process_table::ProcessTable;
use crate::registry::{ApprovalRequest, ApprovalResponse};
use crate::tools::backend_pool::{
BackendCacheEntry, backend_cache, now_epoch_secs, prepare_backend_config,
};
use lingshu_types::ToolError;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tempfile::TempDir;
use tokio_util::sync::CancellationToken;
static TERMINAL_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct FakeBackend {
kind: crate::tools::backends::BackendKind,
cleanup_calls: Arc<AtomicUsize>,
}
#[async_trait]
impl ExecutionBackend for FakeBackend {
async fn execute(
&self,
_command: &str,
_cwd: &str,
_timeout: Duration,
_cancel: CancellationToken,
_options: crate::tool_progress_tail::ExecuteOptions,
) -> Result<crate::tools::backends::ExecOutput, ToolError> {
Ok(crate::tools::backends::ExecOutput {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
})
}
async fn cleanup(&self) -> Result<(), ToolError> {
self.cleanup_calls.fetch_add(1, Ordering::Relaxed);
Ok(())
}
fn kind(&self) -> crate::tools::backends::BackendKind {
self.kind.clone()
}
}
fn ctx_in(dir: &std::path::Path) -> ToolContext {
let mut ctx = ToolContext::test_context();
ctx.task_id = format!("terminal-test-{}", uuid::Uuid::new_v4().simple());
ctx.cwd = dir.to_path_buf();
ctx
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn terminal_echo() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = TempDir::new().expect("tmpdir");
let ctx = ctx_in(dir.path());
let result = TerminalTool
.execute(json!({"command": "echo hello world"}), &ctx)
.await
.expect("terminal");
assert!(result.contains("hello world"));
let _ = cleanup_backend_for_task(&ctx.task_id).await;
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn terminal_exit_code() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = TempDir::new().expect("tmpdir");
let ctx = ctx_in(dir.path());
let result = TerminalTool
.execute(json!({"command": "exit 42"}), &ctx)
.await
.expect("terminal");
assert!(result.contains("exit code: 42"));
let _ = cleanup_backend_for_task(&ctx.task_id).await;
}
#[test]
fn destructive_checkpoint_label_preserves_utf8_boundaries() {
let label = destructive_checkpoint_label("rm -rf 你好你好你好你好你好你好你好你好你好");
assert!(label.starts_with("before terminal: "));
assert!(std::str::from_utf8(label.as_bytes()).is_ok());
assert!(!label.ends_with('\u{FFFD}'));
}
#[test]
fn destructive_checkpoint_label_normalizes_whitespace() {
let label = destructive_checkpoint_label("rm -rf\t./tmp\nand-more");
assert_eq!(label, "before terminal: rm -rf ./tmp and-more");
}
#[cfg_attr(
windows,
ignore = "PTY and POSIX shell syntax not available on Windows"
)]
#[tokio::test]
async fn terminal_pty_reports_tty_to_child() {
let dir = TempDir::new().expect("tmpdir");
let ctx = ctx_in(dir.path());
let result = TerminalTool
.execute(
json!({"command": "[ -t 0 ] && [ -t 1 ] && printf tty-ok || printf no-tty", "pty": true}),
&ctx,
)
.await;
let output = result.expect("pty terminal");
assert!(output.contains("tty-ok"), "got: {output}");
}
#[tokio::test]
async fn terminal_background_delegates_to_shared_process_path() {
let dir = TempDir::new().expect("tmpdir");
let mut ctx = ctx_in(dir.path());
ctx.process_table = Some(Arc::new(ProcessTable::new()));
let result = TerminalTool
.execute(json!({"command": "echo hello", "background": true}), &ctx)
.await
.expect("background process");
assert!(result.contains("proc-"), "got: {result}");
let v: serde_json::Value =
serde_json::from_str(&result).expect("background result must be JSON");
assert_eq!(v["ok"], serde_json::Value::Bool(true));
assert!(
v["process_id"].as_str().unwrap_or("").starts_with("proc-"),
"got: {result}"
);
}
#[tokio::test]
async fn terminal_pty_rejects_remote_backend() {
let dir = TempDir::new().expect("tmpdir");
let mut ctx = ctx_in(dir.path());
ctx.config.terminal_backend = crate::tools::backends::BackendKind::Modal;
let result = TerminalTool
.execute(json!({"command": "printf ok", "pty": true}), &ctx)
.await;
let err = result.expect_err("remote PTY should fail");
let ToolError::CapabilityDenied { code, message, .. } = err else {
panic!("expected capability denied");
};
assert_eq!(code, "pty_backend_unsupported");
assert!(message.contains("local terminal backend"));
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn terminal_cwd_respected() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = TempDir::new().expect("tmpdir");
std::fs::write(dir.path().join("marker.txt"), "found").expect("write");
let ctx = ctx_in(dir.path());
let result = TerminalTool
.execute(json!({"command": "cat marker.txt"}), &ctx)
.await
.expect("terminal");
assert!(result.contains("found"));
let _ = cleanup_backend_for_task(&ctx.task_id).await;
}
#[test]
fn prepare_backend_config_mounts_workspace_for_docker() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = TempDir::new().expect("tmpdir");
let mut ctx = ctx_in(dir.path());
ctx.config.terminal_backend = crate::tools::backends::BackendKind::Docker;
let cfg = prepare_backend_config(&ctx);
let mount = cfg
.docker
.workspace_mount
.as_ref()
.expect("docker workspace mount");
assert_eq!(mount.container_path, "/workspace");
assert_eq!(
std::path::Path::new(&mount.host_path),
dir.path(),
"task cwd should be the mounted host workspace"
);
}
#[test]
fn modal_backend_rejects_host_workspace_paths() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = TempDir::new().expect("tmpdir");
let err = validate_backend_workdir_visibility(
&crate::tools::backends::BackendKind::Modal,
&dir.path().to_string_lossy(),
)
.expect_err("modal should reject host cwd");
assert!(
err.to_string()
.contains("cannot access host workspace path")
);
assert!(
err.to_string()
.contains("File tools in this session are rooted at")
);
}
#[test]
fn terminal_result_header_is_machine_readable() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let header = terminal_result_header(
&crate::tools::backends::BackendKind::Docker,
"/workspace/demo",
0,
);
assert_eq!(
header,
"[terminal_result status=success backend=docker cwd=/workspace/demo exit_code=0]"
);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn terminal_requests_approval_for_dangerous_command() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = TempDir::new().expect("tmpdir");
let mut ctx = ctx_in(dir.path());
let (approval_tx, mut approval_rx) =
tokio::sync::mpsc::unbounded_channel::<ApprovalRequest>();
ctx.approval_tx = Some(approval_tx);
let approver = tokio::spawn(async move {
let request = approval_rx.recv().await.expect("approval request");
assert!(request.full_command.contains("rm -rf"));
let _ = request.response_tx.send(ApprovalResponse::Once);
});
let result = TerminalTool
.execute(json!({"command": "rm -rf /tmp/lingshu-danger-test"}), &ctx)
.await
.expect("terminal");
approver.await.expect("approver task");
assert!(result.contains("exit code: 0"));
let _ = cleanup_backend_for_task(&ctx.task_id).await;
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn terminal_rejects_tty_ui_commands() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = TempDir::new().expect("tmpdir");
let ctx = ctx_in(dir.path());
let err = TerminalTool
.execute(json!({"command": "vim Cargo.toml"}), &ctx)
.await
.expect_err("tty ui should be rejected");
let ToolError::CapabilityDenied { message, code, .. } = err else {
panic!("expected capability denied");
};
assert_eq!(code, "non_interactive_terminal_required");
assert!(message.contains("interactive terminal UI"));
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn cleanup_inactive_backends_removes_idle_entries() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _ = cleanup_all_backends().await;
let cleanup_calls = Arc::new(AtomicUsize::new(0));
let backend: Arc<dyn ExecutionBackend> = Arc::new(FakeBackend {
kind: crate::tools::backends::BackendKind::Local,
cleanup_calls: cleanup_calls.clone(),
});
let entry = Arc::new(BackendCacheEntry::new(backend));
entry
.last_used_epoch_secs
.store(now_epoch_secs().saturating_sub(600), Ordering::Relaxed);
backend_cache().insert("idle-test".into(), entry);
let cleaned = cleanup_inactive_backends(Duration::from_secs(300)).await;
assert_eq!(cleaned, 1);
assert_eq!(cleanup_calls.load(Ordering::Relaxed), 1);
assert!(!backend_cache().contains_key("idle-test"));
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn cleanup_inactive_backends_preserves_in_use_entries() {
let _guard = TERMINAL_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _ = cleanup_all_backends().await;
let cleanup_calls = Arc::new(AtomicUsize::new(0));
let backend: Arc<dyn ExecutionBackend> = Arc::new(FakeBackend {
kind: crate::tools::backends::BackendKind::Local,
cleanup_calls: cleanup_calls.clone(),
});
let held_clone = backend.clone();
let entry = Arc::new(BackendCacheEntry::new(backend));
entry
.last_used_epoch_secs
.store(now_epoch_secs().saturating_sub(600), Ordering::Relaxed);
backend_cache().insert("busy-test".into(), entry);
let cleaned = cleanup_inactive_backends(Duration::from_secs(300)).await;
assert_eq!(cleaned, 0);
assert_eq!(cleanup_calls.load(Ordering::Relaxed), 0);
assert!(backend_cache().contains_key("busy-test"));
drop(held_clone);
let _ = cleanup_all_backends().await;
}
#[test]
fn detect_cat_antipattern() {
assert!(detect_file_io_antipattern("cat main.rs").is_some());
assert!(detect_file_io_antipattern(" cat README.md").is_some());
}
#[test]
fn detect_head_tail_antipattern() {
assert!(detect_file_io_antipattern("head -n 20 file.txt").is_some());
assert!(detect_file_io_antipattern("tail -f /var/log/syslog").is_some());
}
#[test]
fn detect_echo_redirect_antipattern() {
assert!(detect_file_io_antipattern("echo hello > output.txt").is_some());
}
#[test]
fn detect_sed_inplace_antipattern() {
assert!(detect_file_io_antipattern("sed -i 's/foo/bar/' file.rs").is_some());
}
#[test]
fn detect_python_file_read_antipattern() {
assert!(detect_file_io_antipattern("python3 -c 'print(open(\"f\").read())'").is_some());
}
#[test]
fn detect_piped_antipattern() {
assert!(detect_file_io_antipattern("cat file.txt | grep foo").is_some());
}
#[test]
fn no_false_positive_for_legitimate_commands() {
assert!(detect_file_io_antipattern("cargo test --workspace").is_none());
assert!(detect_file_io_antipattern("ls -la").is_none());
assert!(detect_file_io_antipattern("grep -rn pattern src/").is_none());
assert!(detect_file_io_antipattern("git status").is_none());
}
}