use std::path::Path;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PromptHistory {
entries: Vec<String>,
}
impl PromptHistory {
pub fn new() -> Self {
PromptHistory::default()
}
pub fn load_from_file(path: &Path) -> Self {
let Ok(text) = std::fs::read_to_string(path) else {
return PromptHistory::new();
};
let entries = text
.lines()
.filter(|l| !l.is_empty())
.map(unescape_entry)
.collect();
PromptHistory { entries }
}
pub fn save_to_file(&self, path: &Path) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let text: String = self
.entries
.iter()
.map(|e| escape_entry(e))
.collect::<Vec<_>>()
.join("\n");
let _ = std::fs::write(path, text);
}
pub fn push(&mut self, entry: impl Into<String>) {
let entry = entry.into();
if entry.is_empty() {
return;
}
if self.entries.last().map(String::as_str) != Some(entry.as_str()) {
self.entries.push(entry);
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn search(&self, query: &str) -> Vec<&str> {
let q = query.to_ascii_lowercase();
self.entries
.iter()
.rev()
.filter(|e| q.is_empty() || e.to_ascii_lowercase().contains(&q))
.map(String::as_str)
.collect()
}
}
fn escape_entry(s: &str) -> String {
s.replace('\\', "\\\\").replace('\n', "\\n")
}
fn unescape_entry(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some('\\') => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_then_search_finds_substring_most_recent_first() {
let mut h = PromptHistory::new();
h.push("fix the login bug");
h.push("add a test for login");
h.push("refactor the parser");
let hits = h.search("login");
assert_eq!(hits, vec!["add a test for login", "fix the login bug"]);
}
#[test]
fn search_empty_query_returns_all_most_recent_first() {
let mut h = PromptHistory::new();
h.push("one");
h.push("two");
assert_eq!(h.search(""), vec!["two", "one"]);
}
#[test]
fn adjacent_duplicate_is_not_appended_twice() {
let mut h = PromptHistory::new();
h.push("same");
h.push("same");
assert_eq!(h.len(), 1);
}
#[test]
fn empty_push_is_ignored() {
let mut h = PromptHistory::new();
h.push("");
assert!(h.is_empty());
}
#[test]
fn round_trips_through_a_file_including_multiline_entries() {
let dir = std::env::temp_dir().join(format!(
"supercode-tui-history-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let path = dir.join("history.txt");
let mut h = PromptHistory::new();
h.push("single line");
h.push("multi\nline\nprompt");
h.push("with a \\ backslash");
h.save_to_file(&path);
let loaded = PromptHistory::load_from_file(&path);
assert_eq!(loaded, h);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_from_missing_file_is_empty_not_an_error() {
let path = std::env::temp_dir().join("supercode-tui-history-definitely-missing.txt");
let _ = std::fs::remove_file(&path);
let h = PromptHistory::load_from_file(&path);
assert!(h.is_empty());
}
}