Skip to main content

sqry_cli/commands/
history.rs

1//! History management command implementation
2//!
3//! Handles the `sqry history` subcommand for managing query history.
4
5use crate::args::{Cli, HistoryAction};
6use crate::output::OutputStreams;
7use crate::persistence::{HistoryManager, PersistenceConfig, open_shared_index, parse_duration};
8use anyhow::{Result, bail};
9use chrono::Utc;
10use std::path::Path;
11
12/// Run the history command.
13///
14/// # Errors
15/// Returns an error if history cannot be loaded or written.
16pub fn run_history(cli: &Cli, action: &HistoryAction) -> Result<()> {
17    match action {
18        HistoryAction::List { limit } => run_list(cli, *limit),
19        HistoryAction::Search { pattern, limit } => run_search(cli, pattern, *limit),
20        HistoryAction::Clear { older, confirm } => run_clear(cli, older.as_deref(), *confirm),
21        HistoryAction::Stats => run_stats(cli),
22    }
23}
24
25/// List recent history entries
26fn run_list(cli: &Cli, limit: usize) -> Result<()> {
27    let config = PersistenceConfig::from_env();
28    let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
29    let manager = HistoryManager::new(index);
30    let mut streams = OutputStreams::with_pager(cli.pager_config());
31
32    let entries = manager.list(limit)?;
33
34    if cli.json {
35        let json_entries: Vec<_> = entries
36            .iter()
37            .map(|e| {
38                serde_json::json!({
39                    "id": e.id,
40                    "timestamp": e.timestamp.to_rfc3339(),
41                    "command": e.command,
42                    "args": e.args,
43                    "working_dir": e.working_dir,
44                    "success": e.success,
45                    "duration_ms": e.duration_ms,
46                })
47            })
48            .collect();
49        let output = serde_json::to_string_pretty(&json_entries)?;
50        streams.write_result(&output)?;
51    } else if entries.is_empty() {
52        streams.write_result("No history entries.\n")?;
53    } else {
54        streams.write_result(&format!("History ({} entries):\n\n", entries.len()))?;
55        for entry in &entries {
56            let status = if entry.success { "" } else { " [FAILED]" };
57            let duration = entry
58                .duration_ms
59                .map(|d| format!(" ({d}ms)"))
60                .unwrap_or_default();
61            let args_str = if entry.args.is_empty() {
62                String::new()
63            } else {
64                format!(" {}", entry.args.join(" "))
65            };
66            let timestamp = entry.timestamp.format("%Y-%m-%d %H:%M:%S");
67            streams.write_result(&format!(
68                "  [{timestamp}] {}{}{}{}\n",
69                entry.command, args_str, status, duration
70            ))?;
71        }
72    }
73
74    streams.finish_checked()
75}
76
77/// Search history entries
78fn run_search(cli: &Cli, pattern: &str, limit: usize) -> Result<()> {
79    let config = PersistenceConfig::from_env();
80    let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
81    let manager = HistoryManager::new(index);
82    let mut streams = OutputStreams::with_pager(cli.pager_config());
83
84    let entries = manager.search(pattern, limit)?;
85
86    if cli.json {
87        let json_entries: Vec<_> = entries
88            .iter()
89            .map(|e| {
90                serde_json::json!({
91                    "id": e.id,
92                    "timestamp": e.timestamp.to_rfc3339(),
93                    "command": e.command,
94                    "args": e.args,
95                    "working_dir": e.working_dir,
96                    "success": e.success,
97                    "duration_ms": e.duration_ms,
98                })
99            })
100            .collect();
101        let output = serde_json::to_string_pretty(&json_entries)?;
102        streams.write_result(&output)?;
103    } else if entries.is_empty() {
104        streams.write_result(&format!("No history entries matching '{pattern}'.\n"))?;
105    } else {
106        streams.write_result(&format!(
107            "History entries matching '{}' ({}):\n\n",
108            pattern,
109            entries.len()
110        ))?;
111        for entry in &entries {
112            let status = if entry.success { "" } else { " [FAILED]" };
113            let args_str = if entry.args.is_empty() {
114                String::new()
115            } else {
116                format!(" {}", entry.args.join(" "))
117            };
118            let timestamp = entry.timestamp.format("%Y-%m-%d %H:%M:%S");
119            streams.write_result(&format!(
120                "  [{timestamp}] {}{}{}\n",
121                entry.command, args_str, status
122            ))?;
123        }
124    }
125
126    streams.finish_checked()
127}
128
129/// Clear history entries
130fn run_clear(cli: &Cli, older: Option<&str>, confirm: bool) -> Result<()> {
131    let config = PersistenceConfig::from_env();
132    let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
133    let manager = HistoryManager::new(index);
134    let mut streams = OutputStreams::with_pager(cli.pager_config());
135
136    if let Some(duration_str) = older {
137        // Clear entries older than the specified duration
138        let duration = parse_duration(duration_str)
139            .map_err(|e| anyhow::anyhow!("Invalid duration format '{duration_str}': {e}"))?;
140        let cutoff = Utc::now() - duration;
141
142        let cleared = manager.clear_older_than(cutoff)?;
143
144        if cli.json {
145            let output = serde_json::json!({
146                "cleared": cleared,
147                "older_than": duration_str,
148            });
149            streams.write_result(&serde_json::to_string_pretty(&output)?)?;
150        } else {
151            streams.write_result(&format!(
152                "Cleared {cleared} history entries older than {duration_str}.\n"
153            ))?;
154        }
155    } else {
156        // Clear all - requires confirmation
157        if !confirm && !cli.json {
158            streams.write_result("Clear ALL history entries? This cannot be undone.\n")?;
159            streams.write_result(
160                "Use --confirm to confirm, or --older <duration> to clear selectively.\n",
161            )?;
162            streams.finish_checked()?;
163            bail!("Confirmation required to clear all history");
164        }
165
166        let cleared = manager.clear()?;
167
168        if cli.json {
169            let output = serde_json::json!({
170                "cleared": cleared,
171            });
172            streams.write_result(&serde_json::to_string_pretty(&output)?)?;
173        } else {
174            streams.write_result(&format!("Cleared {cleared} history entries.\n"))?;
175        }
176    }
177
178    streams.finish_checked()
179}
180
181/// Show history statistics
182fn run_stats(cli: &Cli) -> Result<()> {
183    let config = PersistenceConfig::from_env();
184    let index = open_shared_index(Some(Path::new(cli.search_path())), config)?;
185    let manager = HistoryManager::new(index);
186    let mut streams = OutputStreams::with_pager(cli.pager_config());
187
188    let stats = manager.stats()?;
189
190    if cli.json {
191        let output = serde_json::json!({
192            "total_entries": stats.total_entries,
193            "oldest_entry": stats.oldest_entry.map(|t| t.to_rfc3339()),
194            "newest_entry": stats.newest_entry.map(|t| t.to_rfc3339()),
195            "success_count": stats.success_count,
196            "failure_count": stats.failure_count,
197            "commands": stats.command_counts,
198        });
199        streams.write_result(&serde_json::to_string_pretty(&output)?)?;
200    } else {
201        streams.write_result("History Statistics:\n\n")?;
202        streams.write_result(&format!("  Total entries: {}\n", stats.total_entries))?;
203
204        if let Some(oldest) = stats.oldest_entry {
205            streams.write_result(&format!(
206                "  Oldest entry: {}\n",
207                oldest.format("%Y-%m-%d %H:%M:%S")
208            ))?;
209        }
210        if let Some(newest) = stats.newest_entry {
211            streams.write_result(&format!(
212                "  Newest entry: {}\n",
213                newest.format("%Y-%m-%d %H:%M:%S")
214            ))?;
215        }
216
217        streams.write_result(&format!("  Successful: {}\n", stats.success_count))?;
218        streams.write_result(&format!("  Failed: {}\n", stats.failure_count))?;
219
220        if !stats.command_counts.is_empty() {
221            streams.write_result("\n  Commands used:\n")?;
222            let mut counts: Vec<_> = stats.command_counts.iter().collect();
223            counts.sort_by(|a, b| b.1.cmp(a.1)); // Sort by count descending
224            for (cmd, count) in counts {
225                streams.write_result(&format!("    {cmd}: {count}\n"))?;
226            }
227        }
228    }
229
230    streams.finish_checked()
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::args::Cli;
237    use crate::large_stack_test;
238    use clap::Parser;
239    use serial_test::serial;
240    use tempfile::TempDir;
241
242    fn create_test_cli(args: &[&str]) -> Cli {
243        let mut full_args = vec!["sqry"];
244        full_args.extend(args);
245        // Use isolated config dir for tests to avoid host state
246        unsafe {
247            std::env::set_var("SQRY_CONFIG_DIR", args.last().unwrap_or(&"."));
248        }
249        Cli::parse_from(full_args)
250    }
251
252    large_stack_test! {
253    #[test]
254    #[serial]
255    fn test_history_list_empty() {
256        let temp_dir = TempDir::new().unwrap();
257        let cli = create_test_cli(&[&temp_dir.path().to_string_lossy()]);
258
259        let result = run_list(&cli, 100);
260        assert!(result.is_ok());
261    }
262    }
263
264    large_stack_test! {
265    #[test]
266    #[serial]
267    fn test_history_search_empty() {
268        let temp_dir = TempDir::new().unwrap();
269        let cli = create_test_cli(&[&temp_dir.path().to_string_lossy()]);
270
271        let result = run_search(&cli, "test", 100);
272        assert!(result.is_ok());
273    }
274    }
275
276    large_stack_test! {
277    #[test]
278    #[serial]
279    fn test_history_stats_empty() {
280        let temp_dir = TempDir::new().unwrap();
281        let cli = create_test_cli(&[&temp_dir.path().to_string_lossy()]);
282
283        let result = run_stats(&cli);
284        assert!(result.is_ok());
285    }
286    }
287
288    large_stack_test! {
289    #[test]
290    #[serial]
291    fn test_history_clear_requires_confirm() {
292        let temp_dir = TempDir::new().unwrap();
293        let cli = create_test_cli(&[&temp_dir.path().to_string_lossy()]);
294
295        // Without --confirm, should fail
296        let result = run_clear(&cli, None, false);
297        assert!(result.is_err());
298    }
299    }
300
301    large_stack_test! {
302    #[test]
303    #[serial]
304    fn test_history_clear_with_older() {
305        let temp_dir = TempDir::new().unwrap();
306        let cli = create_test_cli(&[&temp_dir.path().to_string_lossy()]);
307
308        // With --older, should succeed
309        let result = run_clear(&cli, Some("30d"), false);
310        assert!(result.is_ok());
311    }
312    }
313}