Skip to main content

magic_bird/
buffer.rs

1//! Retrospective buffer for capturing shell command output.
2//!
3//! The buffer allows users to retroactively save commands they didn't explicitly
4//! capture with `shq run`. Shell hooks write output to the buffer, and users can
5//! promote entries to permanent storage with `shq save ~N`.
6
7use 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/// Metadata for a buffer entry.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct BufferMeta {
20    /// Unique identifier for this entry.
21    pub id: Uuid,
22    /// Command that was executed.
23    pub cmd: String,
24    /// Working directory when command was executed.
25    pub cwd: String,
26    /// Exit code (None if command is still running or was killed).
27    pub exit_code: Option<i32>,
28    /// Duration in milliseconds.
29    pub duration_ms: Option<i64>,
30    /// When the command started.
31    pub started_at: DateTime<Utc>,
32    /// When the command completed (None if still running).
33    pub completed_at: Option<DateTime<Utc>>,
34    /// Size of output in bytes.
35    pub output_size: u64,
36    /// Session ID for grouping.
37    pub session_id: String,
38}
39
40impl BufferMeta {
41    /// Create new buffer metadata.
42    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    /// Mark as completed with exit code and duration.
57    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/// A buffer entry with metadata and output path.
66#[derive(Debug)]
67pub struct BufferEntry {
68    pub meta: BufferMeta,
69    pub output_path: std::path::PathBuf,
70}
71
72/// Manager for the retrospective buffer.
73pub struct Buffer {
74    config: Config,
75}
76
77impl Buffer {
78    /// Create a new buffer manager.
79    pub fn new(config: Config) -> Self {
80        Self { config }
81    }
82
83    /// Check if buffering is enabled.
84    pub fn is_enabled(&self) -> bool {
85        self.config.buffer.enabled
86    }
87
88    /// Initialize the buffer directory with secure permissions.
89    pub fn init(&self) -> Result<()> {
90        let dir = self.config.buffer_dir();
91        if !dir.exists() {
92            fs::create_dir_all(&dir)?;
93            // Set directory permissions to 700 (owner only)
94            #[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    /// Check if a command should be excluded from buffering.
104    ///
105    /// Combines hooks.ignore_patterns and buffer.exclude_patterns.
106    pub fn should_exclude(&self, cmd: &str) -> bool {
107        let cmd_lower = cmd.to_lowercase();
108
109        // Check hooks ignore patterns
110        for pattern in &self.config.hooks.ignore_patterns {
111            if matches_glob_pattern(pattern, cmd) {
112                return true;
113            }
114        }
115
116        // Check buffer-specific exclude patterns
117        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    /// Start a new buffer entry. Returns the entry ID for writing output.
127    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        // Write initial metadata
142        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        // Create empty output file
152        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    /// Complete a buffer entry with exit code and duration.
164    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        // Read existing metadata
169        let file = File::open(&meta_path)?;
170        let mut meta: BufferMeta = serde_json::from_reader(BufReader::new(file))?;
171
172        // Get output size
173        let output_size = fs::metadata(&output_path).map(|m| m.len()).unwrap_or(0);
174
175        // Update metadata
176        meta.complete(exit_code, duration_ms, output_size);
177
178        // Write updated metadata
179        let file = File::create(&meta_path)?;
180        serde_json::to_writer(BufWriter::new(file), &meta)?;
181
182        // Rotate if needed
183        self.rotate()?;
184
185        Ok(())
186    }
187
188    /// Write a complete buffer entry at once (for `shq save --to-buffer`).
189    ///
190    /// This is used when all data is available at once (command already completed).
191    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        // Write output file
214        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        // Complete the metadata
234        meta.complete(exit_code, duration_ms.unwrap_or(0), output_size);
235
236        // Write metadata file
237        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        // Rotate if needed
247        self.rotate()?;
248
249        Ok(id)
250    }
251
252    /// List all buffer entries, sorted by start time (most recent first).
253    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            // Only process .meta files
266            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        // Sort by start time, most recent first
279        entries.sort_by(|a, b| b.meta.started_at.cmp(&a.meta.started_at));
280
281        Ok(entries)
282    }
283
284    /// Get a buffer entry by position (1 = most recent).
285    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    /// Get a buffer entry by ID.
291    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    /// Read output from a buffer entry.
306    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    /// Delete a buffer entry.
313    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    /// Clear all buffer entries.
328    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    /// Rotate buffer entries based on configured limits.
340    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        // Calculate total size
350        let mut total_size: u64 = entries.iter().map(|e| e.meta.output_size).sum();
351
352        // Remove entries that exceed limits (oldest first, so reverse)
353        entries.reverse();
354        let mut keep_count = 0;
355
356        for entry in entries {
357            let should_remove =
358                // Exceeded max entries
359                keep_count >= max_entries ||
360                // Exceeded max size
361                total_size > max_size_bytes as u64 ||
362                // Exceeded max age
363                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
380/// Simple glob pattern matching.
381///
382/// Supports:
383/// - `*` matches any sequence of characters
384/// - Case-insensitive matching for patterns with `*`
385fn 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    // First part must match at start (unless pattern starts with *)
402    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    // Middle parts must appear in order
410    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    // Last part must match at end (unless pattern ends with *)
422    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        // Should exclude sensitive commands
477        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        // Should not exclude normal commands
483        assert!(!buffer.should_exclude("cargo build"));
484        assert!(!buffer.should_exclude("git status"));
485        assert!(!buffer.should_exclude("make test"));
486    }
487}