use std::env;
use std::fs;
use std::path::PathBuf;
use crate::error::Result;
const DEFAULT_MAX: usize = 500;
pub struct History {
entries: Vec<String>,
max: usize,
path: Option<PathBuf>,
keep_duplicates: bool,
}
impl Default for History {
fn default() -> Self {
Self::new()
}
}
impl History {
pub fn new() -> Self {
Self {
entries: Vec::new(),
max: DEFAULT_MAX,
path: None,
keep_duplicates: false,
}
}
pub fn for_app(app: &str) -> Self {
Self {
path: state_dir().map(|dir| dir.join(app).join("history")),
..Self::new()
}
}
#[must_use]
pub fn max_entries(mut self, max: usize) -> Self {
self.max = max.max(1);
self
}
#[must_use]
pub fn keep_duplicates(mut self) -> Self {
self.keep_duplicates = true;
self
}
pub fn entries(&self) -> &[String] {
&self.entries
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn add(&mut self, line: &str) {
if line.trim().is_empty() {
return;
}
if !self.keep_duplicates
&& self.entries.last().map(String::as_str) == Some(line)
{
return;
}
self.entries.push(line.to_string());
if self.entries.len() > self.max {
let overflow = self.entries.len() - self.max;
self.entries.drain(0..overflow);
}
}
pub fn load(&mut self) -> Result<()> {
let Some(path) = &self.path else {
return Ok(());
};
if !path.exists() {
return Ok(());
}
let contents = fs::read_to_string(path)?;
self.entries = contents.lines().map(str::to_string).collect();
Ok(())
}
pub fn save(&self) -> Result<()> {
let Some(path) = &self.path else {
return Ok(());
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temp = path.with_extension(format!("tmp.{}", std::process::id()));
fs::write(&temp, self.entries.join("\n"))?;
if let Err(error) = fs::rename(&temp, path) {
let _ = fs::remove_file(&temp);
return Err(error.into());
}
Ok(())
}
}
fn state_dir() -> Option<PathBuf> {
if let Some(dir) = env::var_os("XDG_STATE_HOME") {
return Some(PathBuf::from(dir));
}
#[cfg(windows)]
{
env::var_os("LOCALAPPDATA").map(PathBuf::from)
}
#[cfg(not(windows))]
{
env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_skips_blank_and_consecutive_duplicates() {
let mut history = History::new();
history.add("a");
history.add("a");
history.add(" ");
history.add("b");
assert_eq!(history.entries(), &["a", "b"]);
}
#[test]
fn add_respects_the_max_limit() {
let mut history = History::new().max_entries(2);
history.add("a");
history.add("b");
history.add("c");
assert_eq!(history.entries(), &["b", "c"]);
}
#[test]
fn save_is_atomic_and_leaves_no_temp_files() {
let dir = std::env::temp_dir()
.join(format!("sparcli_hist_test_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let mut history = History::new();
history.path = Some(dir.join("history"));
history.add("one");
history.save().unwrap();
let names: Vec<String> = fs::read_dir(&dir)
.unwrap()
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
.collect();
assert_eq!(names, vec!["history".to_string()]);
assert_eq!(fs::read_to_string(dir.join("history")).unwrap(), "one");
fs::remove_dir_all(&dir).unwrap();
}
}