Skip to main content

par_term/
session_logger.rs

1//! Session logging and recording for terminal sessions.
2//!
3//! This module provides automatic session logging with support for multiple formats:
4//! - Plain text: Simple output without escape sequences
5//! - HTML: Rendered output with colors preserved
6//! - Asciicast: asciinema-compatible format for replay/sharing
7
8use crate::config::SessionLogFormat;
9use anyhow::Result;
10use chrono::{Local, Utc};
11use par_term_emu_core_rust::terminal::{RecordingEvent, RecordingEventType, RecordingSession};
12use parking_lot::Mutex;
13use std::fs::File;
14use std::io::{BufWriter, Write};
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18/// Session logger that records terminal output to files.
19///
20/// The logger captures PTY output with timestamps and can export
21/// to multiple formats. It uses buffered writes for performance.
22pub struct SessionLogger {
23    /// Whether logging is currently active
24    active: bool,
25    /// The log format to use
26    format: SessionLogFormat,
27    /// Output file path
28    output_path: PathBuf,
29    /// Buffered writer for the log file
30    writer: Option<BufWriter<File>>,
31    /// Recording session data (for asciicast format)
32    recording: Option<RecordingSession>,
33    /// Recording start time (for relative timestamps)
34    start_time: std::time::Instant,
35    /// Terminal dimensions
36    dimensions: (usize, usize),
37    /// Session title
38    title: Option<String>,
39}
40
41impl SessionLogger {
42    /// Create a new session logger.
43    ///
44    /// # Arguments
45    /// * `format` - The output format to use
46    /// * `log_dir` - Directory where log files are stored
47    /// * `dimensions` - Terminal dimensions (cols, rows)
48    /// * `title` - Optional session title
49    pub fn new(
50        format: SessionLogFormat,
51        log_dir: &Path,
52        dimensions: (usize, usize),
53        title: Option<String>,
54    ) -> Result<Self> {
55        // Generate filename with timestamp
56        let timestamp = Local::now().format("%Y%m%d_%H%M%S");
57        let filename = format!("session_{}.{}", timestamp, format.extension());
58        let output_path = log_dir.join(filename);
59
60        log::info!(
61            "Creating session logger: {:?} (format: {:?})",
62            output_path,
63            format
64        );
65
66        // Create the log file
67        let file = File::create(&output_path)?;
68        let writer = BufWriter::with_capacity(8192, file); // 8KB buffer
69
70        // Initialize recording session for asciicast format
71        let recording = if format == SessionLogFormat::Asciicast {
72            let mut env = std::collections::HashMap::new();
73            env.insert("TERM".to_string(), "xterm-256color".to_string());
74            env.insert("COLS".to_string(), dimensions.0.to_string());
75            env.insert("ROWS".to_string(), dimensions.1.to_string());
76
77            Some(RecordingSession {
78                id: uuid::Uuid::new_v4().to_string(),
79                created_at: Utc::now().timestamp_millis() as u64,
80                initial_size: dimensions,
81                env,
82                events: Vec::new(),
83                duration: 0,
84                title: title
85                    .clone()
86                    .unwrap_or_else(|| "Terminal Recording".to_string()),
87            })
88        } else {
89            None
90        };
91
92        Ok(Self {
93            active: false,
94            format,
95            output_path,
96            writer: Some(writer),
97            recording,
98            start_time: std::time::Instant::now(),
99            dimensions,
100            title,
101        })
102    }
103
104    /// Start logging.
105    pub fn start(&mut self) -> Result<()> {
106        if self.active {
107            return Ok(());
108        }
109
110        self.active = true;
111        self.start_time = std::time::Instant::now();
112
113        // Write header for HTML format
114        if self.format == SessionLogFormat::Html {
115            self.write_html_header()?;
116        }
117
118        log::info!("Session logging started: {:?}", self.output_path);
119        Ok(())
120    }
121
122    /// Stop logging and finalize the log file.
123    pub fn stop(&mut self) -> Result<PathBuf> {
124        if !self.active {
125            return Ok(self.output_path.clone());
126        }
127
128        self.active = false;
129
130        // Finalize based on format
131        match self.format {
132            SessionLogFormat::Plain => {
133                // Nothing special needed
134            }
135            SessionLogFormat::Html => {
136                self.write_html_footer()?;
137            }
138            SessionLogFormat::Asciicast => {
139                self.write_asciicast()?;
140            }
141        }
142
143        // Flush and close the writer
144        if let Some(mut writer) = self.writer.take() {
145            writer.flush()?;
146        }
147
148        log::info!("Session logging stopped: {:?}", self.output_path);
149        Ok(self.output_path.clone())
150    }
151
152    /// Record output data from the terminal.
153    pub fn record_output(&mut self, data: &[u8]) {
154        if !self.active {
155            return;
156        }
157
158        let elapsed = self.start_time.elapsed().as_millis() as u64;
159
160        match self.format {
161            SessionLogFormat::Plain => {
162                // Strip ANSI escape sequences and write plain text
163                let text = strip_ansi_escapes(data);
164                if let Some(ref mut writer) = self.writer {
165                    let _ = writer.write_all(text.as_bytes());
166                }
167            }
168            SessionLogFormat::Html => {
169                // Convert to HTML (basic escaping for now)
170                let text = String::from_utf8_lossy(data);
171                let escaped = html_escape(&text);
172                if let Some(ref mut writer) = self.writer {
173                    let _ = writer.write_all(escaped.as_bytes());
174                }
175            }
176            SessionLogFormat::Asciicast => {
177                // Add event to recording
178                if let Some(ref mut recording) = self.recording {
179                    recording.events.push(RecordingEvent {
180                        timestamp: elapsed,
181                        event_type: RecordingEventType::Output,
182                        data: data.to_vec(),
183                        metadata: None,
184                    });
185                    recording.duration = elapsed;
186                }
187            }
188        }
189    }
190
191    /// Record input data (keyboard input).
192    pub fn record_input(&mut self, data: &[u8]) {
193        if !self.active {
194            return;
195        }
196
197        // Only asciicast records input
198        if self.format == SessionLogFormat::Asciicast {
199            let elapsed = self.start_time.elapsed().as_millis() as u64;
200            if let Some(ref mut recording) = self.recording {
201                recording.events.push(RecordingEvent {
202                    timestamp: elapsed,
203                    event_type: RecordingEventType::Input,
204                    data: data.to_vec(),
205                    metadata: None,
206                });
207                recording.duration = elapsed;
208            }
209        }
210    }
211
212    /// Record a terminal resize event.
213    pub fn record_resize(&mut self, cols: usize, rows: usize) {
214        if !self.active {
215            return;
216        }
217
218        self.dimensions = (cols, rows);
219
220        // Only asciicast records resize events
221        if self.format == SessionLogFormat::Asciicast {
222            let elapsed = self.start_time.elapsed().as_millis() as u64;
223            if let Some(ref mut recording) = self.recording {
224                recording.events.push(RecordingEvent {
225                    timestamp: elapsed,
226                    event_type: RecordingEventType::Resize,
227                    data: Vec::new(),
228                    metadata: Some((cols, rows)),
229                });
230                recording.duration = elapsed;
231            }
232        }
233    }
234
235    /// Check if logging is active.
236    pub fn is_active(&self) -> bool {
237        self.active
238    }
239
240    /// Get the output path.
241    pub fn output_path(&self) -> &PathBuf {
242        &self.output_path
243    }
244
245    /// Flush buffered data to disk.
246    pub fn flush(&mut self) -> Result<()> {
247        if let Some(ref mut writer) = self.writer {
248            writer.flush()?;
249        }
250        Ok(())
251    }
252
253    // === Private helper methods ===
254
255    fn write_html_header(&mut self) -> Result<()> {
256        let header = format!(
257            r#"<!DOCTYPE html>
258<html>
259<head>
260    <meta charset="UTF-8">
261    <title>{}</title>
262    <style>
263        body {{
264            background-color: #1e1e1e;
265            color: #d4d4d4;
266            font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
267            font-size: 14px;
268            padding: 20px;
269            white-space: pre-wrap;
270            word-wrap: break-word;
271        }}
272        .timestamp {{
273            color: #808080;
274            font-size: 10px;
275        }}
276    </style>
277</head>
278<body>
279<pre>
280"#,
281            self.title.as_deref().unwrap_or("Terminal Session")
282        );
283
284        if let Some(ref mut writer) = self.writer {
285            writer.write_all(header.as_bytes())?;
286        }
287        Ok(())
288    }
289
290    fn write_html_footer(&mut self) -> Result<()> {
291        let footer = r#"
292</pre>
293</body>
294</html>
295"#;
296        if let Some(ref mut writer) = self.writer {
297            writer.write_all(footer.as_bytes())?;
298        }
299        Ok(())
300    }
301
302    fn write_asciicast(&mut self) -> Result<()> {
303        if let Some(ref recording) = self.recording {
304            // Write asciicast v2 format
305            // Header line (JSON object)
306            let header = serde_json::json!({
307                "version": 2,
308                "width": recording.initial_size.0,
309                "height": recording.initial_size.1,
310                "timestamp": recording.created_at / 1000, // Convert to seconds
311                "title": &recording.title,
312                "env": recording.env,
313            });
314
315            if let Some(ref mut writer) = self.writer {
316                writeln!(writer, "{}", header)?;
317
318                // Event lines (JSON arrays)
319                for event in &recording.events {
320                    let time_seconds = event.timestamp as f64 / 1000.0;
321
322                    match event.event_type {
323                        RecordingEventType::Output => {
324                            let data_str = String::from_utf8_lossy(&event.data);
325                            let line = serde_json::json!([time_seconds, "o", data_str]);
326                            writeln!(writer, "{}", line)?;
327                        }
328                        RecordingEventType::Input => {
329                            let data_str = String::from_utf8_lossy(&event.data);
330                            let line = serde_json::json!([time_seconds, "i", data_str]);
331                            writeln!(writer, "{}", line)?;
332                        }
333                        RecordingEventType::Resize => {
334                            if let Some((cols, rows)) = event.metadata {
335                                let line = serde_json::json!([
336                                    time_seconds,
337                                    "r",
338                                    format!("{}x{}", cols, rows)
339                                ]);
340                                writeln!(writer, "{}", line)?;
341                            }
342                        }
343                        RecordingEventType::Marker => {
344                            let label = String::from_utf8_lossy(&event.data);
345                            let line = serde_json::json!([time_seconds, "m", label]);
346                            writeln!(writer, "{}", line)?;
347                        }
348                        RecordingEventType::Metadata => {
349                            // Metadata events store key-value pairs; emit as asciicast marker
350                            let data_str = String::from_utf8_lossy(&event.data);
351                            let line = serde_json::json!([time_seconds, "m", data_str]);
352                            writeln!(writer, "{}", line)?;
353                        }
354                    }
355                }
356            }
357        }
358        Ok(())
359    }
360}
361
362impl Drop for SessionLogger {
363    fn drop(&mut self) {
364        if self.active {
365            let _ = self.stop();
366        }
367    }
368}
369
370/// Thread-safe wrapper for SessionLogger
371pub type SharedSessionLogger = Arc<Mutex<Option<SessionLogger>>>;
372
373/// Create a new shared session logger
374pub fn create_shared_logger() -> SharedSessionLogger {
375    Arc::new(Mutex::new(None))
376}
377
378// === Helper functions ===
379
380/// Strip ANSI escape sequences from text
381fn strip_ansi_escapes(data: &[u8]) -> String {
382    let text = String::from_utf8_lossy(data);
383    let mut result = String::with_capacity(text.len());
384    let mut chars = text.chars().peekable();
385
386    while let Some(c) = chars.next() {
387        if c == '\x1b' {
388            // ESC sequence - skip until we hit the terminator
389            if let Some(&next) = chars.peek() {
390                if next == '[' {
391                    // CSI sequence - skip until we hit a letter
392                    chars.next(); // consume '['
393                    while let Some(&c) = chars.peek() {
394                        chars.next();
395                        if c.is_ascii_alphabetic() || c == '@' || c == '`' {
396                            break;
397                        }
398                    }
399                } else if next == ']' {
400                    // OSC sequence - skip until BEL or ST
401                    chars.next(); // consume ']'
402                    while let Some(c) = chars.next() {
403                        if c == '\x07' {
404                            break;
405                        }
406                        if c == '\x1b'
407                            && let Some(&'\\') = chars.peek()
408                        {
409                            chars.next();
410                            break;
411                        }
412                    }
413                } else if next == '(' || next == ')' || next == '*' || next == '+' {
414                    // Character set designation - skip one more char
415                    chars.next();
416                    chars.next();
417                } else {
418                    // Other ESC sequence - skip one char
419                    chars.next();
420                }
421            }
422        } else {
423            result.push(c);
424        }
425    }
426
427    result
428}
429
430/// Escape HTML special characters
431fn html_escape(text: &str) -> String {
432    let mut result = String::with_capacity(text.len());
433    for c in text.chars() {
434        match c {
435            '<' => result.push_str("&lt;"),
436            '>' => result.push_str("&gt;"),
437            '&' => result.push_str("&amp;"),
438            '"' => result.push_str("&quot;"),
439            '\'' => result.push_str("&#39;"),
440            _ => result.push(c),
441        }
442    }
443    result
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use tempfile::TempDir;
450
451    #[test]
452    fn test_strip_ansi_escapes() {
453        // Simple text
454        assert_eq!(strip_ansi_escapes(b"hello world"), "hello world");
455
456        // CSI sequence (color)
457        assert_eq!(strip_ansi_escapes(b"\x1b[32mgreen\x1b[0m"), "green");
458
459        // OSC sequence (title)
460        assert_eq!(strip_ansi_escapes(b"\x1b]0;title\x07text"), "text");
461
462        // Multiple sequences
463        assert_eq!(
464            strip_ansi_escapes(b"\x1b[1;32mBold Green\x1b[0m Normal"),
465            "Bold Green Normal"
466        );
467    }
468
469    #[test]
470    fn test_html_escape() {
471        assert_eq!(html_escape("<script>"), "&lt;script&gt;");
472        assert_eq!(html_escape("a & b"), "a &amp; b");
473        assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
474    }
475
476    #[test]
477    fn test_session_logger_plain() {
478        let temp_dir = TempDir::new().unwrap();
479        let mut logger = SessionLogger::new(
480            SessionLogFormat::Plain,
481            temp_dir.path(),
482            (80, 24),
483            Some("Test Session".to_string()),
484        )
485        .unwrap();
486
487        logger.start().unwrap();
488        logger.record_output(b"Hello, World!\n");
489        logger.record_output(b"\x1b[32mGreen text\x1b[0m\n");
490        let path = logger.stop().unwrap();
491
492        let content = std::fs::read_to_string(&path).unwrap();
493        assert!(content.contains("Hello, World!"));
494        assert!(content.contains("Green text"));
495        assert!(!content.contains("\x1b")); // No escape sequences
496    }
497
498    #[test]
499    fn test_session_logger_asciicast() {
500        let temp_dir = TempDir::new().unwrap();
501        let mut logger = SessionLogger::new(
502            SessionLogFormat::Asciicast,
503            temp_dir.path(),
504            (80, 24),
505            Some("Test Session".to_string()),
506        )
507        .unwrap();
508
509        logger.start().unwrap();
510        logger.record_output(b"Hello\n");
511        std::thread::sleep(std::time::Duration::from_millis(10));
512        logger.record_output(b"World\n");
513        let path = logger.stop().unwrap();
514
515        let content = std::fs::read_to_string(&path).unwrap();
516        let lines: Vec<&str> = content.lines().collect();
517
518        // First line should be header
519        assert!(lines[0].contains("\"version\":2"));
520        assert!(lines[0].contains("\"width\":80"));
521        assert!(lines[0].contains("\"height\":24"));
522
523        // Should have output events
524        assert!(lines.len() >= 3);
525    }
526}