use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
const MAX_ENTRIES: usize = 1000;
pub(crate) struct PromptHistory {
entries: Vec<String>,
cursor: Option<usize>,
draft: Option<String>,
path: Option<PathBuf>,
}
impl PromptHistory {
pub(crate) fn in_memory() -> Self {
Self {
entries: Vec::new(),
cursor: None,
draft: None,
path: None,
}
}
pub(crate) fn load() -> Self {
let Some(path) = default_history_path() else {
return Self::in_memory();
};
let mut entries = read_entries(&path);
if entries.len() > MAX_ENTRIES {
entries.drain(0..entries.len() - MAX_ENTRIES);
}
Self {
entries,
cursor: None,
draft: None,
path: Some(path),
}
}
pub(crate) fn record(&mut self, text: &str) {
self.reset_navigation();
let text = text.trim();
if text.is_empty() {
return;
}
if self.entries.last().map(String::as_str) == Some(text) {
return;
}
self.entries.push(text.to_string());
if self.entries.len() > MAX_ENTRIES {
self.entries.remove(0);
}
self.append_to_log(text);
}
pub(crate) fn reset_navigation(&mut self) {
self.cursor = None;
self.draft = None;
}
pub(crate) fn is_browsing(&self) -> bool {
self.cursor.is_some()
}
pub(crate) fn current_entry(&self) -> Option<&str> {
self.cursor.map(|i| self.entries[i].as_str())
}
pub(crate) fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub(crate) fn entry_at(&self, index: usize) -> Option<&str> {
self.entries.get(index).map(String::as_str)
}
pub(crate) fn reverse_search(&self, query: &str, start_at: usize) -> Option<(usize, String)> {
if self.entries.is_empty() {
return None;
}
let start = start_at.min(self.entries.len() - 1);
let needle = query.to_lowercase();
(0..=start)
.rev()
.find(|&i| self.entries[i].to_lowercase().contains(&needle))
.map(|i| (i, self.entries[i].clone()))
}
pub(crate) fn newest_index(&self) -> Option<usize> {
self.entries.len().checked_sub(1)
}
pub(crate) fn navigate_up(&mut self, current: &str) -> Option<String> {
match self.cursor {
None => {
let last = self.entries.len().checked_sub(1)?;
self.draft = Some(current.to_string());
self.cursor = Some(last);
Some(self.entries[last].clone())
}
Some(0) => None,
Some(i) => {
self.cursor = Some(i - 1);
Some(self.entries[i - 1].clone())
}
}
}
pub(crate) fn navigate_down(&mut self, _current: &str) -> Option<String> {
let i = self.cursor?;
if i + 1 < self.entries.len() {
self.cursor = Some(i + 1);
Some(self.entries[i + 1].clone())
} else {
self.cursor = None;
Some(self.draft.take().unwrap_or_default())
}
}
fn append_to_log(&self, text: &str) {
let Some(path) = &self.path else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let line = serde_json::json!({ "text": text }).to_string();
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(file, "{line}");
}
}
}
fn default_history_path() -> Option<PathBuf> {
dirs::data_dir().map(|p| p.join("yolop").join("prompt_history.jsonl"))
}
fn read_entries(path: &PathBuf) -> Vec<String> {
let Ok(file) = File::open(path) else {
return Vec::new();
};
let mut entries = Vec::new();
for line in BufReader::new(file).lines().map_while(Result::ok) {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(serde_json::Value::Object(obj)) = serde_json::from_str::<serde_json::Value>(line)
&& let Some(serde_json::Value::String(text)) = obj.get("text")
&& !text.is_empty()
{
entries.push(text.clone());
}
}
entries
}
#[cfg(test)]
mod tests {
use super::*;
fn seeded(entries: &[&str]) -> PromptHistory {
let mut h = PromptHistory::in_memory();
for e in entries {
h.record(e);
}
h
}
#[test]
fn record_skips_empty_and_consecutive_duplicates() {
let mut h = PromptHistory::in_memory();
h.record("hello");
h.record("hello"); h.record(" "); h.record("world");
h.record("hello"); assert_eq!(h.entries, vec!["hello", "world", "hello"]);
}
#[test]
fn record_trims_surrounding_whitespace() {
let mut h = PromptHistory::in_memory();
h.record(" spaced ");
assert_eq!(h.entries, vec!["spaced"]);
}
#[test]
fn up_walks_from_newest_to_oldest_then_stops() {
let mut h = seeded(&["one", "two", "three"]);
assert_eq!(h.navigate_up(""), Some("three".to_string()));
assert_eq!(h.navigate_up("three"), Some("two".to_string()));
assert_eq!(h.navigate_up("two"), Some("one".to_string()));
assert_eq!(h.navigate_up("one"), None);
assert_eq!(h.current_entry(), Some("one"));
}
#[test]
fn down_restores_draft_after_paging_past_newest() {
let mut h = seeded(&["one", "two"]);
assert_eq!(h.navigate_up("draft"), Some("two".to_string()));
assert_eq!(h.navigate_up("two"), Some("one".to_string()));
assert_eq!(h.navigate_down("one"), Some("two".to_string()));
assert_eq!(h.navigate_down("two"), Some("draft".to_string()));
assert!(!h.is_browsing());
}
#[test]
fn down_without_browsing_is_a_no_op() {
let mut h = seeded(&["one"]);
assert_eq!(h.navigate_down("whatever"), None);
}
#[test]
fn up_on_empty_history_does_not_begin_browsing() {
let mut h = PromptHistory::in_memory();
assert_eq!(h.navigate_up("draft"), None);
assert!(!h.is_browsing());
}
#[test]
fn record_resets_an_in_progress_browse() {
let mut h = seeded(&["one", "two"]);
assert_eq!(h.navigate_up(""), Some("two".to_string()));
assert!(h.is_browsing());
h.record("three");
assert!(!h.is_browsing());
assert_eq!(h.navigate_up(""), Some("three".to_string()));
}
#[test]
fn reverse_search_finds_newest_match_and_cycles_older() {
let h = seeded(&["deploy staging", "run tests", "deploy prod", "run lint"]);
let newest = h.newest_index().unwrap();
let (i, entry) = h.reverse_search("deploy", newest).unwrap();
assert_eq!((i, entry.as_str()), (2, "deploy prod"));
let (i, entry) = h.reverse_search("deploy", i - 1).unwrap();
assert_eq!((i, entry.as_str()), (0, "deploy staging"));
assert_eq!(h.reverse_search("prod", 0), None);
}
#[test]
fn reverse_search_is_case_insensitive_and_empty_matches_newest() {
let h = seeded(&["First", "SECOND"]);
assert_eq!(h.reverse_search("second", 1).unwrap().1, "SECOND");
assert_eq!(h.reverse_search("", 1).unwrap(), (1, "SECOND".to_string()));
}
#[test]
fn reverse_search_on_empty_history_is_none() {
let h = PromptHistory::in_memory();
assert!(h.newest_index().is_none());
assert_eq!(h.reverse_search("x", 0), None);
}
#[test]
fn persistence_round_trips_through_the_log_file() {
let dir = std::env::temp_dir().join(format!("yolop-hist-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("prompt_history.jsonl");
let mut h = PromptHistory {
entries: Vec::new(),
cursor: None,
draft: None,
path: Some(path.clone()),
};
h.record("first");
h.record("second\nwith newline");
let reloaded = read_entries(&path);
assert_eq!(reloaded, vec!["first", "second\nwith newline"]);
let _ = std::fs::remove_dir_all(&dir);
}
}