use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
const MAX_HISTORY_SIZE: usize = 1000;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
pub timestamp: DateTime<Utc>,
pub command: String,
pub exit_code: i32,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct History {
entries: Vec<HistoryEntry>,
}
impl History {
pub fn history_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.context("Failed to determine config directory")?
.join("mielin");
fs::create_dir_all(&config_dir).context("Failed to create config directory")?;
Ok(config_dir.join("history"))
}
pub fn load() -> Result<Self> {
let history_path = Self::history_path()?;
if history_path.exists() {
let contents =
fs::read_to_string(&history_path).context("Failed to read history file")?;
let history: History =
serde_json::from_str(&contents).context("Failed to parse history file")?;
Ok(history)
} else {
Ok(Self::default())
}
}
pub fn save(&self) -> Result<()> {
let history_path = Self::history_path()?;
let contents = serde_json::to_string_pretty(self).context("Failed to serialize history")?;
fs::write(&history_path, contents).context("Failed to write history file")?;
Ok(())
}
pub fn add(&mut self, command: String, exit_code: i32, duration_ms: u64) {
let entry = HistoryEntry {
timestamp: Utc::now(),
command,
exit_code,
duration_ms,
};
self.entries.push(entry);
if self.entries.len() > MAX_HISTORY_SIZE {
let excess = self.entries.len() - MAX_HISTORY_SIZE;
self.entries.drain(0..excess);
}
}
pub fn entries(&self) -> &[HistoryEntry] {
&self.entries
}
pub fn last(&self, n: usize) -> &[HistoryEntry] {
let start = self.entries.len().saturating_sub(n);
&self.entries[start..]
}
pub fn search(&self, pattern: &str) -> Vec<&HistoryEntry> {
self.entries
.iter()
.filter(|entry| entry.command.contains(pattern))
.collect()
}
pub fn clear(&mut self) {
self.entries.clear();
}
pub fn stats(&self) -> HistoryStats {
let total_commands = self.entries.len();
let successful_commands = self.entries.iter().filter(|e| e.exit_code == 0).count();
let failed_commands = total_commands - successful_commands;
let avg_duration_ms = if total_commands > 0 {
self.entries.iter().map(|e| e.duration_ms).sum::<u64>() / total_commands as u64
} else {
0
};
let most_used_commands = self.get_most_used_commands(5);
HistoryStats {
total_commands,
successful_commands,
failed_commands,
avg_duration_ms,
most_used_commands,
}
}
fn get_most_used_commands(&self, limit: usize) -> Vec<(String, usize)> {
use std::collections::HashMap;
let mut command_counts = HashMap::new();
for entry in &self.entries {
let main_command = entry.command.split_whitespace().next().unwrap_or("");
*command_counts.entry(main_command.to_string()).or_insert(0) += 1;
}
let mut counts: Vec<_> = command_counts.into_iter().collect();
counts.sort_by(|a, b| b.1.cmp(&a.1));
counts.truncate(limit);
counts
}
pub fn export(&self, path: &PathBuf) -> Result<()> {
let contents =
serde_json::to_string_pretty(&self.entries).context("Failed to serialize history")?;
fs::write(path, contents).context("Failed to write export file")?;
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryStats {
pub total_commands: usize,
pub successful_commands: usize,
pub failed_commands: usize,
pub avg_duration_ms: u64,
pub most_used_commands: Vec<(String, usize)>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_history_default() {
let history = History::default();
assert_eq!(history.entries().len(), 0);
}
#[test]
fn test_add_entry() {
let mut history = History::default();
history.add("node list".to_string(), 0, 100);
assert_eq!(history.entries().len(), 1);
assert_eq!(history.entries()[0].command, "node list");
assert_eq!(history.entries()[0].exit_code, 0);
assert_eq!(history.entries()[0].duration_ms, 100);
}
#[test]
fn test_max_history_size() {
let mut history = History::default();
for i in 0..(MAX_HISTORY_SIZE + 100) {
history.add(format!("command {}", i), 0, 100);
}
assert_eq!(history.entries().len(), MAX_HISTORY_SIZE);
assert_eq!(history.entries()[0].command, "command 100");
}
#[test]
fn test_last_n_entries() {
let mut history = History::default();
history.add("cmd1".to_string(), 0, 100);
history.add("cmd2".to_string(), 0, 100);
history.add("cmd3".to_string(), 0, 100);
let last_two = history.last(2);
assert_eq!(last_two.len(), 2);
assert_eq!(last_two[0].command, "cmd2");
assert_eq!(last_two[1].command, "cmd3");
}
#[test]
fn test_search() {
let mut history = History::default();
history.add("node list".to_string(), 0, 100);
history.add("agent list".to_string(), 0, 100);
history.add("node info".to_string(), 0, 100);
let results = history.search("node");
assert_eq!(results.len(), 2);
assert!(results.iter().any(|e| e.command == "node list"));
assert!(results.iter().any(|e| e.command == "node info"));
}
#[test]
fn test_clear() {
let mut history = History::default();
history.add("cmd1".to_string(), 0, 100);
history.add("cmd2".to_string(), 0, 100);
assert_eq!(history.entries().len(), 2);
history.clear();
assert_eq!(history.entries().len(), 0);
}
#[test]
fn test_stats() {
let mut history = History::default();
history.add("node list".to_string(), 0, 100);
history.add("node list".to_string(), 0, 200);
history.add("agent list".to_string(), 1, 150);
let stats = history.stats();
assert_eq!(stats.total_commands, 3);
assert_eq!(stats.successful_commands, 2);
assert_eq!(stats.failed_commands, 1);
assert_eq!(stats.avg_duration_ms, 150);
assert_eq!(stats.most_used_commands[0].0, "node");
assert_eq!(stats.most_used_commands[0].1, 2);
}
#[test]
fn test_serialize_deserialize() {
let mut history = History::default();
history.add("test command".to_string(), 0, 100);
let json = serde_json::to_string(&history).unwrap();
let deserialized: History = serde_json::from_str(&json).unwrap();
assert_eq!(history.entries().len(), deserialized.entries().len());
assert_eq!(
history.entries()[0].command,
deserialized.entries()[0].command
);
}
}