sqry_cli/commands/
history.rs1use 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
12pub 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
25fn 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
77fn 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
129fn 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 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 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
181fn 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)); 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 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 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 let result = run_clear(&cli, Some("30d"), false);
310 assert!(result.is_ok());
311 }
312 }
313}