1use 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
18pub struct SessionLogger {
23 active: bool,
25 format: SessionLogFormat,
27 output_path: PathBuf,
29 writer: Option<BufWriter<File>>,
31 recording: Option<RecordingSession>,
33 start_time: std::time::Instant,
35 dimensions: (usize, usize),
37 title: Option<String>,
39}
40
41impl SessionLogger {
42 pub fn new(
50 format: SessionLogFormat,
51 log_dir: &Path,
52 dimensions: (usize, usize),
53 title: Option<String>,
54 ) -> Result<Self> {
55 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 let file = File::create(&output_path)?;
68 let writer = BufWriter::with_capacity(8192, file); 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 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 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 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 match self.format {
132 SessionLogFormat::Plain => {
133 }
135 SessionLogFormat::Html => {
136 self.write_html_footer()?;
137 }
138 SessionLogFormat::Asciicast => {
139 self.write_asciicast()?;
140 }
141 }
142
143 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 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 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 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 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 pub fn record_input(&mut self, data: &[u8]) {
193 if !self.active {
194 return;
195 }
196
197 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 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 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 pub fn is_active(&self) -> bool {
237 self.active
238 }
239
240 pub fn output_path(&self) -> &PathBuf {
242 &self.output_path
243 }
244
245 pub fn flush(&mut self) -> Result<()> {
247 if let Some(ref mut writer) = self.writer {
248 writer.flush()?;
249 }
250 Ok(())
251 }
252
253 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 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, "title": &recording.title,
312 "env": recording.env,
313 });
314
315 if let Some(ref mut writer) = self.writer {
316 writeln!(writer, "{}", header)?;
317
318 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 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
370pub type SharedSessionLogger = Arc<Mutex<Option<SessionLogger>>>;
372
373pub fn create_shared_logger() -> SharedSessionLogger {
375 Arc::new(Mutex::new(None))
376}
377
378fn 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 if let Some(&next) = chars.peek() {
390 if next == '[' {
391 chars.next(); 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 chars.next(); 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 chars.next();
416 chars.next();
417 } else {
418 chars.next();
420 }
421 }
422 } else {
423 result.push(c);
424 }
425 }
426
427 result
428}
429
430fn 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("<"),
436 '>' => result.push_str(">"),
437 '&' => result.push_str("&"),
438 '"' => result.push_str("""),
439 '\'' => result.push_str("'"),
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 assert_eq!(strip_ansi_escapes(b"hello world"), "hello world");
455
456 assert_eq!(strip_ansi_escapes(b"\x1b[32mgreen\x1b[0m"), "green");
458
459 assert_eq!(strip_ansi_escapes(b"\x1b]0;title\x07text"), "text");
461
462 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>"), "<script>");
472 assert_eq!(html_escape("a & b"), "a & b");
473 assert_eq!(html_escape("\"quoted\""), ""quoted"");
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")); }
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 assert!(lines[0].contains("\"version\":2"));
520 assert!(lines[0].contains("\"width\":80"));
521 assert!(lines[0].contains("\"height\":24"));
522
523 assert!(lines.len() >= 3);
525 }
526}