Skip to main content

command_stream/terminal/
artifacts.rs

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(&current[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(&current[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('<', "&lt;")
58        .replace('>', "&gt;")
59        .replace('"', "&quot;")
60        .replace('\'', "&apos;")
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}