1use std::fs::{self, File};
8use std::io::{BufReader, BufWriter, Read};
9use std::time::{Duration, SystemTime};
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15use crate::{Config, Result, Error};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct BufferMeta {
20 pub id: Uuid,
22 pub cmd: String,
24 pub cwd: String,
26 pub exit_code: Option<i32>,
28 pub duration_ms: Option<i64>,
30 pub started_at: DateTime<Utc>,
32 pub completed_at: Option<DateTime<Utc>>,
34 pub output_size: u64,
36 pub session_id: String,
38}
39
40impl BufferMeta {
41 pub fn new(id: Uuid, cmd: &str, cwd: &str, session_id: &str) -> Self {
43 Self {
44 id,
45 cmd: cmd.to_string(),
46 cwd: cwd.to_string(),
47 exit_code: None,
48 duration_ms: None,
49 started_at: Utc::now(),
50 completed_at: None,
51 output_size: 0,
52 session_id: session_id.to_string(),
53 }
54 }
55
56 pub fn complete(&mut self, exit_code: i32, duration_ms: i64, output_size: u64) {
58 self.exit_code = Some(exit_code);
59 self.duration_ms = Some(duration_ms);
60 self.completed_at = Some(Utc::now());
61 self.output_size = output_size;
62 }
63}
64
65#[derive(Debug)]
67pub struct BufferEntry {
68 pub meta: BufferMeta,
69 pub output_path: std::path::PathBuf,
70}
71
72pub struct Buffer {
74 config: Config,
75}
76
77impl Buffer {
78 pub fn new(config: Config) -> Self {
80 Self { config }
81 }
82
83 pub fn is_enabled(&self) -> bool {
85 self.config.buffer.enabled
86 }
87
88 pub fn init(&self) -> Result<()> {
90 let dir = self.config.buffer_dir();
91 if !dir.exists() {
92 fs::create_dir_all(&dir)?;
93 #[cfg(unix)]
95 {
96 use std::os::unix::fs::PermissionsExt;
97 fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
98 }
99 }
100 Ok(())
101 }
102
103 pub fn should_exclude(&self, cmd: &str) -> bool {
107 let cmd_lower = cmd.to_lowercase();
108
109 for pattern in &self.config.hooks.ignore_patterns {
111 if matches_glob_pattern(pattern, cmd) {
112 return true;
113 }
114 }
115
116 for pattern in &self.config.buffer.exclude_patterns {
118 if matches_glob_pattern(pattern, cmd) || matches_glob_pattern(pattern, &cmd_lower) {
119 return true;
120 }
121 }
122
123 false
124 }
125
126 pub fn start_entry(&self, cmd: &str, cwd: &str, session_id: &str) -> Result<Uuid> {
128 if !self.is_enabled() {
129 return Err(Error::Config("Buffer is not enabled".to_string()));
130 }
131
132 if self.should_exclude(cmd) {
133 return Err(Error::Config("Command is excluded from buffering".to_string()));
134 }
135
136 self.init()?;
137
138 let id = Uuid::now_v7();
139 let meta = BufferMeta::new(id, cmd, cwd, session_id);
140
141 let meta_path = self.config.buffer_meta_path(&id);
143 let file = File::create(&meta_path)?;
144 #[cfg(unix)]
145 {
146 use std::os::unix::fs::PermissionsExt;
147 fs::set_permissions(&meta_path, fs::Permissions::from_mode(0o600))?;
148 }
149 serde_json::to_writer(BufWriter::new(file), &meta)?;
150
151 let output_path = self.config.buffer_output_path(&id);
153 File::create(&output_path)?;
154 #[cfg(unix)]
155 {
156 use std::os::unix::fs::PermissionsExt;
157 fs::set_permissions(&output_path, fs::Permissions::from_mode(0o600))?;
158 }
159
160 Ok(id)
161 }
162
163 pub fn complete_entry(&self, id: &Uuid, exit_code: i32, duration_ms: i64) -> Result<()> {
165 let meta_path = self.config.buffer_meta_path(id);
166 let output_path = self.config.buffer_output_path(id);
167
168 let file = File::open(&meta_path)?;
170 let mut meta: BufferMeta = serde_json::from_reader(BufReader::new(file))?;
171
172 let output_size = fs::metadata(&output_path).map(|m| m.len()).unwrap_or(0);
174
175 meta.complete(exit_code, duration_ms, output_size);
177
178 let file = File::create(&meta_path)?;
180 serde_json::to_writer(BufWriter::new(file), &meta)?;
181
182 self.rotate()?;
184
185 Ok(())
186 }
187
188 pub fn write_complete_entry(
192 &self,
193 cmd: &str,
194 cwd: &str,
195 session_id: &str,
196 exit_code: i32,
197 duration_ms: Option<i64>,
198 output: Option<&[u8]>,
199 ) -> Result<Uuid> {
200 if !self.is_enabled() {
201 return Err(Error::Config("Buffer is not enabled".to_string()));
202 }
203
204 if self.should_exclude(cmd) {
205 return Err(Error::Config("Command is excluded from buffering".to_string()));
206 }
207
208 self.init()?;
209
210 let id = Uuid::now_v7();
211 let mut meta = BufferMeta::new(id, cmd, cwd, session_id);
212
213 let output_path = self.config.buffer_output_path(&id);
215 let output_size = if let Some(data) = output {
216 fs::write(&output_path, data)?;
217 #[cfg(unix)]
218 {
219 use std::os::unix::fs::PermissionsExt;
220 fs::set_permissions(&output_path, fs::Permissions::from_mode(0o600))?;
221 }
222 data.len() as u64
223 } else {
224 File::create(&output_path)?;
225 #[cfg(unix)]
226 {
227 use std::os::unix::fs::PermissionsExt;
228 fs::set_permissions(&output_path, fs::Permissions::from_mode(0o600))?;
229 }
230 0
231 };
232
233 meta.complete(exit_code, duration_ms.unwrap_or(0), output_size);
235
236 let meta_path = self.config.buffer_meta_path(&id);
238 let file = File::create(&meta_path)?;
239 #[cfg(unix)]
240 {
241 use std::os::unix::fs::PermissionsExt;
242 fs::set_permissions(&meta_path, fs::Permissions::from_mode(0o600))?;
243 }
244 serde_json::to_writer(BufWriter::new(file), &meta)?;
245
246 self.rotate()?;
248
249 Ok(id)
250 }
251
252 pub fn list_entries(&self) -> Result<Vec<BufferEntry>> {
254 let dir = self.config.buffer_dir();
255 if !dir.exists() {
256 return Ok(Vec::new());
257 }
258
259 let mut entries = Vec::new();
260
261 for entry in fs::read_dir(&dir)? {
262 let entry = entry?;
263 let path = entry.path();
264
265 if path.extension().map(|e| e == "meta").unwrap_or(false) {
267 if let Ok(file) = File::open(&path) {
268 if let Ok(meta) = serde_json::from_reader::<_, BufferMeta>(BufReader::new(file)) {
269 let output_path = self.config.buffer_output_path(&meta.id);
270 if output_path.exists() {
271 entries.push(BufferEntry { meta, output_path });
272 }
273 }
274 }
275 }
276 }
277
278 entries.sort_by(|a, b| b.meta.started_at.cmp(&a.meta.started_at));
280
281 Ok(entries)
282 }
283
284 pub fn get_by_position(&self, position: usize) -> Result<Option<BufferEntry>> {
286 let entries = self.list_entries()?;
287 Ok(entries.into_iter().nth(position.saturating_sub(1)))
288 }
289
290 pub fn get_by_id(&self, id: &Uuid) -> Result<Option<BufferEntry>> {
292 let meta_path = self.config.buffer_meta_path(id);
293 let output_path = self.config.buffer_output_path(id);
294
295 if !meta_path.exists() || !output_path.exists() {
296 return Ok(None);
297 }
298
299 let file = File::open(&meta_path)?;
300 let meta: BufferMeta = serde_json::from_reader(BufReader::new(file))?;
301
302 Ok(Some(BufferEntry { meta, output_path }))
303 }
304
305 pub fn read_output(&self, entry: &BufferEntry) -> Result<Vec<u8>> {
307 let mut content = Vec::new();
308 File::open(&entry.output_path)?.read_to_end(&mut content)?;
309 Ok(content)
310 }
311
312 pub fn delete_entry(&self, id: &Uuid) -> Result<()> {
314 let meta_path = self.config.buffer_meta_path(id);
315 let output_path = self.config.buffer_output_path(id);
316
317 if meta_path.exists() {
318 fs::remove_file(&meta_path)?;
319 }
320 if output_path.exists() {
321 fs::remove_file(&output_path)?;
322 }
323
324 Ok(())
325 }
326
327 pub fn clear(&self) -> Result<usize> {
329 let entries = self.list_entries()?;
330 let count = entries.len();
331
332 for entry in entries {
333 self.delete_entry(&entry.meta.id)?;
334 }
335
336 Ok(count)
337 }
338
339 pub fn rotate(&self) -> Result<usize> {
341 let mut entries = self.list_entries()?;
342 let mut removed = 0;
343
344 let max_entries = self.config.buffer.max_entries;
345 let max_size_bytes = self.config.buffer.max_size_mb * 1024 * 1024;
346 let max_age = Duration::from_secs(self.config.buffer.max_age_hours as u64 * 3600);
347 let now = SystemTime::now();
348
349 let mut total_size: u64 = entries.iter().map(|e| e.meta.output_size).sum();
351
352 entries.reverse();
354 let mut keep_count = 0;
355
356 for entry in entries {
357 let should_remove =
358 keep_count >= max_entries ||
360 total_size > max_size_bytes as u64 ||
362 entry.meta.started_at.signed_duration_since(
364 DateTime::<Utc>::from(now - max_age)
365 ).num_seconds() < 0;
366
367 if should_remove {
368 total_size = total_size.saturating_sub(entry.meta.output_size);
369 self.delete_entry(&entry.meta.id)?;
370 removed += 1;
371 } else {
372 keep_count += 1;
373 }
374 }
375
376 Ok(removed)
377 }
378}
379
380fn matches_glob_pattern(pattern: &str, text: &str) -> bool {
386 if !pattern.contains('*') {
387 return pattern.eq_ignore_ascii_case(text);
388 }
389
390 let pattern_lower = pattern.to_lowercase();
391 let text_lower = text.to_lowercase();
392
393 let parts: Vec<&str> = pattern_lower.split('*').collect();
394
395 if parts.is_empty() {
396 return true;
397 }
398
399 let mut pos = 0;
400
401 if !pattern_lower.starts_with('*') {
403 if !text_lower.starts_with(parts[0]) {
404 return false;
405 }
406 pos = parts[0].len();
407 }
408
409 for part in parts.iter().skip(if pattern_lower.starts_with('*') { 0 } else { 1 }) {
411 if part.is_empty() {
412 continue;
413 }
414 if let Some(found) = text_lower[pos..].find(part) {
415 pos += found + part.len();
416 } else {
417 return false;
418 }
419 }
420
421 if !pattern_lower.ends_with('*') && !parts.is_empty() {
423 let last = parts.last().unwrap();
424 if !last.is_empty() && !text_lower.ends_with(last) {
425 return false;
426 }
427 }
428
429 true
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
437 fn test_glob_pattern_exact() {
438 assert!(matches_glob_pattern("exit", "exit"));
439 assert!(matches_glob_pattern("exit", "EXIT"));
440 assert!(!matches_glob_pattern("exit", "exit 0"));
441 }
442
443 #[test]
444 fn test_glob_pattern_star_end() {
445 assert!(matches_glob_pattern("shq *", "shq run"));
446 assert!(matches_glob_pattern("shq *", "shq show foo"));
447 assert!(!matches_glob_pattern("shq *", "blq run"));
448 }
449
450 #[test]
451 fn test_glob_pattern_star_middle() {
452 assert!(matches_glob_pattern("*password*", "echo password123"));
453 assert!(matches_glob_pattern("*password*", "PASSWORD_FILE"));
454 assert!(matches_glob_pattern("*password*", "my_password_var"));
455 assert!(!matches_glob_pattern("*password*", "echo hello"));
456 }
457
458 #[test]
459 fn test_glob_pattern_star_start() {
460 assert!(matches_glob_pattern("*token", "my_token"));
461 assert!(matches_glob_pattern("*token", "API_TOKEN"));
462 assert!(!matches_glob_pattern("*token", "token_var"));
463 }
464
465 #[test]
466 fn test_glob_pattern_case_insensitive() {
467 assert!(matches_glob_pattern("*SECRET*", "my_secret_key"));
468 assert!(matches_glob_pattern("*secret*", "MY_SECRET_KEY"));
469 }
470
471 #[test]
472 fn test_buffer_exclude_patterns() {
473 let config = Config::with_root("/tmp/test");
474 let buffer = Buffer::new(config);
475
476 assert!(buffer.should_exclude("ssh user@host"));
478 assert!(buffer.should_exclude("gpg --decrypt file"));
479 assert!(buffer.should_exclude("export API_TOKEN=xxx"));
480 assert!(buffer.should_exclude("printenv"));
481
482 assert!(!buffer.should_exclude("cargo build"));
484 assert!(!buffer.should_exclude("git status"));
485 assert!(!buffer.should_exclude("make test"));
486 }
487}