#![cfg(feature = "localfs")]
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use kaish_kernel::ast::Value;
use kaish_kernel::interpreter::ExecResult;
use kaish_kernel::trash::{TrashBackend, TrashEntry, TrashError};
use kaish_kernel::{Kernel, KernelConfig};
fn tempdir() -> tempfile::TempDir {
tempfile::Builder::new()
.prefix("latch-trash-")
.tempdir_in(env!("CARGO_TARGET_TMPDIR"))
.expect("tempdir under CARGO_TARGET_TMPDIR")
}
fn kernel_at(dir: &Path) -> Kernel {
let config = KernelConfig::repl()
.with_cwd(dir.to_path_buf())
.with_latch(false)
.with_trash(false);
Kernel::new(config).expect("kernel")
}
async fn run(kernel: &Kernel, script: &str) -> ExecResult {
kernel.execute(script).await.expect("kernel execute")
}
fn latch_data_str(result: &ExecResult, key: &str) -> String {
let data = result
.data
.as_ref()
.expect("latch exit-2 result carries structured data");
match data {
Value::Json(json) => json[key]
.as_str()
.unwrap_or_else(|| panic!("latch data {key:?} should be a string: {json}"))
.to_string(),
other => panic!("expected Value::Json latch data, got {other:?}"),
}
}
#[tokio::test]
async fn latch_gates_rm_then_confirm_hint_deletes() {
let dir = tempdir();
std::fs::write(dir.path().join("precious.txt"), "data").expect("write");
let kernel = kernel_at(dir.path());
let enable = run(&kernel, "set -o latch").await;
assert_eq!(enable.code, 0, "set -o latch failed: {}", enable.err);
let gated = run(&kernel, "rm precious.txt").await;
assert_eq!(gated.code, 2, "expected latch exit 2, err: {}", gated.err);
assert!(
gated.err.contains("confirmation required"),
"latch message missing: {}",
gated.err
);
assert!(
dir.path().join("precious.txt").exists(),
"file must survive the latch gate"
);
let hint = latch_data_str(&gated, "hint");
let confirmed = run(&kernel, &hint).await;
assert_eq!(
confirmed.code, 0,
"confirm hint {hint:?} failed: {}",
confirmed.err
);
assert!(
!dir.path().join("precious.txt").exists(),
"file should be deleted after confirmation"
);
}
#[tokio::test]
async fn latch_bogus_nonce_fails_and_file_survives() {
let dir = tempdir();
std::fs::write(dir.path().join("precious.txt"), "data").expect("write");
let kernel = kernel_at(dir.path());
run(&kernel, "set -o latch").await;
let r = run(&kernel, "rm --confirm=\"deadbeef\" precious.txt").await;
assert_eq!(r.code, 1, "bogus nonce must fail, out: {}", r.text_out());
assert!(
r.err.contains("rm:"),
"expected an rm error naming the bad nonce, got: {}",
r.err
);
assert!(
dir.path().join("precious.txt").exists(),
"file must survive a rejected nonce"
);
}
#[tokio::test]
async fn latch_batches_multiple_paths_under_one_nonce() {
let dir = tempdir();
std::fs::write(dir.path().join("a.txt"), "a").expect("write");
std::fs::write(dir.path().join("b.txt"), "b").expect("write");
let kernel = kernel_at(dir.path());
run(&kernel, "set -o latch").await;
let gated = run(&kernel, "rm a.txt b.txt").await;
assert_eq!(gated.code, 2, "err: {}", gated.err);
assert!(
gated.err.contains("a.txt") && gated.err.contains("b.txt"),
"latch message should authorize both paths: {}",
gated.err
);
let hint = latch_data_str(&gated, "hint");
let confirmed = run(&kernel, &hint).await;
assert_eq!(confirmed.code, 0, "err: {}", confirmed.err);
assert!(!dir.path().join("a.txt").exists(), "a.txt should be gone");
assert!(!dir.path().join("b.txt").exists(), "b.txt should be gone");
}
#[tokio::test]
async fn latch_off_by_default_rm_deletes_directly() {
let dir = tempdir();
std::fs::write(dir.path().join("plain.txt"), "x").expect("write");
let kernel = kernel_at(dir.path());
let r = run(&kernel, "rm plain.txt").await;
assert_eq!(r.code, 0, "err: {}", r.err);
assert!(!dir.path().join("plain.txt").exists());
}
#[derive(Default)]
struct MockTrash {
trashed: Mutex<Vec<PathBuf>>,
fail: bool,
}
impl MockTrash {
fn failing() -> Self {
Self { fail: true, ..Self::default() }
}
fn trashed_paths(&self) -> Vec<PathBuf> {
self.trashed.lock().expect("mock lock").clone()
}
}
#[async_trait]
impl TrashBackend for MockTrash {
async fn trash(&self, path: &Path) -> Result<(), TrashError> {
if self.fail {
return Err(TrashError::Backend("mock trash refused".into()));
}
self.trashed
.lock()
.expect("mock lock")
.push(path.to_path_buf());
Ok(())
}
async fn list(&self, _filter: Option<&str>) -> Result<Vec<TrashEntry>, TrashError> {
Ok(Vec::new())
}
async fn find_by_name(&self, _name: &str) -> Result<Vec<TrashEntry>, TrashError> {
Ok(Vec::new())
}
async fn restore(&self, _entries: Vec<TrashEntry>) -> Result<(), TrashError> {
Ok(())
}
async fn purge_all(&self) -> Result<usize, TrashError> {
Ok(0)
}
}
fn kernel_with_trash(dir: &Path, mock: &Arc<MockTrash>) -> Kernel {
let mut kernel = kernel_at(dir);
kernel.set_trash_backend(Some(Arc::clone(mock) as Arc<dyn TrashBackend>));
kernel
}
#[tokio::test]
async fn trash_small_file_delegates_to_backend() {
let dir = tempdir();
std::fs::write(dir.path().join("keep.txt"), "data").expect("write");
let mock = Arc::new(MockTrash::default());
let kernel = kernel_with_trash(dir.path(), &mock);
run(&kernel, "set -o trash").await;
let r = run(&kernel, "rm keep.txt").await;
assert_eq!(r.code, 0, "err: {}", r.err);
let trashed = mock.trashed_paths();
assert_eq!(trashed.len(), 1, "exactly one trash call: {trashed:?}");
assert!(
trashed[0].ends_with("keep.txt"),
"trash received the real path: {trashed:?}"
);
assert!(
dir.path().join("keep.txt").exists(),
"rm must delegate removal to the trash backend, not delete as well"
);
}
#[tokio::test]
async fn trash_directory_always_trashes_without_recursive_flag() {
let dir = tempdir();
std::fs::create_dir(dir.path().join("sub")).expect("mkdir");
std::fs::write(dir.path().join("sub/inner.txt"), "x").expect("write");
let mock = Arc::new(MockTrash::default());
let kernel = kernel_with_trash(dir.path(), &mock);
run(&kernel, "set -o trash").await;
let r = run(&kernel, "rm sub").await;
assert_eq!(r.code, 0, "err: {}", r.err);
let trashed = mock.trashed_paths();
assert_eq!(trashed.len(), 1, "one trash call for the dir: {trashed:?}");
assert!(trashed[0].ends_with("sub"));
}
#[tokio::test]
async fn trash_catches_small_file_even_with_latch_enabled() {
let dir = tempdir();
std::fs::write(dir.path().join("small.txt"), "tiny").expect("write");
let mock = Arc::new(MockTrash::default());
let kernel = kernel_with_trash(dir.path(), &mock);
run(&kernel, "set -o latch").await;
run(&kernel, "set -o trash").await;
let r = run(&kernel, "rm small.txt").await;
assert_eq!(r.code, 0, "trash should win over latch, err: {}", r.err);
assert_eq!(mock.trashed_paths().len(), 1);
}
#[tokio::test]
async fn trash_failure_is_loud_and_never_falls_through_to_delete() {
let dir = tempdir();
std::fs::write(dir.path().join("guarded.txt"), "data").expect("write");
let mock = Arc::new(MockTrash::failing());
let kernel = kernel_with_trash(dir.path(), &mock);
run(&kernel, "set -o trash").await;
let r = run(&kernel, "rm guarded.txt").await;
assert_eq!(r.code, 1, "trash failure must be an error, not silent");
assert!(
r.err.contains("trash failed"),
"error should name the trash failure: {}",
r.err
);
assert!(
dir.path().join("guarded.txt").exists(),
"trash failure fell through to permanent delete"
);
}
#[tokio::test]
async fn trash_backend_absent_fails_loud() {
let dir = tempdir();
std::fs::write(dir.path().join("orphan.txt"), "data").expect("write");
let mut kernel = kernel_at(dir.path());
kernel.set_trash_backend(None);
run(&kernel, "set -o trash").await;
let r = run(&kernel, "rm orphan.txt").await;
assert_eq!(r.code, 1, "missing backend must be an error");
assert!(
r.err.contains("trash backend not available"),
"error should name the missing backend: {}",
r.err
);
assert!(
dir.path().join("orphan.txt").exists(),
"missing trash backend must not fall through to delete"
);
}