返回 DeepSeek-TUI-2026
composer_stash.rs
根目录 / crates / tui / src / composer_stash.rs
1 //! Parked-draft stash for the composer (#440).
2 //!
3 //! A stash is a side-channel from history: it holds drafts the user
4 //! parked deliberately (Ctrl+S) instead of submissions made in the
5 //! past (which live in `composer_history.rs`). Pop semantics make it
6 //! a LIFO — the most recent stash comes back first.
7 //!
8 //! ## On-disk format
9 //!
10 //! `~/.deepseek/composer_stash.jsonl` — one JSON object per line:
11 //!
12 //! ```jsonl
13 //! {"ts":"2026-05-04T01:23:45Z","text":"draft here"}
14 //! ```
15 //!
16 //! Self-healing parser: malformed lines are skipped silently so a
17 //! single bad write doesn't corrupt the rest of the stash. The
18 //! parser doesn't require any specific field order; only `text` is
19 //! mandatory.
20 //!
21 //! ## Why JSONL and not a plain text file?
22 //!
23 //! Drafts can contain newlines (they're prompts, not single-line
24 //! commands), so a `\n`-delimited plain file would mangle multi-line
25 //! drafts. JSONL escapes newlines inside JSON strings without
26 //! ambiguity and the timestamp / future fields land cleanly.
27
28 use std::fs;
29 use std::io;
30 use std::io::{BufRead, BufReader};
31 use std::path::{Path, PathBuf};
32
33 use serde::{Deserialize, Serialize};
34
35 const STASH_FILE_NAME: &str = "composer_stash.jsonl";
36
37 /// Hard cap so a runaway script can't fill the user's home with
38 /// parked drafts. Older entries are pruned at push time when the
39 /// stash exceeds this count.
40 pub const MAX_STASH_ENTRIES: usize = 200;
41
42 /// One parked draft. Fields are `#[serde(default)]` so legacy /
43 /// truncated records still parse instead of poisoning the stash.
44 #[derive(Debug, Clone, Serialize, Deserialize)]
45 pub struct StashedDraft {
46 /// RFC 3339 timestamp; omitted on legacy records.
47 #[serde(default)]
48 pub ts: String,
49 /// The parked text. Required — entries with no `text` are
50 /// dropped during load (treated as malformed).
51 pub text: String,
52 }
53
54 fn default_stash_path() -> Option<PathBuf> {
55 dirs::home_dir().map(|home| home.join(".deepseek").join(STASH_FILE_NAME))
56 }
57
58 /// Load every stashed draft from disk in the order they were
59 /// written (oldest first). Self-healing: malformed lines are
60 /// dropped silently. Returns an empty vec when the file doesn't
61 /// exist.
62 #[must_use]
63 pub fn load_stash() -> Vec<StashedDraft> {
64 let Some(path) = default_stash_path() else {
65 return Vec::new();
66 };
67 load_stash_from(&path)
68 }
69
70 fn load_stash_from(path: &Path) -> Vec<StashedDraft> {
71 let Ok(file) = fs::File::open(path) else {
72 return Vec::new();
73 };
74 BufReader::new(file)
75 .lines()
76 .map_while(Result::ok)
77 .filter(|line| !line.trim().is_empty())
78 .filter_map(|line| serde_json::from_str::<StashedDraft>(&line).ok())
79 .filter(|draft| !draft.text.is_empty())
80 .collect()
81 }
82
83 /// Push a new draft onto the stash. Empty / whitespace-only text
84 /// is silently dropped so a stray Ctrl+S on an empty composer
85 /// doesn't pollute the file. Failures are logged but never
86 /// propagated — stash is a UX nicety, not a correctness concern.
87 pub fn push_stash(text: &str) {
88 let Some(path) = default_stash_path() else {
89 return;
90 };
91 push_stash_to(&path, text);
92 }
93
94 fn push_stash_to(path: &Path, text: &str) {
95 let trimmed = text.trim();
96 if trimmed.is_empty() {
97 return;
98 }
99 if let Some(parent) = path.parent()
100 && let Err(err) = fs::create_dir_all(parent)
101 {
102 tracing::warn!(
103 "Failed to create composer stash dir {}: {err}",
104 parent.display()
105 );
106 return;
107 }
108
109 let mut entries = load_stash_from(path);
110 entries.push(StashedDraft {
111 ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
112 text: text.to_string(),
113 });
114 if entries.len() > MAX_STASH_ENTRIES {
115 let excess = entries.len() - MAX_STASH_ENTRIES;
116 entries.drain(0..excess);
117 }
118 write_stash_to(path, &entries);
119 }
120
121 /// Remove and return the most recently pushed draft, if any.
122 /// Rewrites the on-disk file with the remaining entries.
123 #[must_use]
124 pub fn pop_stash() -> Option<StashedDraft> {
125 let path = default_stash_path()?;
126 pop_stash_from(&path)
127 }
128
129 /// Wipe the stash file entirely. Returns the number of entries
130 /// that were dropped (so the caller can report it). Returns 0
131 /// when the file doesn't exist or had no entries.
132 pub fn clear_stash() -> io::Result<usize> {
133 let Some(path) = default_stash_path() else {
134 return Ok(0);
135 };
136 clear_stash_at(&path)
137 }
138
139 fn clear_stash_at(path: &Path) -> io::Result<usize> {
140 if !path.exists() {
141 return Ok(0);
142 }
143 let entries = load_stash_from(path);
144 let count = entries.len();
145 if count == 0 {
146 return Ok(0);
147 }
148 crate::utils::write_atomic(path, b"")?;
149 Ok(count)
150 }
151
152 fn pop_stash_from(path: &Path) -> Option<StashedDraft> {
153 let mut entries = load_stash_from(path);
154 let popped = entries.pop()?;
155 write_stash_to(path, &entries);
156 Some(popped)
157 }
158
159 fn write_stash_to(path: &Path, entries: &[StashedDraft]) {
160 let mut payload = String::new();
161 for entry in entries {
162 match serde_json::to_string(entry) {
163 Ok(line) => {
164 payload.push_str(&line);
165 payload.push('\n');
166 }
167 Err(err) => {
168 // A draft that round-trips through serde shouldn't
169 // fail to serialize, but belt-and-suspenders so a
170 // weird codepoint in `text` doesn't blow the file
171 // away mid-write.
172 tracing::warn!("Skipping stash entry due to serialize failure: {err}");
173 }
174 }
175 }
176 if let Err(err) = crate::utils::write_atomic(path, payload.as_bytes()) {
177 tracing::warn!(
178 "Failed to persist composer stash at {}: {err}",
179 path.display()
180 );
181 }
182 }
183
184 #[cfg(test)]
185 mod tests {
186 use super::*;
187 use tempfile::TempDir;
188
189 fn temp_stash_path() -> (TempDir, PathBuf) {
190 let tmp = tempfile::tempdir().expect("tempdir");
191 let path = tmp.path().join("composer_stash.jsonl");
192 (tmp, path)
193 }
194
195 #[test]
196 fn push_and_load_round_trip() {
197 let (_tmp, path) = temp_stash_path();
198 push_stash_to(&path, "first draft");
199 push_stash_to(&path, "second draft");
200 let entries = load_stash_from(&path);
201 assert_eq!(entries.len(), 2);
202 assert_eq!(entries[0].text, "first draft");
203 assert_eq!(entries[1].text, "second draft");
204 assert!(!entries[1].ts.is_empty(), "timestamp stamped on push");
205 }
206
207 #[test]
208 fn pop_returns_lifo_and_rewrites_file() {
209 let (_tmp, path) = temp_stash_path();
210 push_stash_to(&path, "first");
211 push_stash_to(&path, "second");
212 let popped = pop_stash_from(&path).expect("non-empty stash");
213 assert_eq!(popped.text, "second");
214 let remaining = load_stash_from(&path);
215 assert_eq!(remaining.len(), 1);
216 assert_eq!(remaining[0].text, "first");
217 }
218
219 #[test]
220 fn pop_on_empty_stash_returns_none() {
221 let (_tmp, path) = temp_stash_path();
222 assert!(pop_stash_from(&path).is_none());
223 }
224
225 #[test]
226 fn empty_text_is_dropped() {
227 let (_tmp, path) = temp_stash_path();
228 push_stash_to(&path, "");
229 push_stash_to(&path, " \n ");
230 assert!(load_stash_from(&path).is_empty());
231 }
232
233 #[test]
234 fn multiline_drafts_are_preserved_intact() {
235 let (_tmp, path) = temp_stash_path();
236 let multiline = "first line\nsecond line\n third line";
237 push_stash_to(&path, multiline);
238 let entries = load_stash_from(&path);
239 assert_eq!(entries.len(), 1);
240 // Multi-line text round-trips because JSON escapes the newlines.
241 assert_eq!(entries[0].text, multiline);
242 }
243
244 #[test]
245 fn malformed_lines_are_skipped_and_valid_lines_survive() {
246 let (_tmp, path) = temp_stash_path();
247 // Mix of valid JSON, garbage, and partial-write truncation.
248 let raw = "\
249 {\"ts\":\"2026-05-04T01:23:45Z\",\"text\":\"good one\"}
250 this is not json
251 {\"text\":\"good two\"}
252 {\"ts\":\"2026-05-04T01:24:00Z\"
253 {\"text\":\"\"}
254 {}
255 ";
256 std::fs::write(&path, raw).unwrap();
257 let entries = load_stash_from(&path);
258 assert_eq!(entries.len(), 2);
259 assert_eq!(entries[0].text, "good one");
260 assert_eq!(entries[1].text, "good two");
261 }
262
263 #[test]
264 fn clear_returns_zero_when_file_is_absent() {
265 let (_tmp, path) = temp_stash_path();
266 // Path doesn't exist yet.
267 assert_eq!(clear_stash_at(&path).unwrap(), 0);
268 }
269
270 #[test]
271 fn clear_returns_zero_when_file_is_empty() {
272 let (_tmp, path) = temp_stash_path();
273 std::fs::write(&path, "").unwrap();
274 assert_eq!(clear_stash_at(&path).unwrap(), 0);
275 }
276
277 #[test]
278 fn clear_drops_entries_and_reports_count() {
279 let (_tmp, path) = temp_stash_path();
280 push_stash_to(&path, "first");
281 push_stash_to(&path, "second");
282 push_stash_to(&path, "third");
283 let dropped = clear_stash_at(&path).expect("clear succeeds");
284 assert_eq!(dropped, 3);
285 // File still exists but is empty so subsequent loads come back clean.
286 assert!(load_stash_from(&path).is_empty());
287 }
288
289 #[test]
290 fn cap_prunes_oldest_at_push_time() {
291 let (_tmp, path) = temp_stash_path();
292 for i in 0..(MAX_STASH_ENTRIES + 5) {
293 push_stash_to(&path, &format!("draft {i}"));
294 }
295 let entries = load_stash_from(&path);
296 assert_eq!(entries.len(), MAX_STASH_ENTRIES);
297 // Oldest survivors are `5..` because the first 5 were pruned.
298 assert_eq!(entries[0].text, "draft 5");
299 assert_eq!(
300 entries[entries.len() - 1].text,
301 format!("draft {}", MAX_STASH_ENTRIES + 5 - 1)
302 );
303 }
304 }
305
305 lines RUST