use super::{ReplError, ReplResult};
use std::collections::VecDeque;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct HistoryEntry {
pub command: String,
pub timestamp: std::time::SystemTime,
pub duration: Option<std::time::Duration>,
pub success: Option<bool>,
pub category: HistoryCategory,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum HistoryCategory {
Query, Meta, Config, Navigation, System, Unknown,
}
impl std::fmt::Display for HistoryCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HistoryCategory::Query => write!(f, "query"),
HistoryCategory::Meta => write!(f, "meta"),
HistoryCategory::Config => write!(f, "config"),
HistoryCategory::Navigation => write!(f, "navigation"),
HistoryCategory::System => write!(f, "system"),
HistoryCategory::Unknown => write!(f, "unknown"),
}
}
}
#[derive(Debug, Clone)]
pub struct HistoryFilter {
pub pattern: Option<String>,
pub category: Option<HistoryCategory>,
pub success_only: bool,
pub since: Option<std::time::SystemTime>,
pub limit: Option<usize>,
}
impl Default for HistoryFilter {
fn default() -> Self {
Self {
pattern: None,
category: None,
success_only: false,
since: None,
limit: Some(50),
}
}
}
pub struct HistoryManager {
history: VecDeque<HistoryEntry>,
max_size: usize,
current_position: Option<usize>,
history_file: Option<PathBuf>,
persistent: bool,
stats: HistoryStats,
}
#[derive(Debug, Default)]
pub struct HistoryStats {
pub total_commands: u64,
pub successful_commands: u64,
pub failed_commands: u64,
pub by_category: std::collections::HashMap<HistoryCategory, u64>,
pub avg_duration_ms: f64,
}
impl HistoryManager {
pub fn new(max_size: usize) -> ReplResult<Self> {
Ok(Self {
history: VecDeque::with_capacity(max_size),
max_size,
current_position: None,
history_file: None,
persistent: false,
stats: HistoryStats::default(),
})
}
pub fn new_persistent(max_size: usize, history_dir: &Path) -> ReplResult<Self> {
let history_file = history_dir.join("cqlite_history.txt");
if let Some(parent) = history_file.parent() {
fs::create_dir_all(parent).map_err(ReplError::Io)?;
}
let mut manager = Self {
history: VecDeque::with_capacity(max_size),
max_size,
current_position: None,
history_file: Some(history_file),
persistent: true,
stats: HistoryStats::default(),
};
manager.load_history()?;
Ok(manager)
}
pub fn add_command(&mut self, command: &str) -> ReplResult<()> {
if command.trim().is_empty() {
return Ok(());
}
if let Some(last_entry) = self.history.back() {
if last_entry.command.trim() == command.trim() {
return Ok(());
}
}
let entry = HistoryEntry {
command: command.to_string(),
timestamp: std::time::SystemTime::now(),
duration: None,
success: None,
category: self.categorize_command(command),
};
self.add_entry(entry)?;
Ok(())
}
pub fn add_command_with_result(
&mut self,
command: &str,
duration: std::time::Duration,
success: bool,
) -> ReplResult<()> {
if command.trim().is_empty() {
return Ok(());
}
let entry = HistoryEntry {
command: command.to_string(),
timestamp: std::time::SystemTime::now(),
duration: Some(duration),
success: Some(success),
category: self.categorize_command(command),
};
self.add_entry(entry.clone())?;
self.update_stats(&entry);
Ok(())
}
fn add_entry(&mut self, entry: HistoryEntry) -> ReplResult<()> {
while self.history.len() >= self.max_size {
self.history.pop_front();
}
self.history.push_back(entry.clone());
self.current_position = None;
if self.persistent {
self.persist_entry(&entry)?;
}
Ok(())
}
fn categorize_command(&self, command: &str) -> HistoryCategory {
let trimmed = command.trim();
if trimmed.starts_with(':') || trimmed.starts_with('.') || trimmed.starts_with('\\') {
if trimmed.contains("config") || trimmed.contains("set") {
HistoryCategory::Config
} else if trimmed.contains("use")
|| trimmed.contains("tables")
|| trimmed.contains("keyspaces")
|| trimmed.contains("describe")
{
HistoryCategory::Navigation
} else if trimmed.contains("clear")
|| trimmed.contains("history")
|| trimmed.contains("source")
{
HistoryCategory::System
} else {
HistoryCategory::Meta
}
} else {
let upper = trimmed.to_uppercase();
if upper.starts_with("SELECT")
|| upper.starts_with("INSERT")
|| upper.starts_with("UPDATE")
|| upper.starts_with("DELETE")
|| upper.starts_with("CREATE")
|| upper.starts_with("ALTER")
|| upper.starts_with("DROP")
{
HistoryCategory::Query
} else {
HistoryCategory::Unknown
}
}
}
pub fn recent_commands(&self, limit: usize) -> Vec<String> {
self.history
.iter()
.rev()
.take(limit)
.map(|entry| entry.command.clone())
.collect()
}
pub fn search(&self, filter: &HistoryFilter) -> Vec<&HistoryEntry> {
let mut results: Vec<&HistoryEntry> = self
.history
.iter()
.filter(|entry| self.matches_filter(entry, filter))
.collect();
results.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
if let Some(limit) = filter.limit {
results.truncate(limit);
}
results
}
fn matches_filter(&self, entry: &HistoryEntry, filter: &HistoryFilter) -> bool {
if let Some(ref pattern) = filter.pattern {
if !entry
.command
.to_lowercase()
.contains(&pattern.to_lowercase())
{
return false;
}
}
if let Some(ref category) = filter.category {
if entry.category != *category {
return false;
}
}
if filter.success_only {
if let Some(success) = entry.success {
if !success {
return false;
}
} else {
return false; }
}
if let Some(since) = filter.since {
if entry.timestamp < since {
return false;
}
}
true
}
pub fn previous(&mut self) -> Option<String> {
if self.history.is_empty() {
return None;
}
let new_position = match self.current_position {
None => self.history.len() - 1,
Some(pos) => {
if pos > 0 {
pos - 1
} else {
return None; }
}
};
self.current_position = Some(new_position);
Some(self.history[new_position].command.clone())
}
pub fn next(&mut self) -> Option<String> {
if let Some(pos) = self.current_position {
if pos < self.history.len() - 1 {
self.current_position = Some(pos + 1);
Some(self.history[pos + 1].command.clone())
} else {
self.current_position = None;
None }
} else {
None
}
}
pub fn reset_position(&mut self) {
self.current_position = None;
}
pub fn stats(&self) -> &HistoryStats {
&self.stats
}
fn update_stats(&mut self, entry: &HistoryEntry) {
self.stats.total_commands += 1;
if let Some(success) = entry.success {
if success {
self.stats.successful_commands += 1;
} else {
self.stats.failed_commands += 1;
}
}
*self
.stats
.by_category
.entry(entry.category.clone())
.or_insert(0) += 1;
if let Some(duration) = entry.duration {
let duration_ms = duration.as_millis() as f64;
let total_duration =
self.stats.avg_duration_ms * (self.stats.total_commands - 1) as f64;
self.stats.avg_duration_ms =
(total_duration + duration_ms) / self.stats.total_commands as f64;
}
}
fn load_history(&mut self) -> ReplResult<()> {
if let Some(ref path) = self.history_file {
if path.exists() {
let content = fs::read_to_string(path).map_err(ReplError::Io)?;
for line in content.lines() {
if !line.trim().is_empty() {
if let Some(command) = self.parse_history_line(line) {
let entry = HistoryEntry {
command,
timestamp: std::time::SystemTime::now(),
duration: None,
success: None,
category: self.categorize_command(&line),
};
if self.history.len() >= self.max_size {
self.history.pop_front();
}
self.history.push_back(entry);
}
}
}
}
}
Ok(())
}
fn parse_history_line(&self, line: &str) -> Option<String> {
if line.trim().is_empty() {
None
} else {
Some(line.trim().to_string())
}
}
fn persist_entry(&self, entry: &HistoryEntry) -> ReplResult<()> {
if let Some(ref path) = self.history_file {
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(ReplError::Io)?;
writeln!(file, "{}", entry.command).map_err(ReplError::Io)?;
}
Ok(())
}
pub fn save_history(&self) -> ReplResult<()> {
if let Some(ref path) = self.history_file {
let mut file = fs::File::create(path).map_err(ReplError::Io)?;
for entry in &self.history {
writeln!(file, "{}", entry.command).map_err(ReplError::Io)?;
}
}
Ok(())
}
pub fn clear(&mut self) -> ReplResult<()> {
self.history.clear();
self.current_position = None;
self.stats = HistoryStats::default();
if let Some(ref path) = self.history_file {
if path.exists() {
fs::remove_file(path).map_err(ReplError::Io)?;
}
}
Ok(())
}
pub fn len(&self) -> usize {
self.history.len()
}
pub fn is_empty(&self) -> bool {
self.history.is_empty()
}
pub fn export_text(&self) -> String {
let mut output = String::new();
output.push_str(&format!(
"# CQLite Command History ({})\n",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S")
));
output.push_str(&format!("# Total commands: {}\n", self.history.len()));
output.push_str("# Format: command\n\n");
for entry in &self.history {
output.push_str(&entry.command);
output.push('\n');
}
output
}
pub fn export_detailed(&self) -> String {
let mut output = String::new();
output.push_str(&format!(
"# CQLite Detailed Command History ({})\n",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S")
));
output.push_str(&format!(
"# Total commands: {}\n",
self.stats.total_commands
));
output.push_str(&format!(
"# Successful: {}\n",
self.stats.successful_commands
));
output.push_str(&format!("# Failed: {}\n", self.stats.failed_commands));
output.push_str(&format!(
"# Average duration: {:.2}ms\n",
self.stats.avg_duration_ms
));
output.push_str("# Format: timestamp | category | duration | success | command\n\n");
for entry in &self.history {
let timestamp = entry
.timestamp
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let duration_str = entry
.duration
.map(|d| format!("{:.2}ms", d.as_millis()))
.unwrap_or_else(|| "N/A".to_string());
let success_str = entry
.success
.map(|s| if s { "OK" } else { "ERR" })
.unwrap_or("N/A");
output.push_str(&format!(
"{} | {} | {} | {} | {}\n",
timestamp, entry.category, duration_str, success_str, entry.command
));
}
output
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_basic_history() {
let mut history = HistoryManager::new(10).unwrap();
history.add_command("SELECT * FROM users").unwrap();
history.add_command("SELECT count(*) FROM orders").unwrap();
assert_eq!(history.len(), 2);
let recent = history.recent_commands(5);
assert_eq!(recent.len(), 2);
assert_eq!(recent[0], "SELECT count(*) FROM orders");
assert_eq!(recent[1], "SELECT * FROM users");
}
#[test]
fn test_navigation() {
let mut history = HistoryManager::new(10).unwrap();
history.add_command("command1").unwrap();
history.add_command("command2").unwrap();
history.add_command("command3").unwrap();
assert_eq!(history.previous(), Some("command3".to_string()));
assert_eq!(history.previous(), Some("command2".to_string()));
assert_eq!(history.previous(), Some("command1".to_string()));
assert_eq!(history.previous(), None);
assert_eq!(history.next(), Some("command2".to_string()));
assert_eq!(history.next(), Some("command3".to_string()));
assert_eq!(history.next(), None); }
#[test]
fn test_search_filter() {
let mut history = HistoryManager::new(10).unwrap();
history.add_command("SELECT * FROM users").unwrap();
history.add_command(":tables").unwrap();
history.add_command("SELECT * FROM orders").unwrap();
let filter = HistoryFilter {
pattern: Some("SELECT".to_string()),
..Default::default()
};
let results = history.search(&filter);
assert_eq!(results.len(), 2);
assert!(results.iter().all(|r| r.command.contains("SELECT")));
}
#[test]
fn test_categorization() {
let history = HistoryManager::new(10).unwrap();
assert_eq!(
history.categorize_command("SELECT * FROM users"),
HistoryCategory::Query
);
assert_eq!(history.categorize_command(":help"), HistoryCategory::Meta);
assert_eq!(
history.categorize_command(":config show"),
HistoryCategory::Config
);
assert_eq!(
history.categorize_command(":tables"),
HistoryCategory::Navigation
);
assert_eq!(
history.categorize_command(":clear"),
HistoryCategory::System
);
}
#[test]
fn test_persistent_history() {
let temp_dir = tempdir().unwrap();
let temp_path = temp_dir.path();
{
let mut history = HistoryManager::new_persistent(10, temp_path).unwrap();
history.add_command("SELECT 1").unwrap();
history.add_command("SELECT 2").unwrap();
}
let history = HistoryManager::new_persistent(10, temp_path).unwrap();
assert_eq!(history.len(), 2);
let recent = history.recent_commands(5);
assert!(recent.contains(&"SELECT 1".to_string()));
assert!(recent.contains(&"SELECT 2".to_string()));
}
}