1use std::io::{Error, ErrorKind, Write};
17use std::path::{Path, PathBuf};
18use std::time::{Duration, Instant, SystemTime};
19
20use serde::{Deserialize, Serialize};
21
22#[derive(Clone, Debug, Serialize, Deserialize)]
28pub struct FrameRecord {
29 pub frame_index: u64,
31 pub timestamp_ms: u64,
33 pub render_us: u64,
35 pub width: u16,
37 pub height: u16,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub trigger: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub text_snapshot: Option<String>,
45}
46
47#[derive(Clone, Debug, Serialize, Deserialize)]
49pub struct EventRecord {
50 pub timestamp_ms: u64,
52 pub event_index: u64,
54 pub msg_tag: String,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub detail: Option<String>,
59}
60
61#[derive(Clone, Debug, Serialize, Deserialize)]
63pub struct SystemInfo {
64 pub os: String,
65 pub arch: String,
66 pub cass_version: String,
67 pub term: Option<String>,
68 pub colorterm: Option<String>,
69 pub terminal_size: Option<(u16, u16)>,
70 pub timestamp: String,
71}
72
73impl SystemInfo {
74 pub fn capture() -> Self {
76 Self {
77 os: std::env::consts::OS.to_string(),
78 arch: std::env::consts::ARCH.to_string(),
79 cass_version: env!("CARGO_PKG_VERSION").to_string(),
80 term: dotenvy::var("TERM").ok(),
81 colorterm: dotenvy::var("COLORTERM").ok(),
82 terminal_size: None, timestamp: chrono::Utc::now().to_rfc3339(),
84 }
85 }
86}
87
88pub struct TraceWriter {
94 render_file: Option<std::io::BufWriter<std::fs::File>>,
95 events_file: Option<std::io::BufWriter<std::fs::File>>,
96 frame_count: u64,
97 event_count: u64,
98 _epoch: Instant,
99}
100
101impl TraceWriter {
102 pub fn open(render_path: Option<&Path>, events_path: Option<&Path>) -> std::io::Result<Self> {
104 if let (Some(render_path), Some(events_path)) = (render_path, events_path)
105 && render_path == events_path
106 {
107 return Err(Error::new(
108 ErrorKind::InvalidInput,
109 format!(
110 "render and event trace outputs must use distinct paths: {}",
111 render_path.display()
112 ),
113 ));
114 }
115
116 if let Some(path) = render_path {
117 ensure_trace_output_available(path)?;
118 }
119 if let Some(path) = events_path {
120 ensure_trace_output_available(path)?;
121 }
122
123 let render_file = render_path.map(create_trace_output).transpose()?;
124 let events_file = events_path.map(create_trace_output).transpose()?;
125 Ok(Self {
126 render_file,
127 events_file,
128 frame_count: 0,
129 event_count: 0,
130 _epoch: Instant::now(),
131 })
132 }
133
134 pub fn record_frame(
136 &mut self,
137 render_duration: Duration,
138 width: u16,
139 height: u16,
140 trigger: Option<&str>,
141 text_snapshot: Option<String>,
142 ) -> std::io::Result<()> {
143 if let Some(ref mut f) = self.render_file {
144 let record = FrameRecord {
145 frame_index: self.frame_count,
146 timestamp_ms: wall_millis(),
147 render_us: render_duration.as_micros() as u64,
148 width,
149 height,
150 trigger: trigger.map(|s| s.to_string()),
151 text_snapshot,
152 };
153 serde_json::to_writer(&mut *f, &record)?;
154 f.write_all(b"\n")?;
155 self.frame_count += 1;
156 }
157 Ok(())
158 }
159
160 pub fn record_event(&mut self, msg_tag: &str, detail: Option<&str>) -> std::io::Result<()> {
162 if let Some(ref mut f) = self.events_file {
163 let record = EventRecord {
164 timestamp_ms: wall_millis(),
165 event_index: self.event_count,
166 msg_tag: msg_tag.to_string(),
167 detail: detail.map(|s| s.to_string()),
168 };
169 serde_json::to_writer(&mut *f, &record)?;
170 f.write_all(b"\n")?;
171 self.event_count += 1;
172 }
173 Ok(())
174 }
175
176 pub fn flush(&mut self) -> std::io::Result<()> {
178 if let Some(ref mut f) = self.render_file {
179 f.flush()?;
180 }
181 if let Some(ref mut f) = self.events_file {
182 f.flush()?;
183 }
184 Ok(())
185 }
186
187 pub fn frame_count(&self) -> u64 {
189 self.frame_count
190 }
191
192 pub fn event_count(&self) -> u64 {
194 self.event_count
195 }
196
197 pub fn is_active(&self) -> bool {
199 self.render_file.is_some() || self.events_file.is_some()
200 }
201}
202
203fn ensure_trace_output_available(path: &Path) -> std::io::Result<()> {
204 match std::fs::symlink_metadata(path) {
205 Ok(_) => {
206 return Err(Error::new(
207 ErrorKind::AlreadyExists,
208 format!("trace output already exists: {}", path.display()),
209 ));
210 }
211 Err(err) if err.kind() == ErrorKind::NotFound => {}
212 Err(err) => return Err(err),
213 }
214 Ok(())
215}
216
217fn create_trace_output(path: &Path) -> std::io::Result<std::io::BufWriter<std::fs::File>> {
218 ensure_trace_output_available(path)?;
219 let file = std::fs::OpenOptions::new()
220 .write(true)
221 .create_new(true)
222 .open(path)?;
223 Ok(std::io::BufWriter::new(file))
224}
225
226impl Drop for TraceWriter {
227 fn drop(&mut self) {
228 let _ = self.flush();
229 }
230}
231
232pub fn write_trace_bundle(
242 bundle_dir: &Path,
243 system_info: &SystemInfo,
244 tui_state_json: Option<&str>,
245) -> std::io::Result<()> {
246 ensure_trace_bundle_dir(bundle_dir)?;
247
248 let sys_path = bundle_dir.join("system_info.json");
249 let state_path = tui_state_json.map(|_| bundle_dir.join("tui_state.json"));
250 ensure_trace_output_available(&sys_path)?;
251 if let Some(path) = &state_path {
252 ensure_trace_output_available(path)?;
253 }
254
255 let mut sys_file = create_trace_output(&sys_path)?;
257 serde_json::to_writer_pretty(&mut sys_file, system_info)?;
258
259 if let (Some(state), Some(state_path)) = (tui_state_json, state_path) {
261 let mut state_file = create_trace_output(&state_path)?;
262 state_file.write_all(state.as_bytes())?;
263 }
264
265 Ok(())
266}
267
268fn ensure_trace_bundle_dir(bundle_dir: &Path) -> std::io::Result<()> {
269 match std::fs::symlink_metadata(bundle_dir) {
270 Ok(metadata) => {
271 let file_type = metadata.file_type();
272 if file_type.is_symlink() {
273 return Err(Error::new(
274 ErrorKind::InvalidInput,
275 format!(
276 "trace bundle directory must not be a symlink: {}",
277 bundle_dir.display()
278 ),
279 ));
280 }
281 if !file_type.is_dir() {
282 return Err(Error::new(
283 ErrorKind::InvalidInput,
284 format!(
285 "trace bundle path must be a directory: {}",
286 bundle_dir.display()
287 ),
288 ));
289 }
290 Ok(())
291 }
292 Err(err) if err.kind() == ErrorKind::NotFound => {
293 std::fs::create_dir_all(bundle_dir)?;
294 ensure_trace_bundle_dir(bundle_dir)
295 }
296 Err(err) => Err(err),
297 }
298}
299
300pub fn read_render_trace(path: &Path) -> std::io::Result<Vec<FrameRecord>> {
306 let content = std::fs::read_to_string(path)?;
307 let mut records = Vec::new();
308 for line in content.lines() {
309 if line.trim().is_empty() {
310 continue;
311 }
312 let record: FrameRecord = serde_json::from_str(line).map_err(|e| {
313 std::io::Error::new(
314 std::io::ErrorKind::InvalidData,
315 format!("invalid frame record: {e}"),
316 )
317 })?;
318 records.push(record);
319 }
320 Ok(records)
321}
322
323pub fn read_event_stream(path: &Path) -> std::io::Result<Vec<EventRecord>> {
325 let content = std::fs::read_to_string(path)?;
326 let mut records = Vec::new();
327 for line in content.lines() {
328 if line.trim().is_empty() {
329 continue;
330 }
331 let record: EventRecord = serde_json::from_str(line).map_err(|e| {
332 std::io::Error::new(
333 std::io::ErrorKind::InvalidData,
334 format!("invalid event record: {e}"),
335 )
336 })?;
337 records.push(record);
338 }
339 Ok(records)
340}
341
342#[derive(Clone, Debug, Default)]
348pub struct TraceOptions {
349 pub render_path: Option<PathBuf>,
351 pub events_path: Option<PathBuf>,
353 pub bundle_dir: Option<PathBuf>,
355 pub include_snapshots: bool,
357}
358
359impl TraceOptions {
360 pub fn is_active(&self) -> bool {
362 self.render_path.is_some() || self.events_path.is_some() || self.bundle_dir.is_some()
363 }
364
365 pub fn into_writer(&self) -> std::io::Result<TraceWriter> {
368 let (render_path, events_path) = if let Some(ref dir) = self.bundle_dir {
369 ensure_trace_bundle_dir(dir)?;
370 (
371 self.render_path
372 .clone()
373 .unwrap_or_else(|| dir.join("render.trace.jsonl")),
374 self.events_path
375 .clone()
376 .unwrap_or_else(|| dir.join("events.jsonl")),
377 )
378 } else {
379 (
380 self.render_path.clone().unwrap_or_default(),
381 self.events_path.clone().unwrap_or_default(),
382 )
383 };
384
385 let render = if self.render_path.is_some() || self.bundle_dir.is_some() {
386 Some(render_path.as_path())
387 } else {
388 None
389 };
390 let events = if self.events_path.is_some() || self.bundle_dir.is_some() {
391 Some(events_path.as_path())
392 } else {
393 None
394 };
395
396 TraceWriter::open(render, events)
397 }
398}
399
400fn wall_millis() -> u64 {
405 SystemTime::now()
406 .duration_since(SystemTime::UNIX_EPOCH)
407 .unwrap_or_default()
408 .as_millis() as u64
409}
410
411#[cfg(test)]
416mod tests {
417 use super::*;
418 use tempfile::TempDir;
419
420 #[test]
421 fn trace_writer_records_frames_and_events() {
422 let tmp = TempDir::new().unwrap();
423 let render_path = tmp.path().join("render.trace.jsonl");
424 let events_path = tmp.path().join("events.jsonl");
425
426 let mut writer = TraceWriter::open(Some(&render_path), Some(&events_path)).unwrap();
427 assert!(writer.is_active());
428
429 writer
430 .record_frame(Duration::from_micros(150), 80, 24, Some("init"), None)
431 .unwrap();
432 writer
433 .record_frame(Duration::from_micros(200), 80, 24, Some("Tick"), None)
434 .unwrap();
435 writer.record_event("QueryChanged", Some("hello")).unwrap();
436 writer.record_event("SearchRequested", None).unwrap();
437 writer.flush().unwrap();
438
439 assert_eq!(writer.frame_count(), 2);
440 assert_eq!(writer.event_count(), 2);
441
442 let frames = read_render_trace(&render_path).unwrap();
444 assert_eq!(frames.len(), 2);
445 assert_eq!(frames[0].frame_index, 0);
446 assert_eq!(frames[0].trigger.as_deref(), Some("init"));
447 assert_eq!(frames[1].frame_index, 1);
448
449 let events = read_event_stream(&events_path).unwrap();
450 assert_eq!(events.len(), 2);
451 assert_eq!(events[0].msg_tag, "QueryChanged");
452 assert_eq!(events[0].detail.as_deref(), Some("hello"));
453 assert_eq!(events[1].msg_tag, "SearchRequested");
454 }
455
456 #[test]
457 fn trace_writer_noop_when_no_paths() {
458 let mut writer = TraceWriter::open(None, None).unwrap();
459 assert!(!writer.is_active());
460 writer
462 .record_frame(Duration::from_micros(100), 80, 24, None, None)
463 .unwrap();
464 writer.record_event("Tick", None).unwrap();
465 assert_eq!(writer.frame_count(), 0);
466 assert_eq!(writer.event_count(), 0);
467 }
468
469 #[test]
470 fn trace_writer_with_text_snapshot() {
471 let tmp = TempDir::new().unwrap();
472 let render_path = tmp.path().join("render.trace.jsonl");
473
474 let mut writer = TraceWriter::open(Some(&render_path), None).unwrap();
475 writer
476 .record_frame(
477 Duration::from_micros(500),
478 80,
479 24,
480 Some("SearchCompleted"),
481 Some("╭─ results ─╮\n│ hit 1 │\n╰───────────╯".to_string()),
482 )
483 .unwrap();
484 writer.flush().unwrap();
485
486 let frames = read_render_trace(&render_path).unwrap();
487 assert_eq!(frames.len(), 1);
488 assert!(frames[0].text_snapshot.is_some());
489 assert!(frames[0].text_snapshot.as_ref().unwrap().contains("hit 1"));
490 }
491
492 #[test]
493 fn trace_writer_refuses_existing_output_path() {
494 let tmp = TempDir::new().unwrap();
495 let render_path = tmp.path().join("render.trace.jsonl");
496 std::fs::write(&render_path, "existing trace").unwrap();
497
498 let err = match TraceWriter::open(Some(&render_path), None) {
499 Ok(_) => panic!("expected existing trace output to be rejected"),
500 Err(err) => err,
501 };
502
503 assert_eq!(err.kind(), ErrorKind::AlreadyExists);
504 assert_eq!(
505 std::fs::read_to_string(&render_path).unwrap(),
506 "existing trace"
507 );
508 }
509
510 #[test]
511 fn trace_writer_refuses_existing_event_path_without_creating_render_output() {
512 let tmp = TempDir::new().unwrap();
513 let render_path = tmp.path().join("render.trace.jsonl");
514 let events_path = tmp.path().join("events.trace.jsonl");
515 std::fs::write(&events_path, "existing events").unwrap();
516
517 let err = match TraceWriter::open(Some(&render_path), Some(&events_path)) {
518 Ok(_) => panic!("expected existing event trace output to be rejected"),
519 Err(err) => err,
520 };
521
522 assert_eq!(err.kind(), ErrorKind::AlreadyExists);
523 assert!(
524 !render_path.exists(),
525 "render trace should not be created when event trace preflight fails"
526 );
527 assert_eq!(
528 std::fs::read_to_string(&events_path).unwrap(),
529 "existing events"
530 );
531 }
532
533 #[test]
534 fn trace_writer_refuses_shared_render_and_event_path() {
535 let tmp = TempDir::new().unwrap();
536 let trace_path = tmp.path().join("trace.jsonl");
537
538 let err = match TraceWriter::open(Some(&trace_path), Some(&trace_path)) {
539 Ok(_) => panic!("expected shared trace output path to be rejected"),
540 Err(err) => err,
541 };
542
543 assert_eq!(err.kind(), ErrorKind::InvalidInput);
544 assert!(
545 !trace_path.exists(),
546 "shared-path validation should not create a partial trace file"
547 );
548 }
549
550 #[test]
551 #[cfg(unix)]
552 fn trace_writer_refuses_symlinked_output_path() {
553 use std::os::unix::fs::symlink;
554
555 let tmp = TempDir::new().unwrap();
556 let protected_path = tmp.path().join("protected.jsonl");
557 let trace_path = tmp.path().join("render.trace.jsonl");
558 std::fs::write(&protected_path, "do not overwrite").unwrap();
559 symlink(&protected_path, &trace_path).unwrap();
560
561 let err = match TraceWriter::open(Some(&trace_path), None) {
562 Ok(_) => panic!("expected symlinked trace output to be rejected"),
563 Err(err) => err,
564 };
565
566 assert_eq!(err.kind(), ErrorKind::AlreadyExists);
567 assert_eq!(
568 std::fs::read_to_string(&protected_path).unwrap(),
569 "do not overwrite"
570 );
571 assert!(
572 std::fs::symlink_metadata(&trace_path)
573 .unwrap()
574 .file_type()
575 .is_symlink(),
576 "rejected trace symlink should remain untouched"
577 );
578 }
579
580 #[test]
581 fn write_and_read_trace_bundle() {
582 let tmp = TempDir::new().unwrap();
583 let bundle_dir = tmp.path().join("bundle");
584
585 let sys_info = SystemInfo::capture();
586 write_trace_bundle(&bundle_dir, &sys_info, Some(r#"{"query":"test"}"#)).unwrap();
587
588 assert!(bundle_dir.join("system_info.json").exists());
589 assert!(bundle_dir.join("tui_state.json").exists());
590
591 let state = std::fs::read_to_string(bundle_dir.join("tui_state.json")).unwrap();
592 assert!(state.contains("test"));
593 }
594
595 #[test]
596 #[cfg(unix)]
597 fn write_trace_bundle_rejects_symlinked_bundle_dir() {
598 use std::os::unix::fs::symlink;
599
600 let tmp = TempDir::new().unwrap();
601 let outside_dir = tmp.path().join("outside");
602 let bundle_dir = tmp.path().join("bundle");
603 std::fs::create_dir_all(&outside_dir).unwrap();
604 symlink(&outside_dir, &bundle_dir).unwrap();
605
606 let err = write_trace_bundle(&bundle_dir, &SystemInfo::capture(), Some("{}")).unwrap_err();
607
608 assert_eq!(err.kind(), ErrorKind::InvalidInput);
609 assert!(
610 !outside_dir.join("system_info.json").exists(),
611 "trace bundle writer must not follow a symlinked bundle directory"
612 );
613 assert!(
614 std::fs::symlink_metadata(&bundle_dir)
615 .unwrap()
616 .file_type()
617 .is_symlink(),
618 "rejected trace bundle symlink should remain untouched"
619 );
620 }
621
622 #[test]
623 #[cfg(unix)]
624 fn trace_options_rejects_symlinked_bundle_dir() {
625 use std::os::unix::fs::symlink;
626
627 let tmp = TempDir::new().unwrap();
628 let outside_dir = tmp.path().join("outside");
629 let bundle_dir = tmp.path().join("bundle");
630 std::fs::create_dir_all(&outside_dir).unwrap();
631 symlink(&outside_dir, &bundle_dir).unwrap();
632
633 let options = TraceOptions {
634 bundle_dir: Some(bundle_dir.clone()),
635 ..TraceOptions::default()
636 };
637
638 let err = match options.into_writer() {
639 Ok(_) => panic!("expected symlinked trace bundle option to be rejected"),
640 Err(err) => err,
641 };
642
643 assert_eq!(err.kind(), ErrorKind::InvalidInput);
644 assert!(
645 !outside_dir.join("render.trace.jsonl").exists(),
646 "trace options must not follow a symlinked bundle dir for render output"
647 );
648 assert!(
649 !outside_dir.join("events.jsonl").exists(),
650 "trace options must not follow a symlinked bundle dir for event output"
651 );
652 assert!(
653 std::fs::symlink_metadata(&bundle_dir)
654 .unwrap()
655 .file_type()
656 .is_symlink(),
657 "rejected trace options symlink should remain untouched"
658 );
659 }
660
661 #[test]
662 fn write_trace_bundle_refuses_existing_state_without_creating_system_info() {
663 let tmp = TempDir::new().unwrap();
664 let bundle_dir = tmp.path().join("bundle");
665 std::fs::create_dir_all(&bundle_dir).unwrap();
666 let state_path = bundle_dir.join("tui_state.json");
667 std::fs::write(&state_path, "existing state").unwrap();
668
669 let err = write_trace_bundle(&bundle_dir, &SystemInfo::capture(), Some("{}")).unwrap_err();
670
671 assert_eq!(err.kind(), ErrorKind::AlreadyExists);
672 assert!(
673 !bundle_dir.join("system_info.json").exists(),
674 "system_info should not be created when state preflight fails"
675 );
676 assert_eq!(
677 std::fs::read_to_string(&state_path).unwrap(),
678 "existing state"
679 );
680 }
681
682 #[test]
683 fn system_info_captures_environment() {
684 let info = SystemInfo::capture();
685 assert!(!info.os.is_empty());
686 assert!(!info.arch.is_empty());
687 assert!(!info.cass_version.is_empty());
688 assert!(!info.timestamp.is_empty());
689 }
690
691 #[test]
692 fn trace_options_active_detection() {
693 let opts = TraceOptions::default();
694 assert!(!opts.is_active());
695
696 let opts = TraceOptions {
697 render_path: Some(PathBuf::from("/tmp/test.jsonl")),
698 ..Default::default()
699 };
700 assert!(opts.is_active());
701
702 let opts = TraceOptions {
703 bundle_dir: Some(PathBuf::from("/tmp/bundle")),
704 ..Default::default()
705 };
706 assert!(opts.is_active());
707 }
708
709 #[test]
710 fn trace_options_bundle_creates_default_paths() {
711 let tmp = TempDir::new().unwrap();
712 let bundle_dir = tmp.path().join("bundle");
713
714 let opts = TraceOptions {
715 bundle_dir: Some(bundle_dir.clone()),
716 ..Default::default()
717 };
718
719 let mut writer = opts.into_writer().unwrap();
720 assert!(writer.is_active());
721 writer
722 .record_frame(Duration::from_micros(100), 80, 24, None, None)
723 .unwrap();
724 writer.record_event("Tick", None).unwrap();
725 writer.flush().unwrap();
726
727 assert!(bundle_dir.join("render.trace.jsonl").exists());
728 assert!(bundle_dir.join("events.jsonl").exists());
729 }
730}