1use std::sync::Arc;
7use std::time::Duration;
8
9use chrono::{DateTime, Utc};
10use regex::Regex;
11
12use crate::persistence::index::UserMetadataIndex;
13use crate::persistence::types::{HistoryEntry, StorageScope};
14
15static SECRET_PATTERNS: std::sync::LazyLock<Vec<Regex>> = std::sync::LazyLock::new(|| {
21 vec![
22 Regex::new(r#"(?i)(api[_-]?key|api[_-]?token|access[_-]?token|auth[_-]?token|bearer)\s*[=:]\s*['"]?[a-zA-Z0-9_-]{16,}['"]?"#).unwrap(),
24 Regex::new(r"(?i)AKIA[0-9A-Z]{16}").unwrap(),
26 Regex::new(r#"(?i)(aws[_-]?secret|secret[_-]?access[_-]?key)\s*[=:]\s*['"]?[a-zA-Z0-9/+=]{40}['"]?"#).unwrap(),
28 Regex::new(r#"(?i)(password|passwd|pwd|secret|private[_-]?key)\s*[=:]\s*['"]?[^\s'"]{8,}['"]?"#).unwrap(),
30 Regex::new(r"eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*").unwrap(),
32 Regex::new(r"gh[pousr]_[A-Za-z0-9_]{36,}").unwrap(),
34 Regex::new(r"github_pat_[A-Za-z0-9_]{22,}").unwrap(),
36 Regex::new(r"xox[baprs]-[0-9]+-[0-9]+-[a-zA-Z0-9]+").unwrap(),
38 Regex::new(r"[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27}").unwrap(),
40 Regex::new(r"sk-[a-zA-Z0-9]{20,}").unwrap(),
42 Regex::new(r"sk-ant-[a-zA-Z0-9]{20,}").unwrap(),
44 Regex::new(r#""private_key"\s*:\s*"-----BEGIN"#).unwrap(),
46 Regex::new(r"npm_[a-zA-Z0-9]{36}").unwrap(),
48 Regex::new(r"sk_live_[a-zA-Z0-9]{24,}").unwrap(),
50 Regex::new(r"sk_test_[a-zA-Z0-9]{24,}").unwrap(),
51 Regex::new(r"SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}").unwrap(),
53 Regex::new(r"SK[a-f0-9]{32}").unwrap(),
55 ]
56});
57
58pub const REDACTED_PLACEHOLDER: &str = "[REDACTED]";
60
61#[derive(Debug)]
63pub enum HistoryError {
64 Disabled,
66 NotFound { id: u64 },
68 Storage(anyhow::Error),
70}
71
72impl std::fmt::Display for HistoryError {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 Self::Disabled => write!(f, "history recording is disabled"),
76 Self::NotFound { id } => write!(f, "history entry {id} not found"),
77 Self::Storage(e) => write!(f, "storage error: {e}"),
78 }
79 }
80}
81
82impl std::error::Error for HistoryError {
83 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
84 match self {
85 Self::Storage(e) => e.source(),
86 _ => None,
87 }
88 }
89}
90
91impl From<anyhow::Error> for HistoryError {
92 fn from(e: anyhow::Error) -> Self {
93 Self::Storage(e)
94 }
95}
96
97#[derive(Debug, Clone)]
102pub struct HistoryManager {
103 index: Arc<UserMetadataIndex>,
104}
105
106impl HistoryManager {
107 #[must_use]
109 pub fn new(index: Arc<UserMetadataIndex>) -> Self {
110 Self { index }
111 }
112
113 pub fn record(
127 &self,
128 command: &str,
129 args: &[String],
130 working_dir: &std::path::Path,
131 success: bool,
132 duration: Option<Duration>,
133 ) -> Result<u64, HistoryError> {
134 let config = self.index.config();
135
136 if !config.history_enabled {
138 return Err(HistoryError::Disabled);
139 }
140
141 let processed_args = if config.redact_secrets {
143 redact_secrets(args)
144 } else {
145 args.to_vec()
146 };
147
148 let mut entry_id = 0u64;
149
150 self.index.update(StorageScope::Global, |metadata| {
151 entry_id = metadata.history.next_id;
153 metadata.history.next_id += 1;
154
155 let entry = HistoryEntry {
157 id: entry_id,
158 timestamp: Utc::now(),
159 command: command.to_string(),
160 args: processed_args.clone(),
161 working_dir: working_dir.to_path_buf(),
162 success,
163 duration_ms: duration.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)),
164 };
165
166 metadata.history.entries.push(entry);
168
169 let max_entries = config.max_history_entries;
171 if metadata.history.entries.len() > max_entries {
172 let excess = metadata.history.entries.len() - max_entries;
173 metadata.history.entries.drain(0..excess);
174 }
175
176 Ok(())
177 })?;
178
179 Ok(entry_id)
180 }
181
182 pub fn get(&self, id: u64) -> Result<HistoryEntry, HistoryError> {
188 let metadata = self.index.load(StorageScope::Global)?;
189 metadata
190 .history
191 .entries
192 .iter()
193 .find(|e| e.id == id)
194 .cloned()
195 .ok_or(HistoryError::NotFound { id })
196 }
197
198 pub fn last(&self) -> Result<Option<HistoryEntry>, HistoryError> {
204 let metadata = self.index.load(StorageScope::Global)?;
205 Ok(metadata.history.entries.last().cloned())
206 }
207
208 pub fn list(&self, limit: usize) -> Result<Vec<HistoryEntry>, HistoryError> {
220 let metadata = self.index.load(StorageScope::Global)?;
221 let entries: Vec<_> = metadata
222 .history
223 .entries
224 .iter()
225 .rev()
226 .take(limit)
227 .cloned()
228 .collect();
229 Ok(entries)
230 }
231
232 pub fn search(&self, pattern: &str, limit: usize) -> Result<Vec<HistoryEntry>, HistoryError> {
245 let metadata = self.index.load(StorageScope::Global)?;
246 let pattern_lower = pattern.to_lowercase();
247
248 let entries: Vec<_> = metadata
249 .history
250 .entries
251 .iter()
252 .rev()
253 .filter(|e| {
254 e.command.to_lowercase().contains(&pattern_lower)
255 || e.args
256 .iter()
257 .any(|a| a.to_lowercase().contains(&pattern_lower))
258 })
259 .take(limit)
260 .cloned()
261 .collect();
262
263 Ok(entries)
264 }
265
266 pub fn clear(&self) -> Result<usize, HistoryError> {
272 let mut count = 0;
273 self.index.update(StorageScope::Global, |metadata| {
274 count = metadata.history.entries.len();
275 metadata.history.entries.clear();
276 Ok(())
278 })?;
279 Ok(count)
280 }
281
282 pub fn clear_older_than_duration(&self, older_than: Duration) -> Result<usize, HistoryError> {
292 let cutoff = Utc::now() - chrono::Duration::from_std(older_than).unwrap_or_default();
293 self.clear_older_than(cutoff)
294 }
295
296 pub fn clear_older_than(&self, cutoff: DateTime<Utc>) -> Result<usize, HistoryError> {
306 let mut count = 0;
307
308 self.index.update(StorageScope::Global, |metadata| {
309 let before_len = metadata.history.entries.len();
310 metadata.history.entries.retain(|e| e.timestamp >= cutoff);
311 count = before_len - metadata.history.entries.len();
312 Ok(())
313 })?;
314
315 Ok(count)
316 }
317
318 pub fn count(&self) -> Result<usize, HistoryError> {
324 let metadata = self.index.load(StorageScope::Global)?;
325 Ok(metadata.history.entries.len())
326 }
327
328 pub fn for_directory(
334 &self,
335 dir: &std::path::Path,
336 limit: usize,
337 ) -> Result<Vec<HistoryEntry>, HistoryError> {
338 let metadata = self.index.load(StorageScope::Global)?;
339 let entries: Vec<_> = metadata
340 .history
341 .entries
342 .iter()
343 .rev()
344 .filter(|e| e.working_dir == dir)
345 .take(limit)
346 .cloned()
347 .collect();
348 Ok(entries)
349 }
350
351 pub fn at_offset(&self, offset: usize) -> Result<HistoryEntry, HistoryError> {
359 if offset == 0 {
360 return Err(HistoryError::NotFound { id: 0 });
361 }
362
363 let metadata = self.index.load(StorageScope::Global)?;
364 let entries = &metadata.history.entries;
365
366 if offset > entries.len() {
367 return Err(HistoryError::NotFound { id: offset as u64 });
368 }
369
370 Ok(entries[entries.len() - offset].clone())
371 }
372
373 #[must_use]
375 pub fn is_enabled(&self) -> bool {
376 self.index.config().history_enabled
377 }
378
379 pub fn stats(&self) -> Result<HistoryStats, HistoryError> {
385 let metadata = self.index.load(StorageScope::Global)?;
386 let entries = &metadata.history.entries;
387
388 let total_entries = entries.len();
389 let successful = entries.iter().filter(|e| e.success).count();
390 let failed = total_entries - successful;
391
392 let oldest = entries.first().map(|e| e.timestamp);
393 let newest = entries.last().map(|e| e.timestamp);
394
395 let mut command_counts = std::collections::HashMap::new();
397 for entry in entries {
398 *command_counts.entry(entry.command.clone()).or_insert(0) += 1;
399 }
400
401 Ok(HistoryStats {
402 total_entries,
403 success_count: successful,
404 failure_count: failed,
405 oldest_entry: oldest,
406 newest_entry: newest,
407 command_counts,
408 })
409 }
410}
411
412#[derive(Debug, Clone)]
414pub struct HistoryStats {
415 pub total_entries: usize,
417 pub success_count: usize,
419 pub failure_count: usize,
421 pub oldest_entry: Option<DateTime<Utc>>,
423 pub newest_entry: Option<DateTime<Utc>>,
425 pub command_counts: std::collections::HashMap<String, usize>,
427}
428
429#[must_use]
434pub fn redact_secrets(args: &[String]) -> Vec<String> {
435 args.iter()
436 .map(|arg| {
437 let mut result = arg.clone();
438 for pattern in SECRET_PATTERNS.iter() {
439 if pattern.is_match(&result) {
440 result = pattern
441 .replace_all(&result, REDACTED_PLACEHOLDER)
442 .to_string();
443 }
444 }
445 result
446 })
447 .collect()
448}
449
450#[must_use]
452pub fn contains_secrets(text: &str) -> bool {
453 SECRET_PATTERNS.iter().any(|p| p.is_match(text))
454}
455
456pub fn parse_duration(s: &str) -> Result<Duration, String> {
469 let s = s.trim();
470 if s.is_empty() {
471 return Err("empty duration string".to_string());
472 }
473
474 let (num_str, unit) = s.split_at(s.len() - 1);
475 let num: u64 = num_str
476 .parse()
477 .map_err(|_| format!("invalid number in duration: {num_str}"))?;
478
479 let seconds = match unit.to_lowercase().as_str() {
480 "s" => num,
481 "m" => num * 60,
482 "h" => num * 60 * 60,
483 "d" => num * 60 * 60 * 24,
484 "w" => num * 60 * 60 * 24 * 7,
485 _ => return Err(format!("unknown duration unit: {unit}")),
486 };
487
488 Ok(Duration::from_secs(seconds))
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::persistence::config::PersistenceConfig;
495 use tempfile::TempDir;
496
497 fn setup() -> (TempDir, Arc<UserMetadataIndex>) {
498 let dir = TempDir::new().unwrap();
499 let config = PersistenceConfig {
500 global_dir_override: Some(dir.path().join("global")),
501 history_enabled: true,
502 max_history_entries: 100,
503 ..Default::default()
504 };
505 let index = Arc::new(UserMetadataIndex::open(Some(dir.path()), config).unwrap());
506 (dir, index)
507 }
508
509 #[test]
510 fn test_record_and_get() {
511 let (_dir, index) = setup();
512 let manager = HistoryManager::new(index);
513
514 let id = manager
515 .record(
516 "search",
517 &["main".to_string()],
518 std::path::Path::new("/project"),
519 true,
520 Some(Duration::from_millis(100)),
521 )
522 .unwrap();
523
524 let entry = manager.get(id).unwrap();
525 assert_eq!(entry.command, "search");
526 assert_eq!(entry.args, vec!["main"]);
527 assert!(entry.success);
528 assert_eq!(entry.duration_ms, Some(100));
529 }
530
531 #[test]
532 fn test_list_recent() {
533 let (_dir, index) = setup();
534 let manager = HistoryManager::new(index);
535
536 for i in 0..5 {
537 manager
538 .record(
539 "query",
540 &[format!("arg{i}")],
541 std::path::Path::new("/project"),
542 true,
543 None,
544 )
545 .unwrap();
546 }
547
548 let recent = manager.list(3).unwrap();
549 assert_eq!(recent.len(), 3);
550 assert_eq!(recent[0].args, vec!["arg4"]);
552 assert_eq!(recent[1].args, vec!["arg3"]);
553 assert_eq!(recent[2].args, vec!["arg2"]);
554 }
555
556 #[test]
557 fn test_search_history() {
558 let (_dir, index) = setup();
559 let manager = HistoryManager::new(index);
560
561 manager
562 .record(
563 "search",
564 &["function".to_string()],
565 std::path::Path::new("/p"),
566 true,
567 None,
568 )
569 .unwrap();
570 manager
571 .record(
572 "query",
573 &["class".to_string()],
574 std::path::Path::new("/p"),
575 true,
576 None,
577 )
578 .unwrap();
579 manager
580 .record(
581 "search",
582 &["method".to_string()],
583 std::path::Path::new("/p"),
584 true,
585 None,
586 )
587 .unwrap();
588
589 let results = manager.search("search", 10).unwrap();
590 assert_eq!(results.len(), 2);
591
592 let results = manager.search("class", 10).unwrap();
593 assert_eq!(results.len(), 1);
594 }
595
596 #[test]
597 fn test_clear_history() {
598 let (_dir, index) = setup();
599 let manager = HistoryManager::new(index);
600
601 for _ in 0..3 {
602 manager
603 .record("cmd", &[], std::path::Path::new("/p"), true, None)
604 .unwrap();
605 }
606
607 assert_eq!(manager.count().unwrap(), 3);
608
609 let cleared = manager.clear().unwrap();
610 assert_eq!(cleared, 3);
611 assert_eq!(manager.count().unwrap(), 0);
612 }
613
614 #[test]
615 fn test_at_offset() {
616 let (_dir, index) = setup();
617 let manager = HistoryManager::new(index);
618
619 for i in 0..3 {
620 manager
621 .record(
622 "cmd",
623 &[format!("{i}")],
624 std::path::Path::new("/p"),
625 true,
626 None,
627 )
628 .unwrap();
629 }
630
631 let entry = manager.at_offset(1).unwrap();
632 assert_eq!(entry.args, vec!["2"]); let entry = manager.at_offset(3).unwrap();
635 assert_eq!(entry.args, vec!["0"]); }
637
638 #[test]
639 fn test_history_disabled() {
640 let dir = TempDir::new().unwrap();
641 let config = PersistenceConfig {
642 global_dir_override: Some(dir.path().join("global")),
643 history_enabled: false,
644 ..Default::default()
645 };
646 let index = Arc::new(UserMetadataIndex::open(Some(dir.path()), config).unwrap());
647 let manager = HistoryManager::new(index);
648
649 let result = manager.record("cmd", &[], std::path::Path::new("/p"), true, None);
650 assert!(matches!(result, Err(HistoryError::Disabled)));
651 }
652
653 #[test]
654 fn test_max_entries_limit() {
655 let dir = TempDir::new().unwrap();
656 let config = PersistenceConfig {
657 global_dir_override: Some(dir.path().join("global")),
658 history_enabled: true,
659 max_history_entries: 5,
660 ..Default::default()
661 };
662 let index = Arc::new(UserMetadataIndex::open(Some(dir.path()), config).unwrap());
663 let manager = HistoryManager::new(index);
664
665 for i in 0..10 {
666 manager
667 .record(
668 "cmd",
669 &[format!("{i}")],
670 std::path::Path::new("/p"),
671 true,
672 None,
673 )
674 .unwrap();
675 }
676
677 assert_eq!(manager.count().unwrap(), 5);
678
679 let entries = manager.list(10).unwrap();
681 assert_eq!(entries[0].args, vec!["9"]);
682 assert_eq!(entries[4].args, vec!["5"]);
683 }
684
685 #[test]
686 fn test_redact_secrets() {
687 let args = vec![
688 "normal_arg".to_string(),
689 ["api_key=", "sk_live_", "abc123def456ghi789"].concat(),
690 "password=mysecret123".to_string(),
691 "--flag".to_string(),
692 ];
693
694 let redacted = redact_secrets(&args);
695 assert_eq!(redacted[0], "normal_arg");
696 assert!(redacted[1].contains(REDACTED_PLACEHOLDER));
697 assert!(redacted[2].contains(REDACTED_PLACEHOLDER));
698 assert_eq!(redacted[3], "--flag");
699 }
700
701 #[test]
702 fn test_contains_secrets() {
703 assert!(contains_secrets("api_key=abc123def456ghi789jkl"));
705 let aws_key = ["AKIA", "IOSFODNN7EXAMPLE"].concat();
708 assert!(contains_secrets(&aws_key));
709 assert!(contains_secrets("password=mysecret123"));
710 let github_token = ["ghp_", "1234567890abcdefghijABCDEFGHIJKLMNOP"].concat();
711 assert!(contains_secrets(&github_token));
712 let openai_key = ["sk-", "abc123def456ghi789jklmno"].concat();
714 assert!(contains_secrets(&openai_key));
715
716 assert!(!contains_secrets("normal text here"));
718 assert!(!contains_secrets("--kind function"));
719 assert!(!contains_secrets(
721 "e58f019f1234567890abcdef1234567890abcdef"
722 ));
723 assert!(!contains_secrets("e58f019"));
725 assert!(!contains_secrets("d41d8cd98f00b204e9800998ecf8427e"));
727 assert!(!contains_secrets(
729 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
730 ));
731 }
732
733 #[test]
734 fn test_parse_duration() {
735 assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
736 assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
737 assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
738 assert_eq!(parse_duration("1d").unwrap(), Duration::from_secs(86400));
739 assert_eq!(parse_duration("1w").unwrap(), Duration::from_secs(604800));
740
741 assert!(parse_duration("").is_err());
742 assert!(parse_duration("abc").is_err());
743 assert!(parse_duration("10x").is_err());
744 }
745
746 #[test]
747 fn test_stats() {
748 let (_dir, index) = setup();
749 let manager = HistoryManager::new(index);
750
751 manager
752 .record("search", &[], std::path::Path::new("/p"), true, None)
753 .unwrap();
754 manager
755 .record("query", &[], std::path::Path::new("/p"), false, None)
756 .unwrap();
757 manager
758 .record("search", &[], std::path::Path::new("/p"), true, None)
759 .unwrap();
760
761 let stats = manager.stats().unwrap();
762 assert_eq!(stats.total_entries, 3);
763 assert_eq!(stats.success_count, 2);
764 assert_eq!(stats.failure_count, 1);
765 assert_eq!(stats.command_counts.len(), 2);
766 }
767
768 #[test]
769 fn test_redact_secrets_with_config() {
770 let dir = TempDir::new().unwrap();
771 let config = PersistenceConfig {
772 global_dir_override: Some(dir.path().join("global")),
773 history_enabled: true,
774 redact_secrets: true,
775 ..Default::default()
776 };
777 let index = Arc::new(UserMetadataIndex::open(Some(dir.path()), config).unwrap());
778 let manager = HistoryManager::new(index);
779
780 let id = manager
781 .record(
782 "search",
783 &["api_key=sk_live_abc123def456ghi789".to_string()],
784 std::path::Path::new("/p"),
785 true,
786 None,
787 )
788 .unwrap();
789
790 let entry = manager.get(id).unwrap();
791 assert!(entry.args[0].contains(REDACTED_PLACEHOLDER));
792 }
793
794 #[test]
795 fn test_error_display() {
796 let err = HistoryError::Disabled;
797 assert_eq!(err.to_string(), "history recording is disabled");
798
799 let err = HistoryError::NotFound { id: 42 };
800 assert_eq!(err.to_string(), "history entry 42 not found");
801 }
802
803 #[test]
804 fn test_for_directory() {
805 let (_dir, index) = setup();
806 let manager = HistoryManager::new(index);
807
808 manager
809 .record(
810 "cmd",
811 &["a".to_string()],
812 std::path::Path::new("/project1"),
813 true,
814 None,
815 )
816 .unwrap();
817 manager
818 .record(
819 "cmd",
820 &["b".to_string()],
821 std::path::Path::new("/project2"),
822 true,
823 None,
824 )
825 .unwrap();
826 manager
827 .record(
828 "cmd",
829 &["c".to_string()],
830 std::path::Path::new("/project1"),
831 true,
832 None,
833 )
834 .unwrap();
835
836 let entries = manager
837 .for_directory(std::path::Path::new("/project1"), 10)
838 .unwrap();
839 assert_eq!(entries.len(), 2);
840 assert_eq!(entries[0].args, vec!["c"]);
841 assert_eq!(entries[1].args, vec!["a"]);
842 }
843}