Skip to main content

sql_cli/
history.rs

1use crate::app_paths::AppPaths;
2use crate::history_protection::HistoryProtection;
3use anyhow::Result;
4use chrono::{DateTime, Utc};
5use fuzzy_matcher::skim::SkimMatcherV2;
6use fuzzy_matcher::FuzzyMatcher;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fs;
10use std::path::PathBuf;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct QueryMetadata {
14    #[serde(default)]
15    pub tables: Vec<String>, // Tables referenced (FROM clause)
16    #[serde(default)]
17    pub select_columns: Vec<String>, // Columns in SELECT clause
18    #[serde(default)]
19    pub where_columns: Vec<String>, // Columns in WHERE clause
20    #[serde(default)]
21    pub order_by_columns: Vec<String>, // Columns in ORDER BY clause
22    #[serde(default)]
23    pub functions_used: Vec<String>, // Functions/methods used (Contains, StartsWith, etc.)
24    #[serde(default)]
25    pub query_type: String, // SELECT, INSERT, UPDATE, DELETE, etc.
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct HistoryEntry {
30    pub command: String,
31    pub timestamp: DateTime<Utc>,
32    pub execution_count: u32,
33    pub success: bool,
34    pub duration_ms: Option<u64>,
35    #[serde(default)]
36    pub schema_columns: Vec<String>, // Column names from the data source
37    #[serde(default)]
38    pub data_source: Option<String>, // e.g., "customers.csv", "trades_api", etc.
39    #[serde(default)]
40    pub metadata: Option<QueryMetadata>, // Parsed query metadata
41    #[serde(default)]
42    pub is_starred: bool, // User marked as important
43    #[serde(default)]
44    pub session_id: Option<String>, // Session this was created in
45}
46
47#[derive(Debug, Clone)]
48pub struct HistoryMatch {
49    pub entry: HistoryEntry,
50    pub score: i64,
51    pub indices: Vec<usize>,
52}
53
54pub struct CommandHistory {
55    entries: Vec<HistoryEntry>,
56    history_file: PathBuf,
57    matcher: SkimMatcherV2,
58    command_counts: HashMap<String, u32>,
59    session_id: String,
60    session_entries: Vec<HistoryEntry>, // Entries from current session only
61    protection: HistoryProtection,
62}
63
64impl CommandHistory {
65    pub fn new() -> Result<Self> {
66        let history_file = AppPaths::history_file()
67            .map_err(|e| anyhow::anyhow!("Failed to get history file path: {}", e))?;
68        Self::with_history_file(history_file)
69    }
70
71    /// Build a `CommandHistory` backed by an explicit history file path, rather
72    /// than the OS-resolved app data dir. The backup directory is a sibling
73    /// `history_backups/` next to the file. Intended for tests that need full
74    /// isolation from the developer's real history without touching the
75    /// process-global environment (which parallel tests share and race on).
76    pub fn with_history_file(history_file: PathBuf) -> Result<Self> {
77        // Create backup directory
78        let backup_dir = history_file
79            .parent()
80            .unwrap_or(std::path::Path::new("."))
81            .join("history_backups");
82
83        // Generate a unique session ID
84        let session_id = format!("session_{}", Utc::now().timestamp_millis());
85
86        let mut history = Self {
87            entries: Vec::new(),
88            history_file,
89            matcher: SkimMatcherV2::default(),
90            command_counts: HashMap::new(),
91            session_id,
92            session_entries: Vec::new(),
93            protection: HistoryProtection::new(backup_dir),
94        };
95
96        history.load_from_file()?;
97
98        // Clean the history file on startup to remove any duplicates
99        // This ensures the file stays clean over time
100        if !history.entries.is_empty() {
101            history.clean_and_save()?;
102        }
103
104        Ok(history)
105    }
106
107    pub fn add_entry(
108        &mut self,
109        command: String,
110        success: bool,
111        duration_ms: Option<u64>,
112    ) -> Result<()> {
113        self.add_entry_with_schema(command, success, duration_ms, Vec::new(), None)
114    }
115
116    pub fn add_entry_with_schema(
117        &mut self,
118        command: String,
119        success: bool,
120        duration_ms: Option<u64>,
121        schema_columns: Vec<String>,
122        data_source: Option<String>,
123    ) -> Result<()> {
124        // Don't add empty commands or duplicates of the last command
125        if command.trim().is_empty() {
126            return Ok(());
127        }
128
129        // Check if this is the same as the last command
130        if let Some(last_entry) = self.entries.last() {
131            if last_entry.command == command {
132                return Ok(());
133            }
134        }
135
136        // Extract metadata from the query
137        let metadata = self.extract_query_metadata(&command);
138
139        let entry = HistoryEntry {
140            command: command.clone(),
141            timestamp: Utc::now(),
142            execution_count: *self.command_counts.get(&command).unwrap_or(&0) + 1,
143            success,
144            duration_ms,
145            schema_columns,
146            data_source,
147            metadata,
148            is_starred: false,
149            session_id: Some(self.session_id.clone()),
150        };
151
152        // Add to session entries
153        self.session_entries.push(entry.clone());
154
155        // Update command count
156        *self.command_counts.entry(command.clone()).or_insert(0) += 1;
157
158        // Remove any existing entry with the same command to avoid duplicates
159        // This moves the command to the end of the history with updated timestamp
160        self.entries.retain(|e| e.command != command);
161
162        self.entries.push(entry);
163
164        // Keep only the last 1000 entries
165        if self.entries.len() > 1000 {
166            self.entries.drain(0..self.entries.len() - 1000);
167        }
168
169        self.save_to_file()?;
170        Ok(())
171    }
172
173    pub fn search(&self, query: &str) -> Vec<HistoryMatch> {
174        self.search_with_schema(query, &[], None)
175    }
176
177    pub fn search_with_schema(
178        &self,
179        query: &str,
180        current_columns: &[String],
181        current_source: Option<&str>,
182    ) -> Vec<HistoryMatch> {
183        if query.is_empty() {
184            // Return recent entries when no query, prioritizing schema matches
185            let mut entries: Vec<_> = self
186                .entries
187                .iter()
188                .rev()
189                .take(100)
190                .map(|entry| {
191                    let schema_score =
192                        self.calculate_schema_match_score(entry, current_columns, current_source);
193                    HistoryMatch {
194                        entry: entry.clone(),
195                        score: 100 + schema_score,
196                        indices: Vec::new(),
197                    }
198                })
199                .collect();
200
201            entries.sort_by(|a, b| b.score.cmp(&a.score));
202            entries.truncate(50);
203            return entries;
204        }
205
206        let mut matches: Vec<HistoryMatch> = self
207            .entries
208            .iter()
209            .filter_map(|entry| {
210                if let Some((score, indices)) = self.matcher.fuzzy_indices(&entry.command, query) {
211                    let schema_score =
212                        self.calculate_schema_match_score(entry, current_columns, current_source);
213                    Some(HistoryMatch {
214                        entry: entry.clone(),
215                        score: score + schema_score,
216                        indices,
217                    })
218                } else {
219                    None
220                }
221            })
222            .collect();
223
224        // Sort by score (descending), then by recency and frequency
225        matches.sort_by(|a, b| {
226            // Primary sort: fuzzy match score (including schema bonus)
227            let score_cmp = b.score.cmp(&a.score);
228            if score_cmp != std::cmp::Ordering::Equal {
229                return score_cmp;
230            }
231
232            // Secondary sort: execution count (more frequently used commands rank higher)
233            let count_cmp = b.entry.execution_count.cmp(&a.entry.execution_count);
234            if count_cmp != std::cmp::Ordering::Equal {
235                return count_cmp;
236            }
237
238            // Tertiary sort: recency (more recent commands rank higher)
239            b.entry.timestamp.cmp(&a.entry.timestamp)
240        });
241
242        matches.truncate(20); // Limit to top 20 matches
243        matches
244    }
245
246    fn calculate_schema_match_score(
247        &self,
248        entry: &HistoryEntry,
249        current_columns: &[String],
250        current_source: Option<&str>,
251    ) -> i64 {
252        let mut score = 0i64;
253
254        // Bonus for matching data source
255        if let (Some(entry_source), Some(current)) = (&entry.data_source, current_source) {
256            if entry_source == current {
257                score += 50; // High bonus for same data source
258            }
259        }
260
261        // Bonus for matching columns in schema
262        if !current_columns.is_empty() && !entry.schema_columns.is_empty() {
263            let matching_columns = entry
264                .schema_columns
265                .iter()
266                .filter(|col| current_columns.contains(col))
267                .count();
268
269            let total_columns = entry.schema_columns.len().max(current_columns.len());
270            if total_columns > 0 {
271                // Scale bonus based on percentage of matching columns
272                let match_percentage = (matching_columns * 100) / total_columns;
273                score += (match_percentage as i64) / 2; // Up to 50 points for perfect match
274            }
275        }
276
277        // Additional bonus for matching columns in query metadata
278        if let Some(metadata) = &entry.metadata {
279            let metadata_columns: Vec<&String> = metadata
280                .select_columns
281                .iter()
282                .chain(metadata.where_columns.iter())
283                .chain(metadata.order_by_columns.iter())
284                .collect();
285
286            let matching_metadata = metadata_columns
287                .iter()
288                .filter(|col| current_columns.contains(col))
289                .count();
290
291            if matching_metadata > 0 {
292                score += (matching_metadata as i64) * 5; // 5 points per matching column
293            }
294        }
295
296        score
297    }
298
299    fn extract_query_metadata(&self, query: &str) -> Option<QueryMetadata> {
300        let query_upper = query.to_uppercase();
301
302        // Determine query type
303        let query_type = if query_upper.starts_with("SELECT") {
304            "SELECT"
305        } else if query_upper.starts_with("INSERT") {
306            "INSERT"
307        } else if query_upper.starts_with("UPDATE") {
308            "UPDATE"
309        } else if query_upper.starts_with("DELETE") {
310            "DELETE"
311        } else {
312            "OTHER"
313        }
314        .to_string();
315
316        // Extract table names (simple regex-based approach)
317        let mut tables = Vec::new();
318        if let Some(from_idx) = query_upper.find(" FROM ") {
319            let after_from = &query[from_idx + 6..];
320            if let Some(end_idx) = after_from.find([' ', '(', ';']) {
321                let table_name = after_from[..end_idx].trim().to_string();
322                if !table_name.is_empty() {
323                    tables.push(table_name);
324                }
325            }
326        }
327
328        // Extract columns from SELECT clause
329        let mut select_columns = Vec::new();
330        if query_type == "SELECT" {
331            if let Some(select_idx) = query_upper.find("SELECT ") {
332                let after_select = &query[select_idx + 7..];
333                if let Some(from_idx) = after_select.to_uppercase().find(" FROM") {
334                    let select_clause = &after_select[..from_idx];
335                    if !select_clause.trim().eq("*") {
336                        // Parse column names (simplified)
337                        for col in select_clause.split(',') {
338                            let col_name = col
339                                .split_whitespace()
340                                .next()
341                                .unwrap_or("")
342                                .trim_matches('"')
343                                .to_string();
344                            if !col_name.is_empty() {
345                                select_columns.push(col_name);
346                            }
347                        }
348                    }
349                }
350            }
351        }
352
353        // Extract columns from WHERE clause and functions used
354        let mut where_columns = Vec::new();
355        let mut functions_used = Vec::new();
356        if let Some(where_idx) = query_upper.find(" WHERE ") {
357            let after_where = &query[where_idx + 7..];
358
359            // Look for LINQ methods
360            let linq_methods = [
361                "Contains",
362                "StartsWith",
363                "EndsWith",
364                "Length",
365                "ToUpper",
366                "ToLower",
367                "IsNullOrEmpty",
368            ];
369            for method in &linq_methods {
370                if after_where.contains(method) {
371                    functions_used.push((*method).to_string());
372                }
373            }
374
375            // Extract column names before operators or methods
376            // This is simplified - a proper parser would be better
377            let words: Vec<&str> = after_where
378                .split(|c: char| !c.is_alphanumeric() && c != '_')
379                .filter(|s| !s.is_empty())
380                .collect();
381
382            for (i, word) in words.iter().enumerate() {
383                // If next word is an operator or method, this might be a column
384                if i + 1 < words.len() {
385                    let next = words[i + 1];
386                    if linq_methods.contains(&next)
387                        || ["IS", "NOT", "LIKE", "BETWEEN"].contains(&next.to_uppercase().as_str())
388                    {
389                        where_columns.push((*word).to_string());
390                    }
391                }
392            }
393        }
394
395        // Extract ORDER BY columns
396        let mut order_by_columns = Vec::new();
397        if let Some(order_idx) = query_upper.find(" ORDER BY ") {
398            let after_order = &query[order_idx + 10..];
399            let end_idx = after_order.find([';', ')']).unwrap_or(after_order.len());
400            let order_clause = &after_order[..end_idx];
401
402            for col in order_clause.split(',') {
403                let col_name = col
404                    .split_whitespace()
405                    .next()
406                    .unwrap_or("")
407                    .trim_matches('"')
408                    .to_string();
409                if !col_name.is_empty()
410                    && col_name.to_uppercase() != "ASC"
411                    && col_name.to_uppercase() != "DESC"
412                {
413                    order_by_columns.push(col_name);
414                }
415            }
416        }
417
418        Some(QueryMetadata {
419            tables,
420            select_columns,
421            where_columns,
422            order_by_columns,
423            functions_used,
424            query_type,
425        })
426    }
427
428    pub fn get_recent(&self, limit: usize) -> Vec<&HistoryEntry> {
429        self.entries.iter().rev().take(limit).collect()
430    }
431
432    pub fn get_all(&self) -> &[HistoryEntry] {
433        &self.entries
434    }
435
436    pub fn get_last_entry(&self) -> Option<&HistoryEntry> {
437        self.entries.last()
438    }
439
440    /// Get session-only entries (from current run)
441    pub fn get_session_entries(&self) -> &[HistoryEntry] {
442        &self.session_entries
443    }
444
445    /// Get entries for navigation (session + starred from persistent)
446    pub fn get_navigation_entries(&self) -> Vec<HistoryEntry> {
447        let mut entries = self.session_entries.clone();
448
449        // Add starred entries from persistent history that aren't in session
450        for entry in &self.entries {
451            if entry.is_starred
452                && !self
453                    .session_entries
454                    .iter()
455                    .any(|e| e.command == entry.command)
456            {
457                entries.push(entry.clone());
458            }
459        }
460
461        // Sort by timestamp, most recent first
462        entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
463
464        // Deduplicate keeping most recent
465        let mut seen = std::collections::HashSet::new();
466        entries.retain(|e| seen.insert(e.command.clone()));
467
468        entries
469    }
470
471    /// Star/unstar a command
472    pub fn toggle_star(&mut self, command: &str) -> Result<()> {
473        // Find in entries and toggle
474        for entry in &mut self.entries {
475            if entry.command == command {
476                entry.is_starred = !entry.is_starred;
477                break;
478            }
479        }
480
481        // Also update session entries
482        for entry in &mut self.session_entries {
483            if entry.command == command {
484                entry.is_starred = !entry.is_starred;
485                break;
486            }
487        }
488
489        self.save_to_file()
490    }
491
492    pub fn clear(&mut self) -> Result<()> {
493        // SAFETY: Create backup before clearing
494        let current_count = self.entries.len();
495        if current_count > 0 {
496            eprintln!("[HISTORY WARNING] Clearing {current_count} entries - creating backup");
497            if let Ok(content) = serde_json::to_string_pretty(&self.entries) {
498                self.protection.backup_before_write(&content, current_count);
499            }
500        }
501        self.entries.clear();
502        self.command_counts.clear();
503        self.save_to_file()?;
504        Ok(())
505    }
506
507    fn load_from_file(&mut self) -> Result<()> {
508        if !self.history_file.exists() {
509            eprintln!("[History] No history file found at {:?}", self.history_file);
510            return Ok(());
511        }
512
513        eprintln!("[History] Loading history from {:?}", self.history_file);
514        let content = fs::read_to_string(&self.history_file)?;
515        if content.trim().is_empty() {
516            eprintln!("[History] History file is empty");
517            return Ok(());
518        }
519
520        // Try to parse the history file
521        let entries: Vec<HistoryEntry> = match serde_json::from_str(&content) {
522            Ok(entries) => entries,
523            Err(e) => {
524                eprintln!("[History] ERROR: Failed to parse history file: {e}");
525                eprintln!("[History] Attempting recovery from backup...");
526
527                // Try to recover from backup
528                if let Some(backup_content) = self.protection.recover_from_backup() {
529                    eprintln!("[History] Found backup, attempting to restore...");
530
531                    // Try to parse the backup
532                    match serde_json::from_str::<Vec<HistoryEntry>>(&backup_content) {
533                        Ok(backup_entries) => {
534                            eprintln!(
535                                "[History] Successfully recovered {} entries from backup",
536                                backup_entries.len()
537                            );
538
539                            // Save the recovered content to the main history file
540                            fs::write(&self.history_file, &backup_content)?;
541
542                            // Move the corrupted file for investigation
543                            let corrupted_path = self.history_file.with_extension("json.corrupted");
544                            let _ = fs::rename(
545                                self.history_file.with_extension("json"),
546                                &corrupted_path,
547                            );
548                            eprintln!("[History] Corrupted file moved to {corrupted_path:?}");
549
550                            backup_entries
551                        }
552                        Err(backup_err) => {
553                            eprintln!("[History] Backup also corrupted: {backup_err}");
554                            eprintln!("[History] Starting with empty history");
555                            Vec::new()
556                        }
557                    }
558                } else {
559                    eprintln!("[History] No backup available, starting with empty history");
560
561                    // Move the corrupted file for investigation
562                    let corrupted_path = self.history_file.with_extension("json.corrupted");
563                    let _ = fs::copy(&self.history_file, &corrupted_path);
564                    eprintln!("[History] Corrupted file copied to {corrupted_path:?}");
565
566                    Vec::new()
567                }
568            }
569        };
570        eprintln!(
571            "[History] Loaded {} entries from history file",
572            entries.len()
573        );
574        let original_count = entries.len();
575
576        // Deduplicate entries, keeping only the most recent of each command
577        // This cleans up any existing duplicates in the history file
578        let mut seen_commands = std::collections::HashSet::new();
579        let mut deduplicated = Vec::new();
580
581        // Process in reverse to keep the most recent version of each command
582        for entry in entries.into_iter().rev() {
583            if seen_commands.insert(entry.command.clone()) {
584                deduplicated.push(entry);
585            }
586        }
587
588        // Reverse back to chronological order
589        deduplicated.reverse();
590
591        // Sort by timestamp to ensure chronological order (oldest first)
592        deduplicated.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
593
594        // Log if we removed duplicates (only on first load, not every save)
595        let removed_count = original_count - deduplicated.len();
596        if removed_count > 0 {
597            eprintln!("[sql-cli] Cleaned {removed_count} duplicate commands from history");
598        }
599
600        // Rebuild command counts
601        self.command_counts.clear();
602        for entry in &deduplicated {
603            *self
604                .command_counts
605                .entry(entry.command.clone())
606                .or_insert(0) = entry.execution_count;
607        }
608
609        self.entries = deduplicated;
610        eprintln!(
611            "[History] Final history contains {} unique entries",
612            self.entries.len()
613        );
614        Ok(())
615    }
616
617    fn save_to_file(&self) -> Result<()> {
618        // SAFETY: Validate before writing
619        let new_content = serde_json::to_string_pretty(&self.entries)?;
620        let new_count = self.entries.len();
621
622        // Get current file entry count for comparison
623        let old_count = if self.history_file.exists() {
624            if let Ok(existing) = fs::read_to_string(&self.history_file) {
625                existing.matches("\"command\":").count()
626            } else {
627                0
628            }
629        } else {
630            0
631        };
632
633        // Validate the write
634        if !self
635            .protection
636            .validate_write(old_count, new_count, &new_content)
637        {
638            eprintln!("[HISTORY PROTECTION] Write blocked! Attempting recovery from backup...");
639            if let Some(backup_content) = self.protection.recover_from_backup() {
640                fs::write(&self.history_file, backup_content)?;
641                return Ok(());
642            }
643            return Err(anyhow::anyhow!("History write validation failed"));
644        }
645
646        // Create backup before significant changes
647        if old_count > 0 && (old_count != new_count || old_count > 10) {
648            self.protection.backup_before_write(&new_content, new_count);
649        }
650
651        // Use atomic write to prevent corruption from partial writes
652        // Write to a temp file first, then rename it
653        let temp_file = self.history_file.with_extension("json.tmp");
654        fs::write(&temp_file, new_content)?;
655
656        // Atomic rename (on Unix, rename is atomic)
657        fs::rename(temp_file, &self.history_file)?;
658        Ok(())
659    }
660
661    /// Clean the history file by removing duplicates and rewriting it
662    /// This is called after loading to ensure the file stays clean
663    pub fn clean_and_save(&mut self) -> Result<()> {
664        // The entries are already deduplicated in memory after loading
665        // Just save them back to clean the file
666        self.save_to_file()?;
667        Ok(())
668    }
669
670    pub fn stats(&self) -> HistoryStats {
671        let total_commands = self.entries.len();
672        let unique_commands = self.command_counts.len();
673        let successful_commands = self.entries.iter().filter(|e| e.success).count();
674        let failed_commands = total_commands - successful_commands;
675
676        let most_used = self
677            .command_counts
678            .iter()
679            .max_by_key(|(_, &count)| count)
680            .map(|(cmd, &count)| (cmd.clone(), count));
681
682        HistoryStats {
683            total_commands,
684            unique_commands,
685            successful_commands,
686            failed_commands,
687            most_used_command: most_used,
688        }
689    }
690}
691
692#[derive(Debug)]
693pub struct HistoryStats {
694    pub total_commands: usize,
695    pub unique_commands: usize,
696    pub successful_commands: usize,
697    pub failed_commands: usize,
698    pub most_used_command: Option<(String, u32)>,
699}
700
701impl Clone for CommandHistory {
702    fn clone(&self) -> Self {
703        Self {
704            entries: self.entries.clone(),
705            history_file: self.history_file.clone(),
706            matcher: SkimMatcherV2::default(), // Create new matcher
707            command_counts: self.command_counts.clone(),
708            session_id: self.session_id.clone(),
709            session_entries: self.session_entries.clone(),
710            protection: HistoryProtection::new(
711                self.history_file
712                    .parent()
713                    .unwrap_or(std::path::Path::new("."))
714                    .join("history_backups"),
715            ),
716        }
717    }
718}
719
720impl Default for CommandHistory {
721    fn default() -> Self {
722        let session_id = format!("session_{}", Utc::now().timestamp_millis());
723        Self::new().unwrap_or_else(|_| {
724            let history_file =
725                AppPaths::history_file().unwrap_or_else(|_| PathBuf::from(".sql_cli_history.json"));
726            let backup_dir = history_file
727                .parent()
728                .unwrap_or(std::path::Path::new("."))
729                .join("history_backups");
730            Self {
731                entries: Vec::new(),
732                history_file,
733                matcher: SkimMatcherV2::default(),
734                command_counts: HashMap::new(),
735                session_id,
736                session_entries: Vec::new(),
737                protection: HistoryProtection::new(backup_dir),
738            }
739        })
740    }
741}