返回 CodeWhale
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+G or 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 //! `~/.codewhale/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 /// Read-only stash facts for diagnostic output.
38 ///
39 /// Unlike the ordinary composer helpers, this report never creates a state
40 /// directory or falls back outside an explicit `CODEWHALE_HOME` boundary. It
41 /// rejects a stash-file symlink observed during inspection; Unix opens also
42 /// use `O_NOFOLLOW` for the final leaf open.
43 #[derive(Debug, Clone)]
44 pub(crate) struct DiagnosticStashReport {
45 /// Candidate stash path, when the Codewhale home could be resolved.
46 pub(crate) path: Option<PathBuf>,
47 /// Whether a regular stash file was present at that path.
48 pub(crate) present: bool,
49 /// Number of valid, non-empty draft records observed without mutation.
50 pub(crate) count: usize,
51 /// A safe path-shape or read error, if inspection could not complete.
52 pub(crate) error: Option<String>,
53 }
54
55 /// Hard cap so a runaway script can't fill the user's home with
56 /// parked drafts. Older entries are pruned at push time when the
57 /// stash exceeds this count.
58 pub const MAX_STASH_ENTRIES: usize = 200;
59
60 /// One parked draft. Fields are `#[serde(default)]` so legacy /
61 /// truncated records still parse instead of poisoning the stash.
62 #[derive(Debug, Clone, Serialize, Deserialize)]
63 pub struct StashedDraft {
64 /// RFC 3339 timestamp; omitted on legacy records.
65 #[serde(default)]
66 pub ts: String,
67 /// The parked text. Required — entries with no `text` are
68 /// dropped during load (treated as malformed).
69 pub text: String,
70 }
71
72 fn default_stash_path() -> Option<PathBuf> {
73 crate::config::effective_home_dir().map(|home| {
74 let primary = home.join(".codewhale").join(STASH_FILE_NAME);
75 let legacy = home.join(".deepseek").join(STASH_FILE_NAME);
76 if primary.exists() || !legacy.exists() {
77 return primary;
78 }
79 legacy
80 })
81 }
82
83 /// Inspect the composer stash for `doctor` without changing product state.
84 ///
85 /// Ordinary composer reads retain their historical legacy fallback behavior.
86 /// Diagnostics follow the same behavior only when no explicit
87 /// `CODEWHALE_HOME` is configured; an explicit home is an isolation boundary
88 /// and must not cause doctor to inspect an ambient `$HOME/.codewhale` or
89 /// `$HOME/.deepseek` stash.
90 pub(crate) fn diagnostic_stash_report() -> DiagnosticStashReport {
91 let primary = match codewhale_config::codewhale_home() {
92 Ok(home) => home.join(STASH_FILE_NAME),
93 Err(error) => {
94 return DiagnosticStashReport {
95 path: None,
96 present: false,
97 count: 0,
98 error: Some(format!(
99 "could not resolve the Codewhale stash path: {error}"
100 )),
101 };
102 }
103 };
104
105 let explicit_home = codewhale_config::codewhale_home_is_explicit();
106 let legacy = if explicit_home {
107 None
108 } else {
109 match codewhale_config::legacy_deepseek_home() {
110 Ok(home) => Some(home.join(STASH_FILE_NAME)),
111 Err(error) => {
112 return DiagnosticStashReport {
113 path: Some(primary),
114 present: false,
115 count: 0,
116 error: Some(format!(
117 "could not resolve the legacy composer stash path: {error}"
118 )),
119 };
120 }
121 }
122 };
123
124 diagnostic_stash_report_from_paths(primary, legacy, explicit_home)
125 }
126
127 fn diagnostic_stash_report_from_paths(
128 primary: PathBuf,
129 legacy: Option<PathBuf>,
130 explicit_home: bool,
131 ) -> DiagnosticStashReport {
132 let path = match std::fs::symlink_metadata(&primary) {
133 Ok(_) => primary,
134 Err(error) if error.kind() == io::ErrorKind::NotFound && !explicit_home => {
135 let Some(legacy) = legacy else {
136 return diagnostic_stash_report_at(primary);
137 };
138 match std::fs::symlink_metadata(&legacy) {
139 Ok(_) => legacy,
140 Err(error) if error.kind() == io::ErrorKind::NotFound => primary,
141 Err(error) => {
142 return DiagnosticStashReport {
143 path: Some(legacy),
144 present: false,
145 count: 0,
146 error: Some(format!(
147 "could not inspect legacy composer stash metadata: {error}"
148 )),
149 };
150 }
151 }
152 }
153 Err(error) if error.kind() == io::ErrorKind::NotFound => primary,
154 Err(error) => {
155 return DiagnosticStashReport {
156 path: Some(primary),
157 present: false,
158 count: 0,
159 error: Some(format!(
160 "could not inspect composer stash metadata: {error}"
161 )),
162 };
163 }
164 };
165 diagnostic_stash_report_at(path)
166 }
167
168 fn diagnostic_stash_report_at(path: PathBuf) -> DiagnosticStashReport {
169 let metadata = match std::fs::symlink_metadata(&path) {
170 Ok(metadata) => metadata,
171 Err(error) if error.kind() == io::ErrorKind::NotFound => {
172 return DiagnosticStashReport {
173 path: Some(path),
174 present: false,
175 count: 0,
176 error: None,
177 };
178 }
179 Err(error) => {
180 return DiagnosticStashReport {
181 path: Some(path),
182 present: false,
183 count: 0,
184 error: Some(format!(
185 "could not inspect composer stash metadata: {error}"
186 )),
187 };
188 }
189 };
190 if metadata.file_type().is_symlink() {
191 return DiagnosticStashReport {
192 path: Some(path),
193 present: false,
194 count: 0,
195 error: Some("composer stash path is a symlink; doctor did not follow it".to_string()),
196 };
197 }
198 if !metadata.file_type().is_file() {
199 return DiagnosticStashReport {
200 path: Some(path),
201 present: false,
202 count: 0,
203 error: Some("composer stash path is not a regular file".to_string()),
204 };
205 }
206
207 match load_stash_for_diagnostic(&path) {
208 Ok(entries) => DiagnosticStashReport {
209 path: Some(path),
210 present: true,
211 count: entries.len(),
212 error: None,
213 },
214 Err(error) => DiagnosticStashReport {
215 path: Some(path),
216 present: false,
217 count: 0,
218 error: Some(error),
219 },
220 }
221 }
222
223 fn load_stash_for_diagnostic(path: &Path) -> Result<Vec<StashedDraft>, String> {
224 #[cfg(unix)]
225 let file = {
226 use std::os::unix::fs::OpenOptionsExt;
227
228 std::fs::OpenOptions::new()
229 .read(true)
230 .custom_flags(libc::O_NOFOLLOW)
231 .open(path)
232 };
233 #[cfg(not(unix))]
234 let file = fs::File::open(path);
235 let file = file.map_err(|error| format!("could not open composer stash read-only: {error}"))?;
236
237 let mut entries = Vec::new();
238 for line in BufReader::new(file).lines() {
239 let line = line.map_err(|error| format!("could not read composer stash: {error}"))?;
240 if line.trim().is_empty() {
241 continue;
242 }
243 if let Ok(draft) = serde_json::from_str::<StashedDraft>(&line)
244 && !draft.text.is_empty()
245 {
246 entries.push(draft);
247 }
248 }
249 Ok(entries)
250 }
251
252 /// Load every stashed draft from disk in the order they were
253 /// written (oldest first). Self-healing: malformed lines are
254 /// dropped silently. Returns an empty vec when the file doesn't
255 /// exist.
256 #[must_use]
257 pub fn load_stash() -> Vec<StashedDraft> {
258 let Some(path) = default_stash_path() else {
259 return Vec::new();
260 };
261 load_stash_from(&path)
262 }
263
264 fn load_stash_from(path: &Path) -> Vec<StashedDraft> {
265 let Ok(file) = fs::File::open(path) else {
266 return Vec::new();
267 };
268 BufReader::new(file)
269 .lines()
270 .map_while(Result::ok)
271 .filter(|line| !line.trim().is_empty())
272 .filter_map(|line| serde_json::from_str::<StashedDraft>(&line).ok())
273 .filter(|draft| !draft.text.is_empty())
274 .collect()
275 }
276
277 /// Push a new draft onto the stash. Empty / whitespace-only text
278 /// is silently dropped so a stray stash shortcut on an empty composer
279 /// doesn't pollute the file. Failures are logged but never
280 /// propagated — stash is a UX nicety, not a correctness concern.
281 pub fn push_stash(text: &str) {
282 let Some(path) = default_stash_path() else {
283 return;
284 };
285 push_stash_to(&path, text);
286 }
287
288 fn push_stash_to(path: &Path, text: &str) {
289 let trimmed = text.trim();
290 if trimmed.is_empty() {
291 return;
292 }
293 if let Some(parent) = path.parent()
294 && let Err(err) = fs::create_dir_all(parent)
295 {
296 tracing::warn!(
297 "Failed to create composer stash dir {}: {err}",
298 parent.display()
299 );
300 return;
301 }
302
303 let mut entries = load_stash_from(path);
304 entries.push(StashedDraft {
305 ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
306 text: text.to_string(),
307 });
308 if entries.len() > MAX_STASH_ENTRIES {
309 let excess = entries.len() - MAX_STASH_ENTRIES;
310 entries.drain(0..excess);
311 }
312 write_stash_to(path, &entries);
313 }
314
315 /// Remove and return the most recently pushed draft, if any.
316 /// Rewrites the on-disk file with the remaining entries.
317 #[must_use]
318 pub fn pop_stash() -> Option<StashedDraft> {
319 let path = default_stash_path()?;
320 pop_stash_from(&path)
321 }
322
323 /// Wipe the stash file entirely. Returns the number of entries
324 /// that were dropped (so the caller can report it). Returns 0
325 /// when the file doesn't exist or had no entries.
326 pub fn clear_stash() -> io::Result<usize> {
327 let Some(path) = default_stash_path() else {
328 return Ok(0);
329 };
330 clear_stash_at(&path)
331 }
332
333 fn clear_stash_at(path: &Path) -> io::Result<usize> {
334 if !path.exists() {
335 return Ok(0);
336 }
337 let count = load_stash_from(path).len();
338 // Clear means the file is empty afterwards, not that no *valid* draft was
339 // found: malformed-only lines are still stashed text on disk (U01-m3).
340 if count == 0 && fs::metadata(path)?.len() == 0 {
341 return Ok(0);
342 }
343 crate::utils::write_atomic(path, b"")?;
344 Ok(count)
345 }
346
347 fn pop_stash_from(path: &Path) -> Option<StashedDraft> {
348 let mut entries = load_stash_from(path);
349 let popped = entries.pop()?;
350 write_stash_to(path, &entries);
351 Some(popped)
352 }
353
354 fn write_stash_to(path: &Path, entries: &[StashedDraft]) {
355 let mut payload = String::new();
356 for entry in entries {
357 match serde_json::to_string(entry) {
358 Ok(line) => {
359 payload.push_str(&line);
360 payload.push('\n');
361 }
362 Err(err) => {
363 // A draft that round-trips through serde shouldn't
364 // fail to serialize, but belt-and-suspenders so a
365 // weird codepoint in `text` doesn't blow the file
366 // away mid-write.
367 tracing::warn!("Skipping stash entry due to serialize failure: {err}");
368 }
369 }
370 }
371 if let Err(err) = crate::utils::write_atomic(path, payload.as_bytes()) {
372 tracing::warn!(
373 "Failed to persist composer stash at {}: {err}",
374 path.display()
375 );
376 }
377 }
378
379 #[cfg(test)]
380 mod tests {
381 use super::*;
382 use tempfile::TempDir;
383
384 fn temp_stash_path() -> (TempDir, PathBuf) {
385 let tmp = tempfile::tempdir().expect("tempdir");
386 let path = tmp.path().join("composer_stash.jsonl");
387 (tmp, path)
388 }
389
390 #[test]
391 fn push_and_load_round_trip() {
392 let (_tmp, path) = temp_stash_path();
393 push_stash_to(&path, "first draft");
394 push_stash_to(&path, "second draft");
395 let entries = load_stash_from(&path);
396 assert_eq!(entries.len(), 2);
397 assert_eq!(entries[0].text, "first draft");
398 assert_eq!(entries[1].text, "second draft");
399 assert!(!entries[1].ts.is_empty(), "timestamp stamped on push");
400 }
401
402 #[test]
403 fn pop_returns_lifo_and_rewrites_file() {
404 let (_tmp, path) = temp_stash_path();
405 push_stash_to(&path, "first");
406 push_stash_to(&path, "second");
407 let popped = pop_stash_from(&path).expect("non-empty stash");
408 assert_eq!(popped.text, "second");
409 let remaining = load_stash_from(&path);
410 assert_eq!(remaining.len(), 1);
411 assert_eq!(remaining[0].text, "first");
412 }
413
414 #[test]
415 fn pop_on_empty_stash_returns_none() {
416 let (_tmp, path) = temp_stash_path();
417 assert!(pop_stash_from(&path).is_none());
418 }
419
420 #[test]
421 fn empty_text_is_dropped() {
422 let (_tmp, path) = temp_stash_path();
423 push_stash_to(&path, "");
424 push_stash_to(&path, " \n ");
425 assert!(load_stash_from(&path).is_empty());
426 }
427
428 #[test]
429 fn multiline_drafts_are_preserved_intact() {
430 let (_tmp, path) = temp_stash_path();
431 let multiline = "first line\nsecond line\n third line";
432 push_stash_to(&path, multiline);
433 let entries = load_stash_from(&path);
434 assert_eq!(entries.len(), 1);
435 // Multi-line text round-trips because JSON escapes the newlines.
436 assert_eq!(entries[0].text, multiline);
437 }
438
439 #[test]
440 fn malformed_lines_are_skipped_and_valid_lines_survive() {
441 let (_tmp, path) = temp_stash_path();
442 // Mix of valid JSON, garbage, and partial-write truncation.
443 let raw = "\
444 {\"ts\":\"2026-05-04T01:23:45Z\",\"text\":\"good one\"}
445 this is not json
446 {\"text\":\"good two\"}
447 {\"ts\":\"2026-05-04T01:24:00Z\"
448 {\"text\":\"\"}
449 {}
450 ";
451 std::fs::write(&path, raw).unwrap();
452 let entries = load_stash_from(&path);
453 assert_eq!(entries.len(), 2);
454 assert_eq!(entries[0].text, "good one");
455 assert_eq!(entries[1].text, "good two");
456 }
457
458 #[test]
459 fn clear_returns_zero_when_file_is_absent() {
460 let (_tmp, path) = temp_stash_path();
461 // Path doesn't exist yet.
462 assert_eq!(clear_stash_at(&path).unwrap(), 0);
463 }
464
465 #[test]
466 fn clear_returns_zero_when_file_is_empty() {
467 let (_tmp, path) = temp_stash_path();
468 std::fs::write(&path, "").unwrap();
469 assert_eq!(clear_stash_at(&path).unwrap(), 0);
470 }
471
472 /// U01-m3: a file holding only unparseable lines still holds stashed
473 /// text; `/stash clear` must leave it empty, not report success over it.
474 #[test]
475 fn clear_empties_a_file_of_malformed_lines() {
476 let (_tmp, path) = temp_stash_path();
477 std::fs::write(&path, "not json\n{\"ts\":1}\n").unwrap();
478 assert_eq!(clear_stash_at(&path).unwrap(), 0);
479 assert_eq!(std::fs::read(&path).unwrap(), b"");
480 }
481
482 #[test]
483 fn clear_drops_entries_and_reports_count() {
484 let (_tmp, path) = temp_stash_path();
485 push_stash_to(&path, "first");
486 push_stash_to(&path, "second");
487 push_stash_to(&path, "third");
488 let dropped = clear_stash_at(&path).expect("clear succeeds");
489 assert_eq!(dropped, 3);
490 // File still exists but is empty so subsequent loads come back clean.
491 assert!(load_stash_from(&path).is_empty());
492 }
493
494 #[test]
495 fn cap_prunes_oldest_at_push_time() {
496 let (_tmp, path) = temp_stash_path();
497 for i in 0..(MAX_STASH_ENTRIES + 5) {
498 push_stash_to(&path, &format!("draft {i}"));
499 }
500 let entries = load_stash_from(&path);
501 assert_eq!(entries.len(), MAX_STASH_ENTRIES);
502 // Oldest survivors are `5..` because the first 5 were pruned.
503 assert_eq!(entries[0].text, "draft 5");
504 assert_eq!(
505 entries[entries.len() - 1].text,
506 format!("draft {}", MAX_STASH_ENTRIES + 5 - 1)
507 );
508 }
509
510 #[test]
511 fn diagnostic_stash_honors_an_explicit_home_without_legacy_fallback() {
512 let tmp = tempfile::tempdir().expect("tempdir");
513 let primary = tmp.path().join("isolated-codewhale").join(STASH_FILE_NAME);
514 let legacy = tmp.path().join("ambient-deepseek").join(STASH_FILE_NAME);
515 std::fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("legacy parent");
516 std::fs::write(&legacy, r#"{"text":"ambient draft"}"#).expect("legacy stash");
517
518 let report = diagnostic_stash_report_from_paths(primary.clone(), Some(legacy), true);
519
520 assert_eq!(report.path.as_deref(), Some(primary.as_path()));
521 assert!(!report.present);
522 assert_eq!(report.count, 0);
523 assert!(report.error.is_none());
524 assert!(
525 !primary.parent().expect("primary parent").exists(),
526 "diagnostic lookup must not create an explicit state home"
527 );
528 }
529
530 #[cfg(unix)]
531 #[test]
532 fn diagnostic_stash_rejects_a_symlink_leaf_without_following_it() {
533 use std::os::unix::fs::symlink;
534
535 let tmp = tempfile::tempdir().expect("tempdir");
536 let external = tmp.path().join("external-stash.jsonl");
537 let primary = tmp.path().join("composer_stash.jsonl");
538 std::fs::write(&external, r#"{"text":"external draft"}"#).expect("external stash");
539 symlink(&external, &primary).expect("symlink stash");
540
541 let report = diagnostic_stash_report_from_paths(primary.clone(), None, true);
542
543 assert_eq!(report.path.as_deref(), Some(primary.as_path()));
544 assert!(!report.present);
545 assert_eq!(report.count, 0);
546 assert!(
547 report
548 .error
549 .as_deref()
550 .is_some_and(|error| error.contains("symlink"))
551 );
552 }
553 }
554
554 lines RUST