返回 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 entries = load_stash_from(path);
338 let count = entries.len();
339 if count == 0 {
340 return Ok(0);
341 }
342 crate::utils::write_atomic(path, b"")?;
343 Ok(count)
344 }
345
346 fn pop_stash_from(path: &Path) -> Option<StashedDraft> {
347 let mut entries = load_stash_from(path);
348 let popped = entries.pop()?;
349 write_stash_to(path, &entries);
350 Some(popped)
351 }
352
353 fn write_stash_to(path: &Path, entries: &[StashedDraft]) {
354 let mut payload = String::new();
355 for entry in entries {
356 match serde_json::to_string(entry) {
357 Ok(line) => {
358 payload.push_str(&line);
359 payload.push('\n');
360 }
361 Err(err) => {
362 // A draft that round-trips through serde shouldn't
363 // fail to serialize, but belt-and-suspenders so a
364 // weird codepoint in `text` doesn't blow the file
365 // away mid-write.
366 tracing::warn!("Skipping stash entry due to serialize failure: {err}");
367 }
368 }
369 }
370 if let Err(err) = crate::utils::write_atomic(path, payload.as_bytes()) {
371 tracing::warn!(
372 "Failed to persist composer stash at {}: {err}",
373 path.display()
374 );
375 }
376 }
377
378 #[cfg(test)]
379 mod tests {
380 use super::*;
381 use tempfile::TempDir;
382
383 fn temp_stash_path() -> (TempDir, PathBuf) {
384 let tmp = tempfile::tempdir().expect("tempdir");
385 let path = tmp.path().join("composer_stash.jsonl");
386 (tmp, path)
387 }
388
389 #[test]
390 fn push_and_load_round_trip() {
391 let (_tmp, path) = temp_stash_path();
392 push_stash_to(&path, "first draft");
393 push_stash_to(&path, "second draft");
394 let entries = load_stash_from(&path);
395 assert_eq!(entries.len(), 2);
396 assert_eq!(entries[0].text, "first draft");
397 assert_eq!(entries[1].text, "second draft");
398 assert!(!entries[1].ts.is_empty(), "timestamp stamped on push");
399 }
400
401 #[test]
402 fn pop_returns_lifo_and_rewrites_file() {
403 let (_tmp, path) = temp_stash_path();
404 push_stash_to(&path, "first");
405 push_stash_to(&path, "second");
406 let popped = pop_stash_from(&path).expect("non-empty stash");
407 assert_eq!(popped.text, "second");
408 let remaining = load_stash_from(&path);
409 assert_eq!(remaining.len(), 1);
410 assert_eq!(remaining[0].text, "first");
411 }
412
413 #[test]
414 fn pop_on_empty_stash_returns_none() {
415 let (_tmp, path) = temp_stash_path();
416 assert!(pop_stash_from(&path).is_none());
417 }
418
419 #[test]
420 fn empty_text_is_dropped() {
421 let (_tmp, path) = temp_stash_path();
422 push_stash_to(&path, "");
423 push_stash_to(&path, " \n ");
424 assert!(load_stash_from(&path).is_empty());
425 }
426
427 #[test]
428 fn multiline_drafts_are_preserved_intact() {
429 let (_tmp, path) = temp_stash_path();
430 let multiline = "first line\nsecond line\n third line";
431 push_stash_to(&path, multiline);
432 let entries = load_stash_from(&path);
433 assert_eq!(entries.len(), 1);
434 // Multi-line text round-trips because JSON escapes the newlines.
435 assert_eq!(entries[0].text, multiline);
436 }
437
438 #[test]
439 fn malformed_lines_are_skipped_and_valid_lines_survive() {
440 let (_tmp, path) = temp_stash_path();
441 // Mix of valid JSON, garbage, and partial-write truncation.
442 let raw = "\
443 {\"ts\":\"2026-05-04T01:23:45Z\",\"text\":\"good one\"}
444 this is not json
445 {\"text\":\"good two\"}
446 {\"ts\":\"2026-05-04T01:24:00Z\"
447 {\"text\":\"\"}
448 {}
449 ";
450 std::fs::write(&path, raw).unwrap();
451 let entries = load_stash_from(&path);
452 assert_eq!(entries.len(), 2);
453 assert_eq!(entries[0].text, "good one");
454 assert_eq!(entries[1].text, "good two");
455 }
456
457 #[test]
458 fn clear_returns_zero_when_file_is_absent() {
459 let (_tmp, path) = temp_stash_path();
460 // Path doesn't exist yet.
461 assert_eq!(clear_stash_at(&path).unwrap(), 0);
462 }
463
464 #[test]
465 fn clear_returns_zero_when_file_is_empty() {
466 let (_tmp, path) = temp_stash_path();
467 std::fs::write(&path, "").unwrap();
468 assert_eq!(clear_stash_at(&path).unwrap(), 0);
469 }
470
471 #[test]
472 fn clear_drops_entries_and_reports_count() {
473 let (_tmp, path) = temp_stash_path();
474 push_stash_to(&path, "first");
475 push_stash_to(&path, "second");
476 push_stash_to(&path, "third");
477 let dropped = clear_stash_at(&path).expect("clear succeeds");
478 assert_eq!(dropped, 3);
479 // File still exists but is empty so subsequent loads come back clean.
480 assert!(load_stash_from(&path).is_empty());
481 }
482
483 #[test]
484 fn cap_prunes_oldest_at_push_time() {
485 let (_tmp, path) = temp_stash_path();
486 for i in 0..(MAX_STASH_ENTRIES + 5) {
487 push_stash_to(&path, &format!("draft {i}"));
488 }
489 let entries = load_stash_from(&path);
490 assert_eq!(entries.len(), MAX_STASH_ENTRIES);
491 // Oldest survivors are `5..` because the first 5 were pruned.
492 assert_eq!(entries[0].text, "draft 5");
493 assert_eq!(
494 entries[entries.len() - 1].text,
495 format!("draft {}", MAX_STASH_ENTRIES + 5 - 1)
496 );
497 }
498
499 #[test]
500 fn diagnostic_stash_honors_an_explicit_home_without_legacy_fallback() {
501 let tmp = tempfile::tempdir().expect("tempdir");
502 let primary = tmp.path().join("isolated-codewhale").join(STASH_FILE_NAME);
503 let legacy = tmp.path().join("ambient-deepseek").join(STASH_FILE_NAME);
504 std::fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("legacy parent");
505 std::fs::write(&legacy, r#"{"text":"ambient draft"}"#).expect("legacy stash");
506
507 let report = diagnostic_stash_report_from_paths(primary.clone(), Some(legacy), true);
508
509 assert_eq!(report.path.as_deref(), Some(primary.as_path()));
510 assert!(!report.present);
511 assert_eq!(report.count, 0);
512 assert!(report.error.is_none());
513 assert!(
514 !primary.parent().expect("primary parent").exists(),
515 "diagnostic lookup must not create an explicit state home"
516 );
517 }
518
519 #[cfg(unix)]
520 #[test]
521 fn diagnostic_stash_rejects_a_symlink_leaf_without_following_it() {
522 use std::os::unix::fs::symlink;
523
524 let tmp = tempfile::tempdir().expect("tempdir");
525 let external = tmp.path().join("external-stash.jsonl");
526 let primary = tmp.path().join("composer_stash.jsonl");
527 std::fs::write(&external, r#"{"text":"external draft"}"#).expect("external stash");
528 symlink(&external, &primary).expect("symlink stash");
529
530 let report = diagnostic_stash_report_from_paths(primary.clone(), None, true);
531
532 assert_eq!(report.path.as_deref(), Some(primary.as_path()));
533 assert!(!report.present);
534 assert_eq!(report.count, 0);
535 assert!(
536 report
537 .error
538 .as_deref()
539 .is_some_and(|error| error.contains("symlink"))
540 );
541 }
542 }
543
543 lines RUST