use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tracing::warn;
const MAX_HISTORY_ENTRIES: usize = 500;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct HistoryEntry {
text: String,
count: u32,
last_used: u64,
}
#[derive(Debug)]
pub struct CommandHistory {
entries: Vec<HistoryEntry>,
nav_index: Option<usize>,
saved_input: String,
file_path: PathBuf,
}
impl CommandHistory {
pub fn new() -> Self {
let file_path = Self::default_path();
let entries = Self::load_from_file(&file_path);
Self {
entries,
nav_index: None,
saved_input: String::new(),
file_path,
}
}
pub fn with_path(file_path: PathBuf) -> Self {
let entries = Self::load_from_file(&file_path);
Self {
entries,
nav_index: None,
saved_input: String::new(),
file_path,
}
}
pub fn record(&mut self, text: &str) {
let text = text.trim();
if text.is_empty() {
return;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if let Some(entry) = self.entries.iter_mut().find(|e| e.text == text) {
entry.count += 1;
entry.last_used = now;
} else {
self.entries.insert(
0,
HistoryEntry {
text: text.to_string(),
count: 1,
last_used: now,
},
);
}
self.entries.sort_by(|a, b| b.last_used.cmp(&a.last_used));
if self.entries.len() > MAX_HISTORY_ENTRIES {
self.entries.truncate(MAX_HISTORY_ENTRIES);
}
self.nav_index = None;
self.saved_input.clear();
self.save();
}
pub fn navigate_up(&mut self, current_input: &str) -> Option<&str> {
if self.entries.is_empty() {
return None;
}
match self.nav_index {
None => {
self.saved_input = current_input.to_string();
self.nav_index = Some(0);
Some(&self.entries[0].text)
}
Some(idx) => {
let next = idx + 1;
if next < self.entries.len() {
self.nav_index = Some(next);
Some(&self.entries[next].text)
} else {
Some(&self.entries[idx].text)
}
}
}
}
pub fn navigate_down(&mut self) -> Option<&str> {
match self.nav_index {
None => None,
Some(0) => {
self.nav_index = None;
Some(&self.saved_input)
}
Some(idx) => {
let prev = idx - 1;
self.nav_index = Some(prev);
Some(&self.entries[prev].text)
}
}
}
pub fn reset_navigation(&mut self) {
self.nav_index = None;
self.saved_input.clear();
}
pub fn is_navigating(&self) -> bool {
self.nav_index.is_some()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn default_path() -> PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".opendev").join("history.json")
}
fn load_from_file(path: &PathBuf) -> Vec<HistoryEntry> {
match std::fs::read_to_string(path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => Vec::new(),
}
}
fn save(&self) {
if let Some(parent) = self.file_path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
warn!("Failed to create history directory: {}", e);
return;
}
match serde_json::to_string_pretty(&self.entries) {
Ok(json) => {
if let Err(e) = std::fs::write(&self.file_path, json) {
warn!("Failed to write history file: {}", e);
}
}
Err(e) => {
warn!("Failed to serialize history: {}", e);
}
}
}
}
impl Default for CommandHistory {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[path = "history_tests.rs"]
mod tests;