feat(stash): /stash clear subcommand to wipe the stash file (#440 polish)

Pairs with `/stash list` and `/stash pop` so the user can fully
manage the stash from inside the TUI without reaching for `rm`.

* New `composer_stash::clear_stash()` returns the number of
  entries dropped so the slash command can report it.
  Atomic-write replaces the file with empty content; missing /
  empty files return `Ok(0)` without erroring.
* `clear` / `wipe` / `drop` are accepted as the subcommand
  alias. The "unknown subcommand" hint now lists the three live
  subcommands explicitly.
* CommandInfo usage updated to `/stash [list|pop|clear]` so
  `/help` and the autocomplete reflect the new option.
* 3 new tests in `composer_stash`: returns-0 when file absent,
  returns-0 when file is empty, drops entries and reports count
  on a populated stash.

No new dependency; reuses `crate::utils::write_atomic` for the
truncate-and-rewrite.
This commit is contained in:
Hunter Bown
2026-05-03 06:28:18 -05:00
parent ba871c56f6
commit 15127046e8
4 changed files with 68 additions and 2 deletions
+5
View File
@@ -214,6 +214,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`TuiOptions::initial_input` plumb that any future caller can
reuse to drop the model into a session with text already
typed.
- **`/stash clear` subcommand** (#440 polish) — wipes the
entire stash file and reports how many parked drafts were
dropped. Pairs with `/stash list` and `/stash pop` so the
user can fully manage the stash from inside the TUI without
reaching for `rm`.
- **RLM tool family** (#512) — `rlm` tool cards map to
`ToolFamily::Rlm` and render `rlm`, not `swarm`. Stale "swarm"
wording cleaned out of docs / comments / tests.
+1 -1
View File
@@ -175,7 +175,7 @@ pub const COMMANDS: &[CommandInfo] = &[
CommandInfo {
name: "stash",
aliases: &["park"],
usage: "/stash [list|pop]",
usage: "/stash [list|pop|clear]",
description_id: MessageId::CmdStashDescription,
},
CommandInfo {
+12 -1
View File
@@ -16,13 +16,16 @@ use super::CommandResult;
/// * `/stash list` — show parked drafts, oldest first.
/// * `/stash pop` — restore the most recently parked draft into
/// the composer; the popped entry is removed from disk.
/// * `/stash clear` — wipe the entire stash file. Reports how many
/// entries were dropped so the user knows what they deleted.
pub fn stash(app: &mut App, arg: Option<&str>) -> CommandResult {
let sub = arg.map(str::trim).unwrap_or("list").to_ascii_lowercase();
match sub.as_str() {
"" | "list" | "ls" | "show" => list(),
"pop" | "restore" => pop(app),
"clear" | "wipe" | "drop" => clear(),
other => CommandResult::error(format!(
"unknown subcommand `{other}`. Try `/stash list` or `/stash pop`."
"unknown subcommand `{other}`. Try `/stash list`, `/stash pop`, or `/stash clear`."
)),
}
}
@@ -49,6 +52,14 @@ fn list() -> CommandResult {
CommandResult::message(out)
}
fn clear() -> CommandResult {
match composer_stash::clear_stash() {
Ok(0) => CommandResult::message("Stash already empty — nothing to clear."),
Ok(n) => CommandResult::message(format!("Cleared {n} parked draft(s) from the stash.")),
Err(err) => CommandResult::error(format!("Failed to clear stash: {err}")),
}
}
fn pop(app: &mut App) -> CommandResult {
match composer_stash::pop_stash() {
Some(entry) => {
+50
View File
@@ -26,6 +26,7 @@
//! ambiguity and the timestamp / future fields land cleanly.
use std::fs;
use std::io;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
@@ -125,6 +126,29 @@ pub fn pop_stash() -> Option<StashedDraft> {
pop_stash_from(&path)
}
/// Wipe the stash file entirely. Returns the number of entries
/// that were dropped (so the caller can report it). Returns 0
/// when the file doesn't exist or had no entries.
pub fn clear_stash() -> io::Result<usize> {
let Some(path) = default_stash_path() else {
return Ok(0);
};
clear_stash_at(&path)
}
fn clear_stash_at(path: &Path) -> io::Result<usize> {
if !path.exists() {
return Ok(0);
}
let entries = load_stash_from(path);
let count = entries.len();
if count == 0 {
return Ok(0);
}
crate::utils::write_atomic(path, b"")?;
Ok(count)
}
fn pop_stash_from(path: &Path) -> Option<StashedDraft> {
let mut entries = load_stash_from(path);
let popped = entries.pop()?;
@@ -236,6 +260,32 @@ this is not json
assert_eq!(entries[1].text, "good two");
}
#[test]
fn clear_returns_zero_when_file_is_absent() {
let (_tmp, path) = temp_stash_path();
// Path doesn't exist yet.
assert_eq!(clear_stash_at(&path).unwrap(), 0);
}
#[test]
fn clear_returns_zero_when_file_is_empty() {
let (_tmp, path) = temp_stash_path();
std::fs::write(&path, "").unwrap();
assert_eq!(clear_stash_at(&path).unwrap(), 0);
}
#[test]
fn clear_drops_entries_and_reports_count() {
let (_tmp, path) = temp_stash_path();
push_stash_to(&path, "first");
push_stash_to(&path, "second");
push_stash_to(&path, "third");
let dropped = clear_stash_at(&path).expect("clear succeeds");
assert_eq!(dropped, 3);
// File still exists but is empty so subsequent loads come back clean.
assert!(load_stash_from(&path).is_empty());
}
#[test]
fn cap_prunes_oldest_at_push_time() {
let (_tmp, path) = temp_stash_path();