use crate::config::Config;
use crate::error::{Error, Result};
use crate::redaction::{RedactionEngine, RedactionStats};
use chrono::{DateTime, Utc};
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct HistoryEntry {
pub command: String,
pub timestamp: DateTime<Utc>,
pub directory: String,
pub redacted: bool,
pub original: Option<String>,
pub deleted: bool,
}
#[derive(Debug, Clone, Default)]
pub struct HistoryStats {
pub total_entries: usize,
pub redacted_entries: usize,
pub unique_commands: usize,
pub duplicates_filtered: usize,
pub common_directories: HashMap<String, usize>,
pub redaction_stats: RedactionStats,
}
pub struct HistoryManager {
config: Config,
redaction_engine: RedactionEngine,
history_file: PathBuf,
stats: HistoryStats,
}
impl HistoryManager {
#[must_use = "History manager must be used to log commands"]
pub fn new(config: Config) -> Result<Self> {
let redaction_engine = RedactionEngine::with_config(
config.redaction.use_builtin_patterns,
config.redaction.custom_patterns.clone(),
config.redaction.exclude_patterns.clone(),
config.redaction.placeholder.clone(),
config.redaction.min_redaction_length,
config.custom_env_vars.clone(),
config.redaction.redact_env_vars,
)?;
let history_file = config.history_file.clone();
if !history_file.exists() {
if let Some(parent) = history_file.parent() {
std::fs::create_dir_all(parent)?;
}
File::create(&history_file)?;
}
let mut manager = Self {
config,
redaction_engine,
history_file,
stats: HistoryStats::default(),
};
manager.update_stats()?;
Ok(manager)
}
pub fn log_command(&mut self, command: &str) -> Result<()> {
self.log_command_with_timestamp(command, None)
}
pub fn log_command_with_timestamp(
&mut self,
command: &str,
timestamp: Option<DateTime<Utc>>,
) -> Result<()> {
if self.config.should_exclude_command(command) {
return Ok(());
}
let timestamp = timestamp.unwrap_or_else(Utc::now);
let directory = env::current_dir()
.unwrap_or_else(|_| PathBuf::from("<unknown>"))
.to_string_lossy()
.to_string();
let (redacted_command, was_redacted) = if self.config.enable_redaction {
let original = command.to_string();
let redacted = self
.redaction_engine
.redact_with_stats(command, &mut self.stats.redaction_stats)?;
(redacted.clone(), redacted != original)
} else {
(command.to_string(), false)
};
let entry = HistoryEntry {
command: redacted_command,
timestamp,
directory,
redacted: was_redacted,
original: if was_redacted && self.config.logging.log_redacted_commands {
Some(command.to_string())
} else {
None
},
deleted: false,
};
if !self.config.shell_integration.log_duplicates && self.is_duplicate(&entry)? {
self.stats.duplicates_filtered += 1;
return Ok(());
}
self.write_entry(&entry)?;
self.update_stats_for_entry(&entry);
if self.config.max_entries > 0 && self.stats.total_entries > self.config.max_entries {
self.trim_history()?;
}
Ok(())
}
pub fn import_from_shell(&mut self, shell: &str, file_path: Option<PathBuf>) -> Result<usize> {
let history_path = if let Some(path) = file_path {
path
} else {
self.config
.import
.shell_history_paths
.get(shell)
.ok_or_else(|| Error::import_failed(shell, "shell not configured"))?
.clone()
};
if !history_path.exists() {
return Err(Error::HistoryFileNotFound { path: history_path });
}
let file = File::open(&history_path)?;
let reader = BufReader::new(file);
let mut imported_count = 0;
let mut seen_commands = HashSet::new();
for line in reader.lines() {
let line = line.unwrap_or_default();
if line.trim().is_empty() {
continue;
}
let entry = match shell {
"zsh" => self.parse_zsh_entry(&line)?,
"bash" => self.parse_bash_entry(&line)?,
"fish" => self.parse_fish_entry(&line)?,
_ => return Err(Error::import_failed(shell, "unsupported shell")),
};
if let Some(entry) = entry {
if self.config.import.max_age_days > 0 {
let age_limit =
Utc::now() - chrono::Duration::days(self.config.import.max_age_days as i64);
if entry.timestamp < age_limit {
continue;
}
}
if self.config.import.deduplicate {
let key = format!("{}:{}", entry.command, entry.directory);
if !seen_commands.insert(key) {
continue;
}
}
self.write_entry(&entry)?;
imported_count += 1;
}
}
self.update_stats()?;
Ok(imported_count)
}
#[must_use = "Query results should be used"]
pub fn get_entries(&self) -> Result<Vec<HistoryEntry>> {
let file = File::open(&self.history_file)?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
for line in reader.lines() {
let line = line?;
if let Some(entry) = self.parse_entry(&line)? {
entries.push(entry);
}
}
Ok(entries)
}
#[must_use = "Search results should be used"]
pub fn search(&self, query: &str, directory_filter: Option<&str>) -> Result<Vec<HistoryEntry>> {
let entries = self.get_entries()?;
let mut results = Vec::new();
let query_lower = query.to_lowercase();
for entry in entries {
if let Some(dir_filter) = directory_filter
&& !entry.directory.contains(dir_filter)
{
continue;
}
let matches = if self.config.search.case_sensitive {
entry.command.contains(query)
} else {
entry.command.to_lowercase().contains(&query_lower)
};
if matches {
results.push(entry);
}
if results.len() >= self.config.search.max_results {
break;
}
}
Ok(results)
}
pub fn get_unique_commands(&self) -> Result<Vec<String>> {
let entries = self.get_entries()?;
let mut seen = HashSet::new();
let mut commands = Vec::new();
for entry in entries.into_iter().rev() {
if seen.insert(entry.command.clone()) {
commands.push(entry.command);
}
}
Ok(commands)
}
pub fn get_stats(&mut self) -> Result<&HistoryStats> {
self.update_stats()?;
Ok(&self.stats)
}
pub fn clear(&mut self) -> Result<()> {
std::fs::write(&self.history_file, "")?;
self.stats = HistoryStats::default();
Ok(())
}
fn trim_history(&mut self) -> Result<()> {
let entries = self.get_entries()?;
let keep_count = self.config.max_entries;
if entries.len() <= keep_count {
return Ok(());
}
let entries_to_keep = &entries[entries.len() - keep_count..];
let file = OpenOptions::new()
.write(true)
.truncate(true)
.open(&self.history_file)?;
let mut writer = BufWriter::new(file);
for entry in entries_to_keep {
writeln!(writer, "{}", self.format_entry(entry))?;
}
writer.flush()?;
self.update_stats()?;
Ok(())
}
fn write_entry(&self, entry: &HistoryEntry) -> Result<()> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.history_file)?;
writeln!(file, "{}", self.format_entry(entry))?;
Ok(())
}
fn format_entry(&self, entry: &HistoryEntry) -> String {
let timestamp_str = entry
.timestamp
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let escaped_cmd = entry.command.replace('\\', "\\\\").replace('\n', "\\n");
let deleted_marker = if entry.deleted { " deleted=true" } else { "" };
format!(
"[{}] dir={} cmd={}{}",
timestamp_str, entry.directory, escaped_cmd, deleted_marker
)
}
fn parse_entry(&self, line: &str) -> Result<Option<HistoryEntry>> {
let line = line.trim();
if !line.starts_with('[') {
return Ok(None);
}
let close_bracket = match line.find("] ") {
Some(pos) => pos,
None => return Ok(None),
};
let timestamp_str = &line[1..close_bracket];
let rest = &line[close_bracket + 2..];
let timestamp: DateTime<Utc> =
timestamp_str.parse().map_err(|_| Error::InvalidTimestamp {
timestamp: timestamp_str.to_string(),
})?;
let dir_prefix = "dir=";
if !rest.starts_with(dir_prefix) {
return Ok(None);
}
let after_dir = &rest[dir_prefix.len()..];
let cmd_marker = " cmd=";
let cmd_pos = match after_dir.find(cmd_marker) {
Some(pos) => pos,
None => return Ok(None),
};
let directory = after_dir[..cmd_pos].to_string();
let mut remaining = after_dir[cmd_pos + cmd_marker.len()..].to_string();
let was_deleted = remaining.ends_with(" deleted=true");
if was_deleted {
remaining = remaining.trim_end_matches(" deleted=true").to_string();
}
let command = remaining.replace("\\n", "\n").replace("\\\\", "\\");
let was_redacted = command.contains("<redacted>");
Ok(Some(HistoryEntry {
command,
timestamp,
directory,
redacted: was_redacted,
deleted: was_deleted,
original: None,
}))
}
fn parse_zsh_entry(&self, line: &str) -> Result<Option<HistoryEntry>> {
let re = regex::Regex::new(r"^: (\d+):\d+;(.*)").unwrap();
if let Some(caps) = re.captures(line) {
let timestamp_str = caps.get(1).unwrap().as_str();
let command = caps.get(2).unwrap().as_str();
let timestamp = timestamp_str
.parse::<i64>()
.map_err(|_| Error::InvalidTimestamp {
timestamp: timestamp_str.to_string(),
})?;
let datetime =
DateTime::from_timestamp(timestamp, 0).ok_or_else(|| Error::InvalidTimestamp {
timestamp: timestamp_str.to_string(),
})?;
let (redacted_command, was_redacted) = if self.config.enable_redaction {
let original = command.to_string();
let redacted = self.redaction_engine.redact(command)?;
(redacted.clone(), redacted != original)
} else {
(command.to_string(), false)
};
Ok(Some(HistoryEntry {
command: redacted_command,
timestamp: datetime,
directory: "<imported>".to_string(),
redacted: was_redacted,
deleted: false,
original: None,
}))
} else {
Ok(None)
}
}
fn parse_bash_entry(&self, line: &str) -> Result<Option<HistoryEntry>> {
if line.starts_with('#') {
return Ok(None); }
let (redacted_command, was_redacted) = if self.config.enable_redaction {
let original = line.to_string();
let redacted = self.redaction_engine.redact(line)?;
(redacted.clone(), redacted != original)
} else {
(line.to_string(), false)
};
Ok(Some(HistoryEntry {
command: redacted_command,
timestamp: Utc::now(), directory: "<imported>".to_string(),
redacted: was_redacted,
deleted: false,
original: None,
}))
}
fn parse_fish_entry(&self, line: &str) -> Result<Option<HistoryEntry>> {
if let Some(command) = line.strip_prefix("- cmd: ") {
let (redacted_command, was_redacted) = if self.config.enable_redaction {
let original = command.to_string();
let redacted = self.redaction_engine.redact(command)?;
(redacted.clone(), redacted != original)
} else {
(command.to_string(), false)
};
Ok(Some(HistoryEntry {
command: redacted_command,
timestamp: Utc::now(), directory: "<imported>".to_string(),
redacted: was_redacted,
deleted: false,
original: None,
}))
} else {
Ok(None)
}
}
fn is_duplicate(&self, entry: &HistoryEntry) -> Result<bool> {
let file = File::open(&self.history_file)?;
let reader = BufReader::new(file);
let mut recent_commands = Vec::new();
let lines: Vec<String> = reader.lines().collect::<std::result::Result<Vec<_>, _>>()?;
for line in lines.iter().rev().take(100) {
if let Some(parsed_entry) = self.parse_entry(line)? {
recent_commands.push(parsed_entry.command);
}
}
Ok(recent_commands.contains(&entry.command))
}
fn update_stats(&mut self) -> Result<()> {
let entries = self.get_entries()?;
let mut unique_commands = HashSet::new();
let mut common_directories = HashMap::new();
let mut redacted_count = 0;
for entry in &entries {
unique_commands.insert(entry.command.clone());
*common_directories
.entry(entry.directory.clone())
.or_insert(0) += 1;
if entry.redacted {
redacted_count += 1;
}
}
self.stats.total_entries = entries.len();
self.stats.unique_commands = unique_commands.len();
self.stats.redacted_entries = redacted_count;
self.stats.common_directories = common_directories;
Ok(())
}
fn update_stats_for_entry(&mut self, entry: &HistoryEntry) {
self.stats.total_entries += 1;
if entry.redacted {
self.stats.redacted_entries += 1;
}
*self
.stats
.common_directories
.entry(entry.directory.clone())
.or_insert(0) += 1;
}
}
impl HistoryEntry {
pub fn new(command: String, timestamp: DateTime<Utc>, directory: String) -> Self {
Self {
command,
timestamp,
directory,
redacted: false,
original: None,
deleted: false,
}
}
pub fn display_command(&self) -> &str {
&self.command
}
pub fn formatted_timestamp(&self) -> String {
self.timestamp.format("%Y-%m-%d %H:%M:%S").to_string()
}
pub fn relative_directory(&self) -> String {
PathBuf::from(&self.directory)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
}
}
impl From<crate::database::CommandEntry> for HistoryEntry {
fn from(cmd: crate::database::CommandEntry) -> Self {
Self {
command: cmd.command,
timestamp: cmd.timestamp,
directory: cmd.directory,
redacted: cmd.redacted,
original: None,
deleted: false, }
}
}
impl crate::backend::HistoryProvider for HistoryManager {
fn get_entries(&self) -> Result<Vec<HistoryEntry>> {
self.get_entries()
}
fn get_recent(&self, count: usize) -> Result<Vec<HistoryEntry>> {
let mut entries = self.get_entries()?;
entries.reverse();
entries.truncate(count);
Ok(entries)
}
fn search(&self, query: &str) -> Result<Vec<HistoryEntry>> {
self.search(query, None)
}
fn log_command(&mut self, command: &str) -> Result<()> {
self.log_command(command)
}
fn clear(&mut self) -> Result<()> {
self.clear()
}
fn delete_entries(&mut self, indices: &[usize]) -> Result<usize> {
if indices.is_empty() {
return Ok(0);
}
let mut entries = self.get_entries()?;
let mut deleted_count = 0;
for &idx in indices {
if let Some(entry) = entries.get_mut(idx)
&& !entry.deleted
{
entry.deleted = true;
deleted_count += 1;
}
}
let file = File::create(&self.history_file)?;
let mut writer = BufWriter::new(file);
for entry in &entries {
writeln!(writer, "{}", self.format_entry(entry))?;
}
writer.flush()?;
Ok(deleted_count)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use tempfile::NamedTempFile;
fn test_config() -> Config {
let temp_file = NamedTempFile::new().unwrap();
let mut config = Config {
history_file: temp_file.path().to_path_buf(),
max_entries: 1000,
..Default::default()
};
config.shell_integration.exclude_commands.clear(); config
}
#[test]
fn test_history_manager_creation() {
let config = test_config();
let manager = HistoryManager::new(config);
assert!(manager.is_ok());
}
#[test]
fn test_log_command() {
let config = test_config();
let mut manager = HistoryManager::new(config).unwrap();
let result = manager.log_command("echo hello world");
assert!(result.is_ok());
let entries = manager.get_entries().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].command, "echo hello world");
}
#[test]
fn test_redaction_in_logging() {
let config = test_config();
let mut manager = HistoryManager::new(config).unwrap();
manager.log_command("password=secret123").unwrap();
let entries = manager.get_entries().unwrap();
assert_eq!(entries.len(), 1);
assert!(entries[0].command.contains("<redacted>"));
assert!(!entries[0].command.contains("secret123"));
}
#[test]
fn test_duplicate_filtering() {
let mut config = test_config();
config.shell_integration.log_duplicates = false;
let mut manager = HistoryManager::new(config).unwrap();
manager.log_command("echo hello").unwrap();
manager.log_command("echo hello").unwrap(); manager.log_command("echo world").unwrap();
let entries = manager.get_entries().unwrap();
assert_eq!(entries.len(), 2); }
#[test]
fn test_search() {
let config = test_config();
let mut manager = HistoryManager::new(config).unwrap();
manager.log_command("echo hello").unwrap();
manager.log_command("ls -la").unwrap();
manager.log_command("echo world").unwrap();
let results = manager.search("echo", None).unwrap();
assert_eq!(results.len(), 2);
let results = manager.search("ls", None).unwrap();
assert_eq!(results.len(), 1);
}
#[test]
fn test_zsh_entry_parsing() {
let config = test_config();
let manager = HistoryManager::new(config).unwrap();
let entry = manager
.parse_zsh_entry(": 1609786800:0;echo hello world")
.unwrap();
assert!(entry.is_some());
let entry = entry.unwrap();
assert_eq!(entry.command, "echo hello world");
}
#[test]
fn test_history_stats() {
let config = test_config();
let mut manager = HistoryManager::new(config).unwrap();
manager.log_command("echo hello").unwrap();
manager.log_command("password=secret").unwrap();
manager.log_command("ls -la").unwrap();
let stats = manager.get_stats().unwrap();
assert_eq!(stats.total_entries, 3);
assert_eq!(stats.redacted_entries, 1);
assert_eq!(stats.unique_commands, 3);
}
#[test]
fn test_clear_history() {
let config = test_config();
let mut manager = HistoryManager::new(config).unwrap();
manager.log_command("echo hello").unwrap();
manager.log_command("ls -la").unwrap();
assert_eq!(manager.get_entries().unwrap().len(), 2);
manager.clear().unwrap();
assert_eq!(manager.get_entries().unwrap().len(), 0);
}
#[test]
fn test_trim_history() {
let mut config = test_config();
config.max_entries = 2;
let mut manager = HistoryManager::new(config).unwrap();
manager.log_command("command1").unwrap();
manager.log_command("command2").unwrap();
manager.log_command("command3").unwrap();
let entries = manager.get_entries().unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].command, "command2");
assert_eq!(entries[1].command, "command3");
}
}