use crate::error::{Result, RustlineError};
use std::collections::VecDeque;
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
const PRIVATE: u32 = 0o600;
fn at(path: &Path, source: std::io::Error) -> RustlineError {
RustlineError::History {
path: path.to_path_buf(),
source,
}
}
#[derive(Debug, Clone)]
pub struct History {
entries: VecDeque<String>,
max_size: usize,
}
impl History {
#[must_use]
pub fn new(max_size: usize) -> Self {
History {
entries: VecDeque::new(),
max_size: max_size.max(1),
}
}
#[must_use]
pub fn max_size(&self) -> usize {
self.max_size
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn iter(&self) -> impl DoubleEndedIterator<Item = &str> + ExactSizeIterator {
self.entries.iter().map(String::as_str)
}
#[must_use]
pub fn get(&self, index: usize) -> Option<&str> {
self.entries.get(index).map(String::as_str)
}
pub fn set(&mut self, index: usize, line: &str) {
if let Some(slot) = self.entries.get_mut(index) {
slot.clear();
slot.push_str(line);
}
}
pub fn add(&mut self, line: &str) -> bool {
if line.is_empty() || self.entries.back().is_some_and(|last| last == line) {
return false;
}
self.push(line.to_string());
true
}
pub fn push(&mut self, line: String) {
self.entries.push_back(line);
while self.entries.len() > self.max_size {
self.entries.pop_front();
}
}
pub fn pop(&mut self) -> Option<String> {
self.entries.pop_back()
}
pub fn clear(&mut self) {
self.entries.clear();
}
#[must_use]
pub fn search_backward(
&self,
needle: &str,
index: usize,
limit: usize,
) -> Option<(usize, usize)> {
if needle.is_empty() {
return None;
}
let mut limit = limit;
for i in (0..=index.min(self.entries.len().checked_sub(1)?)).rev() {
let entry = &self.entries[i];
let end = limit.min(entry.len());
limit = usize::MAX;
let Some(haystack) = entry.get(..end) else {
continue;
};
if let Some(offset) = haystack.rfind(needle) {
return Some((i, offset));
}
}
None
}
pub fn load<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
let path = path.as_ref();
let file = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(at(path, e)),
};
let mut reader = BufReader::new(file);
let mut lines = Vec::new();
let mut raw = Vec::new();
loop {
raw.clear();
if reader
.read_until(b'\n', &mut raw)
.map_err(|e| at(path, e))?
== 0
{
break;
}
if raw.last() == Some(&b'\n') {
raw.pop();
}
if let Ok(line) = std::str::from_utf8(&raw) {
if !line.is_empty() {
lines.push(line.to_string());
}
}
}
if lines.len() > self.max_size {
lines.drain(..lines.len() - self.max_size);
}
self.entries.clear();
self.entries.extend(lines);
Ok(())
}
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let path = path.as_ref();
let path = &std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let temp = temp_path(path);
match self.write_to(&temp) {
Ok(()) => std::fs::rename(&temp, path).map_err(|e| at(path, e)),
Err(e) => {
let _ = std::fs::remove_file(&temp);
Err(e)
}
}
}
fn write_to(&self, temp: &Path) -> Result<()> {
let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(PRIVATE)
.open(temp)
.map_err(|e| at(temp, e))?;
file.set_permissions(std::fs::Permissions::from_mode(PRIVATE))
.map_err(|e| at(temp, e))?;
let mut out = BufWriter::new(file);
for entry in &self.entries {
out.write_all(entry.as_bytes()).map_err(|e| at(temp, e))?;
out.write_all(b"\n").map_err(|e| at(temp, e))?;
}
out.flush().map_err(|e| at(temp, e))?;
out.into_inner()
.map_err(|e| at(temp, e.into_error()))?
.sync_all()
.map_err(|e| at(temp, e))
}
}
fn temp_path(path: &Path) -> PathBuf {
let name = path.file_name().map_or_else(
|| "history".to_string(),
|n| n.to_string_lossy().into_owned(),
);
let temp = format!(".{name}.{}.tmp", std::process::id());
match path.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir.join(temp),
_ => PathBuf::from(temp),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn add_skips_empty_and_consecutive_duplicates() {
let mut h = History::new(10);
assert!(h.add("one"));
assert!(!h.add("one"));
assert!(!h.add(""));
assert!(h.add("two"));
assert!(h.add("one"));
assert_eq!(h.iter().collect::<Vec<_>>(), ["one", "two", "one"]);
}
#[test]
fn capacity_is_exactly_max_size() {
let mut h = History::new(3);
for i in 0..10 {
h.add(&format!("line{i}"));
}
assert_eq!(h.len(), 3);
assert_eq!(h.iter().collect::<Vec<_>>(), ["line7", "line8", "line9"]);
}
#[test]
fn load_keeps_the_newest_entries_and_matches_add_capacity() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hist");
std::fs::write(&path, "a\nb\nc\nd\ne\n").unwrap();
let mut h = History::new(3);
h.load(&path).unwrap();
assert_eq!(h.len(), 3);
assert_eq!(h.iter().collect::<Vec<_>>(), ["c", "d", "e"]);
}
#[test]
fn missing_file_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let mut h = History::new(10);
h.add("kept");
h.load(dir.path().join("does-not-exist")).unwrap();
assert_eq!(h.len(), 1);
}
#[test]
fn save_round_trips_and_is_private() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hist");
let mut h = History::new(10);
h.add("first");
h.add("second");
h.save(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "history must not be world readable");
let mut loaded = History::new(10);
loaded.load(&path).unwrap();
assert_eq!(loaded.iter().collect::<Vec<_>>(), ["first", "second"]);
}
#[test]
fn save_tightens_a_permissive_existing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hist");
std::fs::write(&path, "old\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let mut h = History::new(10);
h.add("new");
h.save(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "an existing history was not tightened");
let mut loaded = History::new(10);
loaded.load(&path).unwrap();
assert_eq!(loaded.iter().collect::<Vec<_>>(), ["new"]);
}
#[test]
fn save_leaves_no_temporary_behind() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hist");
let mut h = History::new(10);
h.add("only");
h.save(&path).unwrap();
let names: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(names, ["hist"], "the temporary file was not renamed away");
}
#[test]
fn save_through_a_symlink_replaces_its_target() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real");
let link = dir.path().join("link");
std::fs::write(&real, "old\n").unwrap();
std::os::unix::fs::symlink(&real, &link).unwrap();
let mut h = History::new(10);
h.add("new");
h.save(&link).unwrap();
assert!(link.is_symlink(), "the symlink was replaced by a file");
assert_eq!(std::fs::read_to_string(&real).unwrap(), "new\n");
}
#[test]
fn save_reports_the_file_it_could_not_write() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("no-such-dir").join("hist");
let err = History::new(10).save(&path).unwrap_err();
assert!(
matches!(err, RustlineError::History { .. }),
"expected a history error, got {err:?}",
);
assert!(err.to_string().contains("hist"));
}
#[test]
fn load_skips_lines_that_are_not_utf8() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hist");
std::fs::write(&path, b"good one\n\xff\xfe bad\ngood two\n").unwrap();
let mut h = History::new(10);
h.load(&path).unwrap();
assert_eq!(h.iter().collect::<Vec<_>>(), ["good one", "good two"]);
}
#[test]
fn load_reads_a_final_line_without_a_newline() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hist");
std::fs::write(&path, "a\nb").unwrap();
let mut h = History::new(10);
h.load(&path).unwrap();
assert_eq!(h.iter().collect::<Vec<_>>(), ["a", "b"]);
}
#[test]
fn search_finds_the_most_recent_match() {
let mut h = History::new(10);
h.add("git status");
h.add("cargo build");
h.add("git commit");
let (index, offset) = h.search_backward("git", 2, usize::MAX).unwrap();
assert_eq!(index, 2);
assert_eq!(offset, 0);
let (index, _) = h.search_backward("git", 1, usize::MAX).unwrap();
assert_eq!(index, 0);
assert_eq!(h.search_backward("nope", 2, usize::MAX), None);
assert_eq!(h.search_backward("", 2, usize::MAX), None);
}
#[test]
fn search_respects_the_byte_limit() {
let mut h = History::new(10);
h.add("abcabc");
assert_eq!(h.search_backward("abc", 0, 4), Some((0, 0)));
assert_eq!(h.search_backward("abc", 0, usize::MAX), Some((0, 3)));
}
#[test]
fn search_on_empty_history_is_none() {
let h = History::new(10);
assert_eq!(h.search_backward("x", 0, usize::MAX), None);
}
#[test]
fn set_replaces_in_place() {
let mut h = History::new(10);
h.add("one");
h.add("two");
h.set(0, "ONE");
assert_eq!(h.get(0), Some("ONE"));
h.set(99, "ignored");
assert_eq!(h.len(), 2);
}
}