supercode_harness/tui/
history.rs1use std::path::Path;
14
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct PromptHistory {
18 entries: Vec<String>,
20}
21
22impl PromptHistory {
23 pub fn new() -> Self {
25 PromptHistory::default()
26 }
27
28 pub fn load_from_file(path: &Path) -> Self {
33 let Ok(text) = std::fs::read_to_string(path) else {
34 return PromptHistory::new();
35 };
36 let entries = text
37 .lines()
38 .filter(|l| !l.is_empty())
39 .map(unescape_entry)
40 .collect();
41 PromptHistory { entries }
42 }
43
44 pub fn save_to_file(&self, path: &Path) {
50 if let Some(parent) = path.parent() {
51 let _ = std::fs::create_dir_all(parent);
52 }
53 let text: String = self
54 .entries
55 .iter()
56 .map(|e| escape_entry(e))
57 .collect::<Vec<_>>()
58 .join("\n");
59 let _ = std::fs::write(path, text);
60 }
61
62 pub fn push(&mut self, entry: impl Into<String>) {
66 let entry = entry.into();
67 if entry.is_empty() {
68 return;
69 }
70 if self.entries.last().map(String::as_str) != Some(entry.as_str()) {
71 self.entries.push(entry);
72 }
73 }
74
75 pub fn len(&self) -> usize {
77 self.entries.len()
78 }
79
80 pub fn is_empty(&self) -> bool {
82 self.entries.is_empty()
83 }
84
85 pub fn search(&self, query: &str) -> Vec<&str> {
90 let q = query.to_ascii_lowercase();
91 self.entries
92 .iter()
93 .rev()
94 .filter(|e| q.is_empty() || e.to_ascii_lowercase().contains(&q))
95 .map(String::as_str)
96 .collect()
97 }
98}
99
100fn escape_entry(s: &str) -> String {
101 s.replace('\\', "\\\\").replace('\n', "\\n")
102}
103
104fn unescape_entry(s: &str) -> String {
105 let mut out = String::with_capacity(s.len());
106 let mut chars = s.chars();
107 while let Some(c) = chars.next() {
108 if c == '\\' {
109 match chars.next() {
110 Some('n') => out.push('\n'),
111 Some('\\') => out.push('\\'),
112 Some(other) => {
113 out.push('\\');
114 out.push(other);
115 }
116 None => out.push('\\'),
117 }
118 } else {
119 out.push(c);
120 }
121 }
122 out
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn push_then_search_finds_substring_most_recent_first() {
131 let mut h = PromptHistory::new();
132 h.push("fix the login bug");
133 h.push("add a test for login");
134 h.push("refactor the parser");
135 let hits = h.search("login");
136 assert_eq!(hits, vec!["add a test for login", "fix the login bug"]);
137 }
138
139 #[test]
140 fn search_empty_query_returns_all_most_recent_first() {
141 let mut h = PromptHistory::new();
142 h.push("one");
143 h.push("two");
144 assert_eq!(h.search(""), vec!["two", "one"]);
145 }
146
147 #[test]
148 fn adjacent_duplicate_is_not_appended_twice() {
149 let mut h = PromptHistory::new();
150 h.push("same");
151 h.push("same");
152 assert_eq!(h.len(), 1);
153 }
154
155 #[test]
156 fn empty_push_is_ignored() {
157 let mut h = PromptHistory::new();
158 h.push("");
159 assert!(h.is_empty());
160 }
161
162 #[test]
163 fn round_trips_through_a_file_including_multiline_entries() {
164 let dir = std::env::temp_dir().join(format!(
165 "supercode-tui-history-test-{}-{}",
166 std::process::id(),
167 std::time::SystemTime::now()
168 .duration_since(std::time::UNIX_EPOCH)
169 .unwrap()
170 .as_nanos()
171 ));
172 let path = dir.join("history.txt");
173 let mut h = PromptHistory::new();
174 h.push("single line");
175 h.push("multi\nline\nprompt");
176 h.push("with a \\ backslash");
177 h.save_to_file(&path);
178 let loaded = PromptHistory::load_from_file(&path);
179 assert_eq!(loaded, h);
180 let _ = std::fs::remove_dir_all(&dir);
181 }
182
183 #[test]
184 fn load_from_missing_file_is_empty_not_an_error() {
185 let path = std::env::temp_dir().join("supercode-tui-history-definitely-missing.txt");
186 let _ = std::fs::remove_file(&path);
187 let h = PromptHistory::load_from_file(&path);
188 assert!(h.is_empty());
189 }
190}