Skip to main content

cqlite_cli/repl/
history.rs

1// Command History Management
2//
3// Manages command history for the REPL, including persistence, search,
4// and navigation through previous commands.
5
6use super::{ReplError, ReplResult};
7use std::collections::VecDeque;
8use std::fs;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11
12/// Command history entry
13#[derive(Debug, Clone)]
14pub struct HistoryEntry {
15    /// The command text
16    pub command: String,
17    /// Timestamp when command was executed
18    pub timestamp: std::time::SystemTime,
19    /// Execution duration (if available)
20    pub duration: Option<std::time::Duration>,
21    /// Whether the command succeeded
22    pub success: Option<bool>,
23    /// Command category for filtering
24    pub category: HistoryCategory,
25}
26
27/// Categories of commands for history filtering
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub enum HistoryCategory {
30    Query,      // CQL queries
31    Meta,       // Meta-commands (:help, :quit, etc.)
32    Config,     // Configuration changes
33    Navigation, // :use, :tables, etc.
34    System,     // :clear, :history, etc.
35    Unknown,
36}
37
38impl std::fmt::Display for HistoryCategory {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            HistoryCategory::Query => write!(f, "query"),
42            HistoryCategory::Meta => write!(f, "meta"),
43            HistoryCategory::Config => write!(f, "config"),
44            HistoryCategory::Navigation => write!(f, "navigation"),
45            HistoryCategory::System => write!(f, "system"),
46            HistoryCategory::Unknown => write!(f, "unknown"),
47        }
48    }
49}
50
51/// History search filter
52#[derive(Debug, Clone)]
53pub struct HistoryFilter {
54    /// Text pattern to match
55    pub pattern: Option<String>,
56    /// Category filter
57    pub category: Option<HistoryCategory>,
58    /// Only successful commands
59    pub success_only: bool,
60    /// Time range filter
61    pub since: Option<std::time::SystemTime>,
62    /// Maximum number of results
63    pub limit: Option<usize>,
64}
65
66impl Default for HistoryFilter {
67    fn default() -> Self {
68        Self {
69            pattern: None,
70            category: None,
71            success_only: false,
72            since: None,
73            limit: Some(50),
74        }
75    }
76}
77
78/// Command history manager
79pub struct HistoryManager {
80    /// Command history entries
81    history: VecDeque<HistoryEntry>,
82    /// Maximum number of entries to keep
83    max_size: usize,
84    /// Current position for navigation
85    current_position: Option<usize>,
86    /// History file path
87    history_file: Option<PathBuf>,
88    /// Whether to persist history to file
89    persistent: bool,
90    /// Statistics
91    stats: HistoryStats,
92}
93
94/// History statistics
95#[derive(Debug, Default)]
96pub struct HistoryStats {
97    pub total_commands: u64,
98    pub successful_commands: u64,
99    pub failed_commands: u64,
100    pub by_category: std::collections::HashMap<HistoryCategory, u64>,
101    pub avg_duration_ms: f64,
102}
103
104impl HistoryManager {
105    /// Create a new history manager
106    pub fn new(max_size: usize) -> ReplResult<Self> {
107        Ok(Self {
108            history: VecDeque::with_capacity(max_size),
109            max_size,
110            current_position: None,
111            history_file: None,
112            persistent: false,
113            stats: HistoryStats::default(),
114        })
115    }
116
117    /// Create a new persistent history manager
118    pub fn new_persistent(max_size: usize, history_dir: &Path) -> ReplResult<Self> {
119        let history_file = history_dir.join("cqlite_history.txt");
120
121        // Create history directory if it doesn't exist
122        if let Some(parent) = history_file.parent() {
123            fs::create_dir_all(parent).map_err(ReplError::Io)?;
124        }
125
126        let mut manager = Self {
127            history: VecDeque::with_capacity(max_size),
128            max_size,
129            current_position: None,
130            history_file: Some(history_file),
131            persistent: true,
132            stats: HistoryStats::default(),
133        };
134
135        // Load existing history
136        manager.load_history()?;
137
138        Ok(manager)
139    }
140
141    /// Add a command to history
142    pub fn add_command(&mut self, command: &str) -> ReplResult<()> {
143        // Skip empty commands and duplicates
144        if command.trim().is_empty() {
145            return Ok(());
146        }
147
148        // Skip consecutive duplicates
149        if let Some(last_entry) = self.history.back() {
150            if last_entry.command.trim() == command.trim() {
151                return Ok(());
152            }
153        }
154
155        let entry = HistoryEntry {
156            command: command.to_string(),
157            timestamp: std::time::SystemTime::now(),
158            duration: None,
159            success: None,
160            category: self.categorize_command(command),
161        };
162
163        self.add_entry(entry)?;
164        Ok(())
165    }
166
167    /// Add a command with execution details
168    pub fn add_command_with_result(
169        &mut self,
170        command: &str,
171        duration: std::time::Duration,
172        success: bool,
173    ) -> ReplResult<()> {
174        if command.trim().is_empty() {
175            return Ok(());
176        }
177
178        let entry = HistoryEntry {
179            command: command.to_string(),
180            timestamp: std::time::SystemTime::now(),
181            duration: Some(duration),
182            success: Some(success),
183            category: self.categorize_command(command),
184        };
185
186        self.add_entry(entry.clone())?;
187        self.update_stats(&entry);
188
189        Ok(())
190    }
191
192    /// Add an entry to history
193    fn add_entry(&mut self, entry: HistoryEntry) -> ReplResult<()> {
194        // Remove oldest entries if at capacity
195        while self.history.len() >= self.max_size {
196            self.history.pop_front();
197        }
198
199        self.history.push_back(entry.clone());
200        self.current_position = None;
201
202        // Persist if enabled
203        if self.persistent {
204            self.persist_entry(&entry)?;
205        }
206
207        Ok(())
208    }
209
210    /// Categorize a command
211    fn categorize_command(&self, command: &str) -> HistoryCategory {
212        let trimmed = command.trim();
213
214        if trimmed.starts_with(':') || trimmed.starts_with('.') || trimmed.starts_with('\\') {
215            if trimmed.contains("config") || trimmed.contains("set") {
216                HistoryCategory::Config
217            } else if trimmed.contains("use")
218                || trimmed.contains("tables")
219                || trimmed.contains("keyspaces")
220                || trimmed.contains("describe")
221            {
222                HistoryCategory::Navigation
223            } else if trimmed.contains("clear")
224                || trimmed.contains("history")
225                || trimmed.contains("source")
226            {
227                HistoryCategory::System
228            } else {
229                HistoryCategory::Meta
230            }
231        } else {
232            let upper = trimmed.to_uppercase();
233            if upper.starts_with("SELECT")
234                || upper.starts_with("INSERT")
235                || upper.starts_with("UPDATE")
236                || upper.starts_with("DELETE")
237                || upper.starts_with("CREATE")
238                || upper.starts_with("ALTER")
239                || upper.starts_with("DROP")
240            {
241                HistoryCategory::Query
242            } else {
243                HistoryCategory::Unknown
244            }
245        }
246    }
247
248    /// Get recent commands
249    pub fn recent_commands(&self, limit: usize) -> Vec<String> {
250        self.history
251            .iter()
252            .rev()
253            .take(limit)
254            .map(|entry| entry.command.clone())
255            .collect()
256    }
257
258    /// Search history with filter
259    pub fn search(&self, filter: &HistoryFilter) -> Vec<&HistoryEntry> {
260        let mut results: Vec<&HistoryEntry> = self
261            .history
262            .iter()
263            .filter(|entry| self.matches_filter(entry, filter))
264            .collect();
265
266        // Sort by timestamp (newest first)
267        results.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
268
269        // Apply limit
270        if let Some(limit) = filter.limit {
271            results.truncate(limit);
272        }
273
274        results
275    }
276
277    /// Check if entry matches filter
278    fn matches_filter(&self, entry: &HistoryEntry, filter: &HistoryFilter) -> bool {
279        // Pattern matching
280        if let Some(ref pattern) = filter.pattern {
281            if !entry
282                .command
283                .to_lowercase()
284                .contains(&pattern.to_lowercase())
285            {
286                return false;
287            }
288        }
289
290        // Category matching
291        if let Some(ref category) = filter.category {
292            if entry.category != *category {
293                return false;
294            }
295        }
296
297        // Success filter
298        if filter.success_only {
299            if let Some(success) = entry.success {
300                if !success {
301                    return false;
302                }
303            } else {
304                return false; // No success info available
305            }
306        }
307
308        // Time range filter
309        if let Some(since) = filter.since {
310            if entry.timestamp < since {
311                return false;
312            }
313        }
314
315        true
316    }
317
318    /// Navigate to previous command
319    pub fn previous(&mut self) -> Option<String> {
320        if self.history.is_empty() {
321            return None;
322        }
323
324        let new_position = match self.current_position {
325            None => self.history.len() - 1,
326            Some(pos) => {
327                if pos > 0 {
328                    pos - 1
329                } else {
330                    return None; // Already at oldest
331                }
332            }
333        };
334
335        self.current_position = Some(new_position);
336        Some(self.history[new_position].command.clone())
337    }
338
339    /// Navigate to next command
340    pub fn next(&mut self) -> Option<String> {
341        if let Some(pos) = self.current_position {
342            if pos < self.history.len() - 1 {
343                self.current_position = Some(pos + 1);
344                Some(self.history[pos + 1].command.clone())
345            } else {
346                self.current_position = None;
347                None // Return to current input
348            }
349        } else {
350            None
351        }
352    }
353
354    /// Reset navigation position
355    pub fn reset_position(&mut self) {
356        self.current_position = None;
357    }
358
359    /// Get history statistics
360    pub fn stats(&self) -> &HistoryStats {
361        &self.stats
362    }
363
364    /// Update statistics
365    fn update_stats(&mut self, entry: &HistoryEntry) {
366        self.stats.total_commands += 1;
367
368        if let Some(success) = entry.success {
369            if success {
370                self.stats.successful_commands += 1;
371            } else {
372                self.stats.failed_commands += 1;
373            }
374        }
375
376        // Update category stats
377        *self
378            .stats
379            .by_category
380            .entry(entry.category.clone())
381            .or_insert(0) += 1;
382
383        // Update average duration
384        if let Some(duration) = entry.duration {
385            let duration_ms = duration.as_millis() as f64;
386            let total_duration =
387                self.stats.avg_duration_ms * (self.stats.total_commands - 1) as f64;
388            self.stats.avg_duration_ms =
389                (total_duration + duration_ms) / self.stats.total_commands as f64;
390        }
391    }
392
393    /// Load history from file
394    fn load_history(&mut self) -> ReplResult<()> {
395        if let Some(ref path) = self.history_file {
396            if path.exists() {
397                let content = fs::read_to_string(path).map_err(ReplError::Io)?;
398
399                for line in content.lines() {
400                    if !line.trim().is_empty() {
401                        // Parse history entry (simple format for now)
402                        if let Some(command) = self.parse_history_line(line) {
403                            let entry = HistoryEntry {
404                                command,
405                                timestamp: std::time::SystemTime::now(),
406                                duration: None,
407                                success: None,
408                                category: self.categorize_command(&line),
409                            };
410
411                            if self.history.len() >= self.max_size {
412                                self.history.pop_front();
413                            }
414                            self.history.push_back(entry);
415                        }
416                    }
417                }
418            }
419        }
420
421        Ok(())
422    }
423
424    /// Parse a history line (simple format)
425    fn parse_history_line(&self, line: &str) -> Option<String> {
426        // For now, just return the line as-is
427        // In a more sophisticated implementation, this would parse
428        // timestamp and other metadata
429        if line.trim().is_empty() {
430            None
431        } else {
432            Some(line.trim().to_string())
433        }
434    }
435
436    /// Persist a single entry
437    fn persist_entry(&self, entry: &HistoryEntry) -> ReplResult<()> {
438        if let Some(ref path) = self.history_file {
439            let mut file = fs::OpenOptions::new()
440                .create(true)
441                .append(true)
442                .open(path)
443                .map_err(ReplError::Io)?;
444
445            // Simple format: just the command for now
446            writeln!(file, "{}", entry.command).map_err(ReplError::Io)?;
447        }
448
449        Ok(())
450    }
451
452    /// Save all history to file
453    pub fn save_history(&self) -> ReplResult<()> {
454        if let Some(ref path) = self.history_file {
455            let mut file = fs::File::create(path).map_err(ReplError::Io)?;
456
457            for entry in &self.history {
458                writeln!(file, "{}", entry.command).map_err(ReplError::Io)?;
459            }
460        }
461
462        Ok(())
463    }
464
465    /// Clear all history
466    pub fn clear(&mut self) -> ReplResult<()> {
467        self.history.clear();
468        self.current_position = None;
469        self.stats = HistoryStats::default();
470
471        // Clear file if persistent
472        if let Some(ref path) = self.history_file {
473            if path.exists() {
474                fs::remove_file(path).map_err(ReplError::Io)?;
475            }
476        }
477
478        Ok(())
479    }
480
481    /// Get history size
482    pub fn len(&self) -> usize {
483        self.history.len()
484    }
485
486    /// Check if history is empty
487    pub fn is_empty(&self) -> bool {
488        self.history.is_empty()
489    }
490
491    /// Export history as text
492    pub fn export_text(&self) -> String {
493        let mut output = String::new();
494
495        output.push_str(&format!(
496            "# CQLite Command History ({})\n",
497            chrono::Utc::now().format("%Y-%m-%d %H:%M:%S")
498        ));
499        output.push_str(&format!("# Total commands: {}\n", self.history.len()));
500        output.push_str("# Format: command\n\n");
501
502        for entry in &self.history {
503            output.push_str(&entry.command);
504            output.push('\n');
505        }
506
507        output
508    }
509
510    /// Export history with metadata
511    pub fn export_detailed(&self) -> String {
512        let mut output = String::new();
513
514        output.push_str(&format!(
515            "# CQLite Detailed Command History ({})\n",
516            chrono::Utc::now().format("%Y-%m-%d %H:%M:%S")
517        ));
518        output.push_str(&format!(
519            "# Total commands: {}\n",
520            self.stats.total_commands
521        ));
522        output.push_str(&format!(
523            "# Successful: {}\n",
524            self.stats.successful_commands
525        ));
526        output.push_str(&format!("# Failed: {}\n", self.stats.failed_commands));
527        output.push_str(&format!(
528            "# Average duration: {:.2}ms\n",
529            self.stats.avg_duration_ms
530        ));
531        output.push_str("# Format: timestamp | category | duration | success | command\n\n");
532
533        for entry in &self.history {
534            let timestamp = entry
535                .timestamp
536                .duration_since(std::time::UNIX_EPOCH)
537                .unwrap_or_default()
538                .as_secs();
539
540            let duration_str = entry
541                .duration
542                .map(|d| format!("{:.2}ms", d.as_millis()))
543                .unwrap_or_else(|| "N/A".to_string());
544
545            let success_str = entry
546                .success
547                .map(|s| if s { "OK" } else { "ERR" })
548                .unwrap_or("N/A");
549
550            output.push_str(&format!(
551                "{} | {} | {} | {} | {}\n",
552                timestamp, entry.category, duration_str, success_str, entry.command
553            ));
554        }
555
556        output
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use tempfile::tempdir;
564
565    #[test]
566    fn test_basic_history() {
567        let mut history = HistoryManager::new(10).unwrap();
568
569        history.add_command("SELECT * FROM users").unwrap();
570        history.add_command("SELECT count(*) FROM orders").unwrap();
571
572        assert_eq!(history.len(), 2);
573
574        let recent = history.recent_commands(5);
575        assert_eq!(recent.len(), 2);
576        assert_eq!(recent[0], "SELECT count(*) FROM orders");
577        assert_eq!(recent[1], "SELECT * FROM users");
578    }
579
580    #[test]
581    fn test_navigation() {
582        let mut history = HistoryManager::new(10).unwrap();
583
584        history.add_command("command1").unwrap();
585        history.add_command("command2").unwrap();
586        history.add_command("command3").unwrap();
587
588        // Navigate backwards
589        assert_eq!(history.previous(), Some("command3".to_string()));
590        assert_eq!(history.previous(), Some("command2".to_string()));
591        assert_eq!(history.previous(), Some("command1".to_string()));
592        assert_eq!(history.previous(), None); // At beginning
593
594        // Navigate forwards
595        assert_eq!(history.next(), Some("command2".to_string()));
596        assert_eq!(history.next(), Some("command3".to_string()));
597        assert_eq!(history.next(), None); // Back to current
598    }
599
600    #[test]
601    fn test_search_filter() {
602        let mut history = HistoryManager::new(10).unwrap();
603
604        history.add_command("SELECT * FROM users").unwrap();
605        history.add_command(":tables").unwrap();
606        history.add_command("SELECT * FROM orders").unwrap();
607
608        let filter = HistoryFilter {
609            pattern: Some("SELECT".to_string()),
610            ..Default::default()
611        };
612
613        let results = history.search(&filter);
614        assert_eq!(results.len(), 2);
615        assert!(results.iter().all(|r| r.command.contains("SELECT")));
616    }
617
618    #[test]
619    fn test_categorization() {
620        let history = HistoryManager::new(10).unwrap();
621
622        assert_eq!(
623            history.categorize_command("SELECT * FROM users"),
624            HistoryCategory::Query
625        );
626        assert_eq!(history.categorize_command(":help"), HistoryCategory::Meta);
627        assert_eq!(
628            history.categorize_command(":config show"),
629            HistoryCategory::Config
630        );
631        assert_eq!(
632            history.categorize_command(":tables"),
633            HistoryCategory::Navigation
634        );
635        assert_eq!(
636            history.categorize_command(":clear"),
637            HistoryCategory::System
638        );
639    }
640
641    #[test]
642    fn test_persistent_history() {
643        let temp_dir = tempdir().unwrap();
644        let temp_path = temp_dir.path();
645
646        {
647            let mut history = HistoryManager::new_persistent(10, temp_path).unwrap();
648            history.add_command("SELECT 1").unwrap();
649            history.add_command("SELECT 2").unwrap();
650        }
651
652        // Load in new instance
653        let history = HistoryManager::new_persistent(10, temp_path).unwrap();
654        assert_eq!(history.len(), 2);
655
656        let recent = history.recent_commands(5);
657        assert!(recent.contains(&"SELECT 1".to_string()));
658        assert!(recent.contains(&"SELECT 2".to_string()));
659    }
660}