| 1 | //! Cross-session composer input history (#366). |
| 2 | //! |
| 3 | //! Persists user-typed prompts to `~/.deepseek/composer_history.txt` so |
| 4 | //! pressing Up-arrow at the composer recalls submissions from previous |
| 5 | //! sessions, not just the current one. One entry per line, oldest first, |
| 6 | //! capped at [`MAX_HISTORY_ENTRIES`] entries (older entries are pruned |
| 7 | //! at append time). |
| 8 | //! |
| 9 | //! Entries that begin with `/` (slash commands) are NOT stored — they |
| 10 | //! pollute the recall stream and the fuzzy slash-menu already covers |
| 11 | //! them. Empty / whitespace-only inputs are also skipped. |
| 12 | |
| 13 | use std::fs; |
| 14 | use std::io::{BufRead, BufReader}; |
| 15 | use std::path::{Path, PathBuf}; |
| 16 | |
| 17 | /// Hard cap on persisted history. Keeps the file small (typical entries |
| 18 | /// are < 200 chars, so 1000 entries ≈ 200 KB) and bounds startup load |
| 19 | /// time. |
| 20 | pub const MAX_HISTORY_ENTRIES: usize = 1000; |
| 21 | |
| 22 | const HISTORY_FILE_NAME: &str = "composer_history.txt"; |
| 23 | |
| 24 | fn default_history_path() -> Option<PathBuf> { |
| 25 | dirs::home_dir().map(|home| home.join(".deepseek").join(HISTORY_FILE_NAME)) |
| 26 | } |
| 27 | |
| 28 | /// Read the persisted history into memory. Returns an empty vec if the |
| 29 | /// file doesn't exist or can't be parsed — this is best-effort. |
| 30 | #[must_use] |
| 31 | pub fn load_history() -> Vec<String> { |
| 32 | let Some(path) = default_history_path() else { |
| 33 | return Vec::new(); |
| 34 | }; |
| 35 | load_history_from(&path) |
| 36 | } |
| 37 | |
| 38 | fn load_history_from(path: &Path) -> Vec<String> { |
| 39 | let Ok(file) = fs::File::open(path) else { |
| 40 | return Vec::new(); |
| 41 | }; |
| 42 | BufReader::new(file) |
| 43 | .lines() |
| 44 | .map_while(Result::ok) |
| 45 | .filter(|line| !line.trim().is_empty()) |
| 46 | .collect() |
| 47 | } |
| 48 | |
| 49 | /// Append an entry to the persisted history, pruning old entries to |
| 50 | /// stay within [`MAX_HISTORY_ENTRIES`]. Slash-commands and empty input |
| 51 | /// are skipped — those don't help recall. |
| 52 | /// |
| 53 | /// Best-effort — failures are logged via `tracing` but not propagated |
| 54 | /// because composer history is a UX nicety, not a correctness concern. |
| 55 | pub fn append_history(entry: &str) { |
| 56 | let Some(path) = default_history_path() else { |
| 57 | return; |
| 58 | }; |
| 59 | append_history_to(&path, entry); |
| 60 | } |
| 61 | |
| 62 | fn append_history_to(path: &Path, entry: &str) { |
| 63 | let trimmed = entry.trim(); |
| 64 | if trimmed.is_empty() || trimmed.starts_with('/') { |
| 65 | return; |
| 66 | } |
| 67 | if let Some(parent) = path.parent() |
| 68 | && let Err(err) = fs::create_dir_all(parent) |
| 69 | { |
| 70 | tracing::warn!( |
| 71 | "Failed to create composer history dir {}: {err}", |
| 72 | parent.display() |
| 73 | ); |
| 74 | return; |
| 75 | } |
| 76 | |
| 77 | // Read existing entries, append the new one, prune from the front |
| 78 | // until under the cap, then atomically rewrite. |
| 79 | let mut entries = load_history_from(path); |
| 80 | if entries.last().map(String::as_str) == Some(trimmed) { |
| 81 | // De-dupe consecutive duplicates — repeated submission of the |
| 82 | // same prompt shouldn't bloat the file. |
| 83 | return; |
| 84 | } |
| 85 | entries.push(trimmed.to_string()); |
| 86 | if entries.len() > MAX_HISTORY_ENTRIES { |
| 87 | let excess = entries.len() - MAX_HISTORY_ENTRIES; |
| 88 | entries.drain(0..excess); |
| 89 | } |
| 90 | |
| 91 | let payload = entries.join("\n") + "\n"; |
| 92 | if let Err(err) = crate::utils::write_atomic(path, payload.as_bytes()) { |
| 93 | tracing::warn!( |
| 94 | "Failed to persist composer history at {}: {err}", |
| 95 | path.display() |
| 96 | ); |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | #[cfg(test)] |
| 101 | mod tests { |
| 102 | use super::*; |
| 103 | |
| 104 | /// Tests use the path-injecting `*_from` / `*_to` helpers so they |
| 105 | /// don't have to mutate `HOME` (which is not honored by |
| 106 | /// `dirs::home_dir()` on Windows — it reads `USERPROFILE` / |
| 107 | /// `SHGetKnownFolderPath` instead). This makes the suite portable |
| 108 | /// across all three CI runners without per-platform env juggling. |
| 109 | fn temp_history_path() -> (tempfile::TempDir, PathBuf) { |
| 110 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 111 | let path = tmp.path().join(HISTORY_FILE_NAME); |
| 112 | (tmp, path) |
| 113 | } |
| 114 | |
| 115 | #[test] |
| 116 | fn append_and_load_round_trip() { |
| 117 | let (_tmp, path) = temp_history_path(); |
| 118 | append_history_to(&path, "first"); |
| 119 | append_history_to(&path, "second"); |
| 120 | append_history_to(&path, "third"); |
| 121 | assert_eq!(load_history_from(&path), vec!["first", "second", "third"]); |
| 122 | } |
| 123 | |
| 124 | #[test] |
| 125 | fn slash_commands_skipped() { |
| 126 | let (_tmp, path) = temp_history_path(); |
| 127 | append_history_to(&path, "/help"); |
| 128 | append_history_to(&path, "real prompt"); |
| 129 | append_history_to(&path, "/cost"); |
| 130 | assert_eq!(load_history_from(&path), vec!["real prompt"]); |
| 131 | } |
| 132 | |
| 133 | #[test] |
| 134 | fn empty_and_whitespace_skipped() { |
| 135 | let (_tmp, path) = temp_history_path(); |
| 136 | append_history_to(&path, ""); |
| 137 | append_history_to(&path, " "); |
| 138 | append_history_to(&path, "\n\t"); |
| 139 | append_history_to(&path, "real"); |
| 140 | assert_eq!(load_history_from(&path), vec!["real"]); |
| 141 | } |
| 142 | |
| 143 | #[test] |
| 144 | fn consecutive_duplicates_deduped() { |
| 145 | let (_tmp, path) = temp_history_path(); |
| 146 | append_history_to(&path, "same"); |
| 147 | append_history_to(&path, "same"); |
| 148 | append_history_to(&path, "same"); |
| 149 | append_history_to(&path, "different"); |
| 150 | append_history_to(&path, "same"); |
| 151 | assert_eq!(load_history_from(&path), vec!["same", "different", "same"]); |
| 152 | } |
| 153 | |
| 154 | #[test] |
| 155 | fn pruned_to_cap_at_append_time() { |
| 156 | let (_tmp, path) = temp_history_path(); |
| 157 | for i in 0..(MAX_HISTORY_ENTRIES + 50) { |
| 158 | append_history_to(&path, &format!("entry {i}")); |
| 159 | } |
| 160 | let history = load_history_from(&path); |
| 161 | assert_eq!(history.len(), MAX_HISTORY_ENTRIES); |
| 162 | // Newest entries survive; oldest 50 were pruned. |
| 163 | assert_eq!(history.first().map(String::as_str), Some("entry 50")); |
| 164 | assert_eq!( |
| 165 | history.last().map(String::as_str), |
| 166 | Some(format!("entry {}", MAX_HISTORY_ENTRIES + 49)).as_deref() |
| 167 | ); |
| 168 | } |
| 169 | |
| 170 | #[test] |
| 171 | fn missing_file_loads_empty() { |
| 172 | let (_tmp, path) = temp_history_path(); |
| 173 | assert!(load_history_from(&path).is_empty()); |
| 174 | } |
| 175 | } |
| 176 |