use std::sync::Arc;
use std::time::Duration;
use chrono::{DateTime, Utc};
use regex::Regex;
use crate::persistence::index::UserMetadataIndex;
use crate::persistence::types::{HistoryEntry, StorageScope};
static SECRET_PATTERNS: std::sync::LazyLock<Vec<Regex>> = std::sync::LazyLock::new(|| {
vec![
Regex::new(r#"(?i)(api[_-]?key|api[_-]?token|access[_-]?token|auth[_-]?token|bearer)\s*[=:]\s*['"]?[a-zA-Z0-9_-]{16,}['"]?"#).unwrap(),
Regex::new(r"(?i)AKIA[0-9A-Z]{16}").unwrap(),
Regex::new(r#"(?i)(aws[_-]?secret|secret[_-]?access[_-]?key)\s*[=:]\s*['"]?[a-zA-Z0-9/+=]{40}['"]?"#).unwrap(),
Regex::new(r#"(?i)(password|passwd|pwd|secret|private[_-]?key)\s*[=:]\s*['"]?[^\s'"]{8,}['"]?"#).unwrap(),
Regex::new(r"eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*").unwrap(),
Regex::new(r"gh[pousr]_[A-Za-z0-9_]{36,}").unwrap(),
Regex::new(r"github_pat_[A-Za-z0-9_]{22,}").unwrap(),
Regex::new(r"xox[baprs]-[0-9]+-[0-9]+-[a-zA-Z0-9]+").unwrap(),
Regex::new(r"[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27}").unwrap(),
Regex::new(r"sk-[a-zA-Z0-9]{20,}").unwrap(),
Regex::new(r"sk-ant-[a-zA-Z0-9]{20,}").unwrap(),
Regex::new(r#""private_key"\s*:\s*"-----BEGIN"#).unwrap(),
Regex::new(r"npm_[a-zA-Z0-9]{36}").unwrap(),
Regex::new(r"sk_live_[a-zA-Z0-9]{24,}").unwrap(),
Regex::new(r"sk_test_[a-zA-Z0-9]{24,}").unwrap(),
Regex::new(r"SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}").unwrap(),
Regex::new(r"SK[a-f0-9]{32}").unwrap(),
]
});
pub const REDACTED_PLACEHOLDER: &str = "[REDACTED]";
#[derive(Debug)]
pub enum HistoryError {
Disabled,
NotFound { id: u64 },
Storage(anyhow::Error),
}
impl std::fmt::Display for HistoryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Disabled => write!(f, "history recording is disabled"),
Self::NotFound { id } => write!(f, "history entry {id} not found"),
Self::Storage(e) => write!(f, "storage error: {e}"),
}
}
}
impl std::error::Error for HistoryError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Storage(e) => e.source(),
_ => None,
}
}
}
impl From<anyhow::Error> for HistoryError {
fn from(e: anyhow::Error) -> Self {
Self::Storage(e)
}
}
#[derive(Debug, Clone)]
pub struct HistoryManager {
index: Arc<UserMetadataIndex>,
}
impl HistoryManager {
#[must_use]
pub fn new(index: Arc<UserMetadataIndex>) -> Self {
Self { index }
}
pub fn record(
&self,
command: &str,
args: &[String],
working_dir: &std::path::Path,
success: bool,
duration: Option<Duration>,
) -> Result<u64, HistoryError> {
let config = self.index.config();
if !config.history_enabled {
return Err(HistoryError::Disabled);
}
let processed_args = if config.redact_secrets {
redact_secrets(args)
} else {
args.to_vec()
};
let mut entry_id = 0u64;
self.index.update(StorageScope::Global, |metadata| {
entry_id = metadata.history.next_id;
metadata.history.next_id += 1;
let entry = HistoryEntry {
id: entry_id,
timestamp: Utc::now(),
command: command.to_string(),
args: processed_args.clone(),
working_dir: working_dir.to_path_buf(),
success,
duration_ms: duration.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)),
};
metadata.history.entries.push(entry);
let max_entries = config.max_history_entries;
if metadata.history.entries.len() > max_entries {
let excess = metadata.history.entries.len() - max_entries;
metadata.history.entries.drain(0..excess);
}
Ok(())
})?;
Ok(entry_id)
}
pub fn get(&self, id: u64) -> Result<HistoryEntry, HistoryError> {
let metadata = self.index.load(StorageScope::Global)?;
metadata
.history
.entries
.iter()
.find(|e| e.id == id)
.cloned()
.ok_or(HistoryError::NotFound { id })
}
pub fn last(&self) -> Result<Option<HistoryEntry>, HistoryError> {
let metadata = self.index.load(StorageScope::Global)?;
Ok(metadata.history.entries.last().cloned())
}
pub fn list(&self, limit: usize) -> Result<Vec<HistoryEntry>, HistoryError> {
let metadata = self.index.load(StorageScope::Global)?;
let entries: Vec<_> = metadata
.history
.entries
.iter()
.rev()
.take(limit)
.cloned()
.collect();
Ok(entries)
}
pub fn search(&self, pattern: &str, limit: usize) -> Result<Vec<HistoryEntry>, HistoryError> {
let metadata = self.index.load(StorageScope::Global)?;
let pattern_lower = pattern.to_lowercase();
let entries: Vec<_> = metadata
.history
.entries
.iter()
.rev()
.filter(|e| {
e.command.to_lowercase().contains(&pattern_lower)
|| e.args
.iter()
.any(|a| a.to_lowercase().contains(&pattern_lower))
})
.take(limit)
.cloned()
.collect();
Ok(entries)
}
pub fn clear(&self) -> Result<usize, HistoryError> {
let mut count = 0;
self.index.update(StorageScope::Global, |metadata| {
count = metadata.history.entries.len();
metadata.history.entries.clear();
Ok(())
})?;
Ok(count)
}
pub fn clear_older_than_duration(&self, older_than: Duration) -> Result<usize, HistoryError> {
let cutoff = Utc::now() - chrono::Duration::from_std(older_than).unwrap_or_default();
self.clear_older_than(cutoff)
}
pub fn clear_older_than(&self, cutoff: DateTime<Utc>) -> Result<usize, HistoryError> {
let mut count = 0;
self.index.update(StorageScope::Global, |metadata| {
let before_len = metadata.history.entries.len();
metadata.history.entries.retain(|e| e.timestamp >= cutoff);
count = before_len - metadata.history.entries.len();
Ok(())
})?;
Ok(count)
}
pub fn count(&self) -> Result<usize, HistoryError> {
let metadata = self.index.load(StorageScope::Global)?;
Ok(metadata.history.entries.len())
}
pub fn for_directory(
&self,
dir: &std::path::Path,
limit: usize,
) -> Result<Vec<HistoryEntry>, HistoryError> {
let metadata = self.index.load(StorageScope::Global)?;
let entries: Vec<_> = metadata
.history
.entries
.iter()
.rev()
.filter(|e| e.working_dir == dir)
.take(limit)
.cloned()
.collect();
Ok(entries)
}
pub fn at_offset(&self, offset: usize) -> Result<HistoryEntry, HistoryError> {
if offset == 0 {
return Err(HistoryError::NotFound { id: 0 });
}
let metadata = self.index.load(StorageScope::Global)?;
let entries = &metadata.history.entries;
if offset > entries.len() {
return Err(HistoryError::NotFound { id: offset as u64 });
}
Ok(entries[entries.len() - offset].clone())
}
#[must_use]
pub fn is_enabled(&self) -> bool {
self.index.config().history_enabled
}
pub fn stats(&self) -> Result<HistoryStats, HistoryError> {
let metadata = self.index.load(StorageScope::Global)?;
let entries = &metadata.history.entries;
let total_entries = entries.len();
let successful = entries.iter().filter(|e| e.success).count();
let failed = total_entries - successful;
let oldest = entries.first().map(|e| e.timestamp);
let newest = entries.last().map(|e| e.timestamp);
let mut command_counts = std::collections::HashMap::new();
for entry in entries {
*command_counts.entry(entry.command.clone()).or_insert(0) += 1;
}
Ok(HistoryStats {
total_entries,
success_count: successful,
failure_count: failed,
oldest_entry: oldest,
newest_entry: newest,
command_counts,
})
}
}
#[derive(Debug, Clone)]
pub struct HistoryStats {
pub total_entries: usize,
pub success_count: usize,
pub failure_count: usize,
pub oldest_entry: Option<DateTime<Utc>>,
pub newest_entry: Option<DateTime<Utc>>,
pub command_counts: std::collections::HashMap<String, usize>,
}
#[must_use]
pub fn redact_secrets(args: &[String]) -> Vec<String> {
args.iter()
.map(|arg| {
let mut result = arg.clone();
for pattern in SECRET_PATTERNS.iter() {
if pattern.is_match(&result) {
result = pattern
.replace_all(&result, REDACTED_PLACEHOLDER)
.to_string();
}
}
result
})
.collect()
}
#[must_use]
pub fn contains_secrets(text: &str) -> bool {
SECRET_PATTERNS.iter().any(|p| p.is_match(text))
}
pub fn parse_duration(s: &str) -> Result<Duration, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty duration string".to_string());
}
let (num_str, unit) = s.split_at(s.len() - 1);
let num: u64 = num_str
.parse()
.map_err(|_| format!("invalid number in duration: {num_str}"))?;
let seconds = match unit.to_lowercase().as_str() {
"s" => num,
"m" => num * 60,
"h" => num * 60 * 60,
"d" => num * 60 * 60 * 24,
"w" => num * 60 * 60 * 24 * 7,
_ => return Err(format!("unknown duration unit: {unit}")),
};
Ok(Duration::from_secs(seconds))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::persistence::config::PersistenceConfig;
use tempfile::TempDir;
fn setup() -> (TempDir, Arc<UserMetadataIndex>) {
let dir = TempDir::new().unwrap();
let config = PersistenceConfig {
global_dir_override: Some(dir.path().join("global")),
history_enabled: true,
max_history_entries: 100,
..Default::default()
};
let index = Arc::new(UserMetadataIndex::open(Some(dir.path()), config).unwrap());
(dir, index)
}
#[test]
fn test_record_and_get() {
let (_dir, index) = setup();
let manager = HistoryManager::new(index);
let id = manager
.record(
"search",
&["main".to_string()],
std::path::Path::new("/project"),
true,
Some(Duration::from_millis(100)),
)
.unwrap();
let entry = manager.get(id).unwrap();
assert_eq!(entry.command, "search");
assert_eq!(entry.args, vec!["main"]);
assert!(entry.success);
assert_eq!(entry.duration_ms, Some(100));
}
#[test]
fn test_list_recent() {
let (_dir, index) = setup();
let manager = HistoryManager::new(index);
for i in 0..5 {
manager
.record(
"query",
&[format!("arg{i}")],
std::path::Path::new("/project"),
true,
None,
)
.unwrap();
}
let recent = manager.list(3).unwrap();
assert_eq!(recent.len(), 3);
assert_eq!(recent[0].args, vec!["arg4"]);
assert_eq!(recent[1].args, vec!["arg3"]);
assert_eq!(recent[2].args, vec!["arg2"]);
}
#[test]
fn test_search_history() {
let (_dir, index) = setup();
let manager = HistoryManager::new(index);
manager
.record(
"search",
&["function".to_string()],
std::path::Path::new("/p"),
true,
None,
)
.unwrap();
manager
.record(
"query",
&["class".to_string()],
std::path::Path::new("/p"),
true,
None,
)
.unwrap();
manager
.record(
"search",
&["method".to_string()],
std::path::Path::new("/p"),
true,
None,
)
.unwrap();
let results = manager.search("search", 10).unwrap();
assert_eq!(results.len(), 2);
let results = manager.search("class", 10).unwrap();
assert_eq!(results.len(), 1);
}
#[test]
fn test_clear_history() {
let (_dir, index) = setup();
let manager = HistoryManager::new(index);
for _ in 0..3 {
manager
.record("cmd", &[], std::path::Path::new("/p"), true, None)
.unwrap();
}
assert_eq!(manager.count().unwrap(), 3);
let cleared = manager.clear().unwrap();
assert_eq!(cleared, 3);
assert_eq!(manager.count().unwrap(), 0);
}
#[test]
fn test_at_offset() {
let (_dir, index) = setup();
let manager = HistoryManager::new(index);
for i in 0..3 {
manager
.record(
"cmd",
&[format!("{i}")],
std::path::Path::new("/p"),
true,
None,
)
.unwrap();
}
let entry = manager.at_offset(1).unwrap();
assert_eq!(entry.args, vec!["2"]);
let entry = manager.at_offset(3).unwrap();
assert_eq!(entry.args, vec!["0"]); }
#[test]
fn test_history_disabled() {
let dir = TempDir::new().unwrap();
let config = PersistenceConfig {
global_dir_override: Some(dir.path().join("global")),
history_enabled: false,
..Default::default()
};
let index = Arc::new(UserMetadataIndex::open(Some(dir.path()), config).unwrap());
let manager = HistoryManager::new(index);
let result = manager.record("cmd", &[], std::path::Path::new("/p"), true, None);
assert!(matches!(result, Err(HistoryError::Disabled)));
}
#[test]
fn test_max_entries_limit() {
let dir = TempDir::new().unwrap();
let config = PersistenceConfig {
global_dir_override: Some(dir.path().join("global")),
history_enabled: true,
max_history_entries: 5,
..Default::default()
};
let index = Arc::new(UserMetadataIndex::open(Some(dir.path()), config).unwrap());
let manager = HistoryManager::new(index);
for i in 0..10 {
manager
.record(
"cmd",
&[format!("{i}")],
std::path::Path::new("/p"),
true,
None,
)
.unwrap();
}
assert_eq!(manager.count().unwrap(), 5);
let entries = manager.list(10).unwrap();
assert_eq!(entries[0].args, vec!["9"]);
assert_eq!(entries[4].args, vec!["5"]);
}
#[test]
fn test_redact_secrets() {
let args = vec![
"normal_arg".to_string(),
["api_key=", "sk_live_", "abc123def456ghi789"].concat(),
"password=mysecret123".to_string(),
"--flag".to_string(),
];
let redacted = redact_secrets(&args);
assert_eq!(redacted[0], "normal_arg");
assert!(redacted[1].contains(REDACTED_PLACEHOLDER));
assert!(redacted[2].contains(REDACTED_PLACEHOLDER));
assert_eq!(redacted[3], "--flag");
}
#[test]
fn test_contains_secrets() {
assert!(contains_secrets("api_key=abc123def456ghi789jkl"));
let aws_key = ["AKIA", "IOSFODNN7EXAMPLE"].concat();
assert!(contains_secrets(&aws_key));
assert!(contains_secrets("password=mysecret123"));
let github_token = ["ghp_", "1234567890abcdefghijABCDEFGHIJKLMNOP"].concat();
assert!(contains_secrets(&github_token));
let openai_key = ["sk-", "abc123def456ghi789jklmno"].concat();
assert!(contains_secrets(&openai_key));
assert!(!contains_secrets("normal text here"));
assert!(!contains_secrets("--kind function"));
assert!(!contains_secrets(
"e58f019f1234567890abcdef1234567890abcdef"
));
assert!(!contains_secrets("e58f019"));
assert!(!contains_secrets("d41d8cd98f00b204e9800998ecf8427e"));
assert!(!contains_secrets(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
));
}
#[test]
fn test_parse_duration() {
assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
assert_eq!(parse_duration("1d").unwrap(), Duration::from_secs(86400));
assert_eq!(parse_duration("1w").unwrap(), Duration::from_secs(604_800));
assert!(parse_duration("").is_err());
assert!(parse_duration("abc").is_err());
assert!(parse_duration("10x").is_err());
}
#[test]
fn test_stats() {
let (_dir, index) = setup();
let manager = HistoryManager::new(index);
manager
.record("search", &[], std::path::Path::new("/p"), true, None)
.unwrap();
manager
.record("query", &[], std::path::Path::new("/p"), false, None)
.unwrap();
manager
.record("search", &[], std::path::Path::new("/p"), true, None)
.unwrap();
let stats = manager.stats().unwrap();
assert_eq!(stats.total_entries, 3);
assert_eq!(stats.success_count, 2);
assert_eq!(stats.failure_count, 1);
assert_eq!(stats.command_counts.len(), 2);
}
#[test]
fn test_redact_secrets_with_config() {
let dir = TempDir::new().unwrap();
let config = PersistenceConfig {
global_dir_override: Some(dir.path().join("global")),
history_enabled: true,
redact_secrets: true,
..Default::default()
};
let index = Arc::new(UserMetadataIndex::open(Some(dir.path()), config).unwrap());
let manager = HistoryManager::new(index);
let id = manager
.record(
"search",
&["api_key=sk_live_abc123def456ghi789".to_string()],
std::path::Path::new("/p"),
true,
None,
)
.unwrap();
let entry = manager.get(id).unwrap();
assert!(entry.args[0].contains(REDACTED_PLACEHOLDER));
}
#[test]
fn test_error_display() {
let err = HistoryError::Disabled;
assert_eq!(err.to_string(), "history recording is disabled");
let err = HistoryError::NotFound { id: 42 };
assert_eq!(err.to_string(), "history entry 42 not found");
}
#[test]
fn test_for_directory() {
let (_dir, index) = setup();
let manager = HistoryManager::new(index);
manager
.record(
"cmd",
&["a".to_string()],
std::path::Path::new("/project1"),
true,
None,
)
.unwrap();
manager
.record(
"cmd",
&["b".to_string()],
std::path::Path::new("/project2"),
true,
None,
)
.unwrap();
manager
.record(
"cmd",
&["c".to_string()],
std::path::Path::new("/project1"),
true,
None,
)
.unwrap();
let entries = manager
.for_directory(std::path::Path::new("/project1"), 10)
.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].args, vec!["c"]);
assert_eq!(entries[1].args, vec!["a"]);
}
}