1use super::types::{
2 Asciicast, AsciicastEvent, AsciicastHeader, TerminalCaptureError, TerminalFrame,
3};
4use std::fs;
5use std::path::Path;
6
7fn trim_blank_edges(lines: &[String]) -> &[String] {
8 let first = lines
9 .iter()
10 .position(|line| !line.is_empty())
11 .unwrap_or(lines.len());
12 let last = lines
13 .iter()
14 .rposition(|line| !line.is_empty())
15 .map_or(first, |index| index + 1);
16 &lines[first..last]
17}
18
19fn overlap_length(previous: &[String], current: &[String]) -> usize {
20 let maximum = previous.len().min(current.len());
21 (1..=maximum)
22 .rev()
23 .find(|size| previous[previous.len() - size..] == current[..*size])
24 .unwrap_or(0)
25}
26
27pub fn unroll_terminal_frames(frames: &[TerminalFrame]) -> String {
28 let mut transcript = Vec::new();
29 let mut previous: &[String] = &[];
30
31 for frame in frames {
32 let current = trim_blank_edges(&frame.lines);
33 if current == previous {
34 continue;
35 }
36
37 let overlap = overlap_length(previous, current);
38 if overlap > 0 {
39 transcript.extend_from_slice(¤t[overlap..]);
40 } else {
41 let common_prefix = previous
42 .iter()
43 .zip(current)
44 .take_while(|(left, right)| left == right)
45 .count();
46 transcript.extend_from_slice(¤t[common_prefix..]);
47 }
48 previous = current;
49 }
50
51 trim_blank_edges(&transcript).join("\n")
52}
53
54fn escape_xml(value: &str) -> String {
55 value
56 .replace('&', "&")
57 .replace('<', "<")
58 .replace('>', ">")
59 .replace('"', """)
60 .replace('\'', "'")
61}
62
63fn svg_text(lines: &[String]) -> String {
64 lines
65 .iter()
66 .enumerate()
67 .map(|(index, line)| {
68 format!(
69 r#"<text x="12" y="{}">{}</text>"#,
70 24 + index * 18,
71 escape_xml(if line.is_empty() { " " } else { line })
72 )
73 })
74 .collect()
75}
76
77fn svg_shell(width: usize, height: usize, body: &str) -> String {
78 format!(
79 concat!(
80 r#"<?xml version="1.0" encoding="UTF-8"?>"#,
81 r#"<svg xmlns="http://www.w3.org/2000/svg" width="{0}" height="{1}" viewBox="0 0 {0} {1}">"#,
82 r##"<rect width="100%" height="100%" rx="6" fill="#111827"/>"##,
83 r##"<g fill="#e5e7eb" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">"##,
84 "{2}</g></svg>"
85 ),
86 width, height, body
87 )
88}
89
90fn render_snapshot_svg(frame: &TerminalFrame) -> String {
91 svg_shell(
92 usize::from(frame.cols) * 9 + 24,
93 usize::from(frame.rows) * 18 + 18,
94 &svg_text(&frame.screen),
95 )
96}
97
98fn render_recording_svg(frames: &[TerminalFrame]) -> String {
99 let columns = frames.iter().map(|frame| frame.cols).max().unwrap_or(1);
100 let rows = frames.iter().map(|frame| frame.rows).max().unwrap_or(1);
101 let count = frames.len().max(1);
102 let key_times = (0..=count)
103 .map(|index| (index as f64 / count as f64).to_string())
104 .collect::<Vec<_>>()
105 .join(";");
106 let duration = (count as f64 * 0.35).max(0.35);
107 let groups = frames
108 .iter()
109 .enumerate()
110 .map(|(frame_index, frame)| {
111 let values = (0..=count)
112 .map(|index| {
113 if index == frame_index || (index == count && frame_index == 0) {
114 "1"
115 } else {
116 "0"
117 }
118 })
119 .collect::<Vec<_>>()
120 .join(";");
121 format!(
122 concat!(
123 "<g>",
124 r#"<animate attributeName="opacity" calcMode="discrete" values="{}" keyTimes="{}" dur="{}s" repeatCount="indefinite"/>"#,
125 "{}</g>"
126 ),
127 values,
128 key_times,
129 duration,
130 svg_text(&frame.screen)
131 )
132 })
133 .collect::<String>();
134 svg_shell(
135 usize::from(columns) * 9 + 24,
136 usize::from(rows) * 18 + 18,
137 &groups,
138 )
139}
140
141pub fn serialize_asciicast(asciicast: &Asciicast) -> Result<String, TerminalCaptureError> {
142 let mut lines = vec![serde_json::to_string(&asciicast.header)
143 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?];
144 for event in &asciicast.events {
145 lines.push(
146 serde_json::to_string(&(event.time, &event.code, &event.data))
147 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?,
148 );
149 }
150 Ok(format!("{}\n", lines.join("\n")))
151}
152
153pub fn read_asciicast(path: impl AsRef<Path>) -> Result<Asciicast, TerminalCaptureError> {
154 let contents = fs::read_to_string(path)
155 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
156 let mut lines = contents.lines();
157 let header: AsciicastHeader = serde_json::from_str(
158 lines
159 .next()
160 .ok_or_else(|| TerminalCaptureError::new("empty asciicast", None))?,
161 )
162 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
163 let events = lines
164 .filter(|line| !line.is_empty())
165 .map(|line| {
166 let (time, code, data): (f64, String, String) = serde_json::from_str(line)
167 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
168 Ok(AsciicastEvent { time, code, data })
169 })
170 .collect::<Result<Vec<_>, TerminalCaptureError>>()?;
171 Ok(Asciicast { header, events })
172}
173
174pub(crate) fn write_terminal_artifacts(
175 directory: &Path,
176 frames: &[TerminalFrame],
177 transcript: &str,
178 asciicast: &Asciicast,
179) -> Result<(), TerminalCaptureError> {
180 fs::create_dir_all(directory)
181 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
182 let fallback = TerminalFrame {
183 time: 0.0,
184 cols: asciicast.header.width,
185 rows: asciicast.header.height,
186 cursor: super::types::TerminalCursor { x: 0, y: 0 },
187 alternate: false,
188 lines: Vec::new(),
189 screen: Vec::new(),
190 };
191 let final_frame = frames.last().unwrap_or(&fallback);
192 let writes = [
193 ("transcript.txt", format!("{transcript}\n")),
194 (
195 "frames.json",
196 format!(
197 "{}\n",
198 serde_json::to_string_pretty(frames)
199 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?
200 ),
201 ),
202 ("session.cast", serialize_asciicast(asciicast)?),
203 ("snapshot.svg", render_snapshot_svg(final_frame)),
204 ("recording.svg", render_recording_svg(frames)),
205 ];
206 for (name, contents) in writes {
207 fs::write(directory.join(name), contents)
208 .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
209 }
210 Ok(())
211}