use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use std::path::{Path, PathBuf};
use crate::backend::BackendError;
use crate::interpreter::ExecResult;
use crate::tools::{is_trash_excluded, schema_from_clap, ExecContext, ToolCtx, GlobalFlags, Tool, ToolArgs, ToolSchema};
#[derive(Parser, Debug)]
#[command(name = "rm", about = "Remove files and directories")]
struct RmArgs {
#[arg(short = 'r', long = "recursive")]
recursive: bool,
#[arg(short = 'R')]
recursive_upper: bool,
#[arg(short = 'f', long = "force")]
force: bool,
#[arg(long = "confirm")]
confirm: Option<String>,
#[command(flatten)]
global: GlobalFlags,
paths: Vec<String>,
}
pub struct Rm;
#[derive(Debug, PartialEq)]
enum RmAction {
Trash(PathBuf),
Delete,
Latch,
}
fn decide_rm_action(
trash_enabled: bool,
latch_enabled: bool,
real_path: Option<&Path>,
file_size: Option<u64>,
trash_max_size: u64,
is_dir: bool,
is_symlink: bool,
) -> RmAction {
if is_symlink {
if latch_enabled {
return RmAction::Latch;
}
return RmAction::Delete;
}
if trash_enabled {
if let Some(rp) = real_path {
if !is_trash_excluded(Some(rp)) {
if is_dir {
return RmAction::Trash(rp.to_path_buf());
}
let size = file_size.unwrap_or(0);
if size <= trash_max_size {
return RmAction::Trash(rp.to_path_buf());
}
if latch_enabled {
return RmAction::Latch;
}
return RmAction::Delete;
}
}
}
if latch_enabled {
return RmAction::Latch;
}
RmAction::Delete
}
#[async_trait]
impl Tool for Rm {
fn name(&self) -> &str {
"rm"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&RmArgs::command(),
"rm",
"Remove files and directories",
[
("Remove a file", "rm temp.txt"),
("Remove directory recursively", "rm -rf build/"),
("Confirm latched removal", "rm --confirm=a3f7b2c1 bigfile.bin"),
],
)
}
async fn execute(&self, mut args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};
args.flagify_bool_named(&self.schema());
let parsed = match RmArgs::try_parse_from(
std::iter::once("rm".to_string()).chain(args.to_argv()),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("rm: {e}")),
};
parsed.global.apply(ctx);
if args.positional.is_empty() {
return ExecResult::failure(1, "rm: missing path argument");
}
let recursive = parsed.recursive || parsed.recursive_upper;
let force = parsed.force;
let confirm = parsed.confirm.clone();
let trash_enabled = ctx.scope.trash_enabled();
let latch_enabled = ctx.scope.latch_enabled();
let trash_max_size = ctx.scope.trash_max_size();
struct Decision {
path: String,
resolved: PathBuf,
action: RmAction,
}
let mut decisions: Vec<Decision> = Vec::with_capacity(args.positional.len());
for value in &args.positional {
let path = match crate::interpreter::value_to_text_sink_named(value, "a path") {
Ok(p) => p,
Err(e) => return ExecResult::failure(1, format!("rm: {e}")),
};
let resolved = ctx.resolve_path(&path);
let entry = match ctx.backend.lstat(Path::new(&resolved)).await {
Ok(info) => Some(info),
Err(BackendError::NotFound(_)) if force => continue, Err(BackendError::NotFound(_)) => {
return ExecResult::failure(1, format!("rm: {}: No such file or directory", path));
}
Err(e) => return ExecResult::failure(1, format!("rm: {}: {}", path, e)),
};
let real_path = ctx.backend.resolve_real_path(Path::new(&resolved));
let file_size = entry.as_ref().map(|s| s.size);
let is_dir = entry.as_ref().is_some_and(|s| s.is_dir());
let is_symlink = entry.as_ref().is_some_and(|s| s.is_symlink());
let action = decide_rm_action(
trash_enabled,
latch_enabled,
real_path.as_deref(),
file_size,
trash_max_size,
is_dir,
is_symlink,
);
decisions.push(Decision { path, resolved, action });
}
if decisions.is_empty() {
return ExecResult::success("");
}
let latched_paths: Vec<&str> = decisions
.iter()
.filter(|d| matches!(d.action, RmAction::Latch))
.map(|d| d.path.as_str())
.collect();
if !latched_paths.is_empty() {
if let Some(nonce) = &confirm {
if let Err(e) = ctx.verify_nonce(nonce, "rm", &latched_paths) {
return ExecResult::failure(1, format!("rm: {}", e));
}
} else {
let joined = latched_paths.join(" ");
return ctx.latch_result("rm", &latched_paths, "latch enabled", |nonce| {
format!("rm --confirm=\"{}\" {}", nonce, joined)
});
}
}
let mut last_err: Option<String> = None;
for d in &decisions {
let result = match &d.action {
RmAction::Trash(real) => {
let trash_backend = match ctx.trash_backend.as_ref() {
Some(tb) => tb,
None => {
last_err = Some("rm: trash backend not available".to_string());
continue;
}
};
trash_backend.trash(real).await.map_err(|e| {
format!(
"rm: {}: trash failed: {} (use `set +o trash` to delete permanently)",
real.display(), e
)
})
}
RmAction::Latch | RmAction::Delete => {
match ctx.backend.remove(Path::new(&d.resolved), recursive).await {
Ok(()) => Ok(()),
Err(BackendError::NotFound(_)) if force => Ok(()),
Err(e) => Err(format!("rm: {}: {}", d.path, e)),
}
}
};
if let Err(msg) = result {
last_err = Some(msg);
}
}
match last_err {
Some(msg) => ExecResult::failure(1, msg),
None => ExecResult::success(""),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Value;
use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
use std::sync::Arc;
async fn make_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.write(Path::new("file.txt"), b"data").await.unwrap();
mem.mkdir(Path::new("emptydir")).await.unwrap();
mem.write(Path::new("fulldir/file.txt"), b"data").await.unwrap();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_rm_file() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/file.txt".into()));
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/file.txt")).await);
}
#[tokio::test]
async fn test_rm_empty_dir() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/emptydir".into()));
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/emptydir")).await);
}
#[tokio::test]
async fn test_rm_non_empty_dir_fails() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/fulldir".into()));
let result = Rm.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(ctx.backend.exists(Path::new("/fulldir")).await);
}
#[tokio::test]
async fn test_rm_nonexistent() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/nonexistent".into()));
let result = Rm.execute(args, &mut ctx).await;
assert!(!result.ok());
}
#[tokio::test]
async fn test_rm_no_arg() {
let mut ctx = make_ctx().await;
let args = ToolArgs::new();
let result = Rm.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(result.err.contains("missing"));
}
#[tokio::test]
async fn test_rm_r_recursive() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/fulldir".into()));
args.flags.insert("r".to_string());
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/fulldir")).await);
assert!(!ctx.backend.exists(Path::new("/fulldir/file.txt")).await);
}
#[tokio::test]
async fn test_rm_recursive_flag() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/fulldir".into()));
args.flags.insert("recursive".to_string());
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/fulldir")).await);
}
#[tokio::test]
async fn test_rm_f_force_nonexistent() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/nonexistent".into()));
args.flags.insert("f".to_string());
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok()); }
#[tokio::test]
async fn test_rm_force_flag_nonexistent() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/nonexistent".into()));
args.flags.insert("force".to_string());
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
}
async fn make_deep_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.write(Path::new("deep/a/b/c/file.txt"), b"data").await.unwrap();
mem.write(Path::new("deep/a/sibling.txt"), b"data").await.unwrap();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_rm_r_deeply_nested() {
let mut ctx = make_deep_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/deep".into()));
args.flags.insert("r".to_string());
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/deep")).await);
assert!(!ctx.backend.exists(Path::new("/deep/a")).await);
assert!(!ctx.backend.exists(Path::new("/deep/a/b")).await);
}
#[tokio::test]
async fn test_rm_latch_off_deletes_normally() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/file.txt".into()));
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/file.txt")).await);
}
#[tokio::test]
async fn test_rm_latch_on_no_confirm_returns_code_2() {
let mut ctx = make_ctx().await;
ctx.scope.set_latch_enabled(true);
let mut args = ToolArgs::new();
args.positional.push(Value::String("/file.txt".into()));
let result = Rm.execute(args, &mut ctx).await;
assert_eq!(result.code, 2);
assert!(result.err.contains("confirmation required"));
assert!(result.err.contains("Authorized: /file.txt"));
assert!(result.err.contains("--confirm="));
assert!(result.err.contains("60 seconds"));
assert!(ctx.backend.exists(Path::new("/file.txt")).await);
assert!(result.data.is_none(), "latch must not use the data-plane .data");
let req = result.latch_request().expect("a latch request on the .latch field");
assert_eq!(req.command, "rm");
assert_eq!(req.paths, vec!["/file.txt".to_string()]);
assert_eq!(req.ttl, 60);
assert!(req.hint.contains("--confirm="));
assert!(!req.nonce.is_empty());
}
#[tokio::test]
async fn test_rm_latch_on_valid_confirm_deletes() {
let mut ctx = make_ctx().await;
ctx.scope.set_latch_enabled(true);
let nonce = ctx.nonce_store.issue("rm", &["/file.txt"]);
let mut args = ToolArgs::new();
args.positional.push(Value::String("/file.txt".into()));
args.named.insert("confirm".to_string(), Value::String(nonce));
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/file.txt")).await);
}
#[tokio::test]
async fn test_rm_latch_on_invalid_confirm_fails() {
let mut ctx = make_ctx().await;
ctx.scope.set_latch_enabled(true);
let mut args = ToolArgs::new();
args.positional.push(Value::String("/file.txt".into()));
args.named.insert("confirm".to_string(), Value::String("bogus123".into()));
let result = Rm.execute(args, &mut ctx).await;
assert_eq!(result.code, 1);
assert!(result.err.contains("invalid nonce"));
assert!(ctx.backend.exists(Path::new("/file.txt")).await);
}
#[tokio::test]
async fn test_rm_latch_on_force_nonexistent() {
let mut ctx = make_ctx().await;
ctx.scope.set_latch_enabled(true);
let mut args = ToolArgs::new();
args.positional.push(Value::String("/nonexistent".into()));
args.flags.insert("f".to_string());
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
}
#[tokio::test]
async fn test_rm_latch_on_nonexistent_no_force() {
let mut ctx = make_ctx().await;
ctx.scope.set_latch_enabled(true);
let mut args = ToolArgs::new();
args.positional.push(Value::String("/nonexistent".into()));
let result = Rm.execute(args, &mut ctx).await;
assert_eq!(result.code, 1);
assert!(result.err.contains("No such file"));
}
#[tokio::test]
async fn test_rm_latch_nonce_reuse_idempotent() {
let mut ctx = make_ctx().await;
ctx.scope.set_latch_enabled(true);
let nonce = ctx.nonce_store.issue("rm", &["/file.txt"]);
let mut args = ToolArgs::new();
args.positional.push(Value::String("/file.txt".into()));
args.named.insert("confirm".to_string(), Value::String(nonce.clone()));
let result = Rm.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(!ctx.backend.exists(Path::new("/file.txt")).await);
let mut args2 = ToolArgs::new();
args2.positional.push(Value::String("/file.txt".into()));
args2.named.insert("confirm".to_string(), Value::String(nonce));
let result2 = Rm.execute(args2, &mut ctx).await;
assert_eq!(result2.code, 1);
}
#[tokio::test]
async fn test_rm_latch_error_message_is_parseable() {
let mut ctx = make_ctx().await;
ctx.scope.set_latch_enabled(true);
let mut args = ToolArgs::new();
args.positional.push(Value::String("/file.txt".into()));
let result = Rm.execute(args, &mut ctx).await;
assert_eq!(result.code, 2);
let err = &result.err;
assert!(err.contains("rm --confirm="));
assert!(err.contains("/file.txt"));
assert!(err.contains("60 seconds"));
let confirm_prefix = "rm --confirm=\"";
let idx = err.find(confirm_prefix).expect("should contain confirm prefix");
let nonce_start = idx + confirm_prefix.len();
let nonce: String = err[nonce_start..].chars().take(8).collect();
assert_eq!(nonce.len(), 8);
assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_decide_rm_action_no_flags() {
let action = decide_rm_action(false, false, None, Some(100), 10_000_000, false, false);
assert_eq!(action, RmAction::Delete);
}
#[test]
fn test_decide_rm_action_latch_only() {
let action = decide_rm_action(false, true, None, Some(100), 10_000_000, false, false);
assert_eq!(action, RmAction::Latch);
}
#[test]
fn test_decide_rm_action_trash_small_file() {
let real = PathBuf::from("/home/user/file.txt");
let action = decide_rm_action(true, false, Some(&real), Some(100), 10_000_000, false, false);
assert_eq!(action, RmAction::Trash(real));
}
#[test]
fn test_decide_rm_action_trash_small_with_latch() {
let real = PathBuf::from("/home/user/file.txt");
let action = decide_rm_action(true, true, Some(&real), Some(100), 10_000_000, false, false);
assert_eq!(action, RmAction::Trash(real));
}
#[test]
fn test_decide_rm_action_trash_large_no_latch() {
let real = PathBuf::from("/home/user/bigfile.bin");
let action = decide_rm_action(true, false, Some(&real), Some(100_000_000), 10_000_000, false, false);
assert_eq!(action, RmAction::Delete);
}
#[test]
fn test_decide_rm_action_trash_large_with_latch() {
let real = PathBuf::from("/home/user/bigfile.bin");
let action = decide_rm_action(true, true, Some(&real), Some(100_000_000), 10_000_000, false, false);
assert_eq!(action, RmAction::Latch);
}
#[test]
fn test_decide_rm_action_trash_virtual_path() {
let action = decide_rm_action(true, false, None, Some(100), 10_000_000, false, false);
assert_eq!(action, RmAction::Delete);
}
#[test]
fn test_decide_rm_action_trash_excluded_tmp() {
let real = PathBuf::from("/tmp/scratch");
let action = decide_rm_action(true, false, Some(&real), Some(100), 10_000_000, false, false);
assert_eq!(action, RmAction::Delete);
}
#[test]
fn test_decide_rm_action_real_v_path_is_trashed() {
let real = PathBuf::from("/v/cas/blob.bin");
let action = decide_rm_action(true, false, Some(&real), Some(100), 10_000_000, false, false);
assert_eq!(action, RmAction::Trash(real));
}
#[test]
fn test_decide_rm_action_dir_always_trashes() {
let real = PathBuf::from("/home/user/mydir");
let action = decide_rm_action(true, false, Some(&real), Some(0), 10_000_000, true, false);
assert_eq!(action, RmAction::Trash(real));
}
#[test]
fn test_decide_rm_action_dir_trashes_with_latch() {
let real = PathBuf::from("/home/user/mydir");
let action = decide_rm_action(true, true, Some(&real), Some(0), 10_000_000, true, false);
assert_eq!(action, RmAction::Trash(real));
}
#[test]
fn test_decide_rm_action_dir_excluded_tmp() {
let real = PathBuf::from("/tmp/mydir");
let action = decide_rm_action(true, false, Some(&real), Some(0), 10_000_000, true, false);
assert_eq!(action, RmAction::Delete);
}
#[derive(Debug, PartialEq)]
enum Outcome {
Deleted,
Trashed,
Latched,
}
fn matrix_action_to_outcome(action: &RmAction) -> Outcome {
match action {
RmAction::Trash(_) => Outcome::Trashed,
RmAction::Delete => Outcome::Deleted,
RmAction::Latch => Outcome::Latched,
}
}
#[test]
fn test_composition_matrix() {
let real = PathBuf::from("/home/user/file.txt");
let small = 100u64;
let large = 100_000_000u64;
let max = 10_000_000u64;
let cases = vec![
(false, false, small, false, false, Outcome::Deleted),
(false, true, small, false, false, Outcome::Latched),
(true, false, small, false, false, Outcome::Trashed),
(true, true, small, false, false, Outcome::Trashed), (false, false, large, false, false, Outcome::Deleted),
(false, true, large, false, false, Outcome::Latched),
(true, false, large, false, false, Outcome::Deleted), (true, true, large, false, false, Outcome::Latched), (true, false, 0, true, false, Outcome::Trashed),
(true, true, 0, true, false, Outcome::Trashed),
(false, false, 0, true, false, Outcome::Deleted),
(false, true, 0, true, false, Outcome::Latched),
(true, false, small, false, true, Outcome::Deleted),
(true, true, small, false, true, Outcome::Latched),
(true, false, 0, true, true, Outcome::Deleted), (false, false, small, false, true, Outcome::Deleted),
(false, true, small, false, true, Outcome::Latched),
];
for (trash, latch, size, is_dir, is_symlink, expected) in cases {
let action = decide_rm_action(trash, latch, Some(&real), Some(size), max, is_dir, is_symlink);
let outcome = matrix_action_to_outcome(&action);
assert_eq!(
outcome, expected,
"trash={}, latch={}, size={}, is_dir={}, is_symlink={}: expected {:?}, got {:?}",
trash, latch, size, is_dir, is_symlink, expected, outcome
);
}
}
}