Skip to main content

rich_record/
record.rs

1//! Running a tape, and writing or checking what it produced.
2
3use std::collections::BTreeSet;
4use std::io::Read;
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
8
9use serde_json::json;
10
11use crate::render::{cast, html, raster, svg, video, Look};
12use crate::screen::{Snapshot, Theme};
13use crate::session::{Session, Timeline, FRAME_RATE, MAX_VIDEO};
14use crate::tape::{Format, Output, Pattern, Shell, Step, Tape, TapeError};
15
16/// The prompt the recorded shell shows; `run` waits for it.
17const PROMPT: &str = "❯";
18/// How long an `Exec` command may run.
19const EXEC_TIMEOUT: Duration = Duration::from_secs(60);
20/// How much of an `Exec` command's error output is kept for its report.
21const EXEC_STDERR: u64 = 64 * 1024;
22
23/// Whether `stem` may name a recording: its output directory, the prefix of
24/// its cast, GIF and MP4, and its workspace. Not empty, `.` or `..`, and no
25/// path separators, so everything written stays inside the output directory.
26pub fn stem_allowed(stem: &str) -> bool {
27    !stem.is_empty()
28        && stem != "."
29        && stem != ".."
30        && !stem
31            .chars()
32            .any(|c| c == '/' || c == '\\' || c.is_control())
33}
34
35fn stem_error(stem: &str) -> String {
36    format!(
37        "{stem:?} cannot name a recording: it must not be empty, . or .., \
38         or contain / or \\"
39    )
40}
41
42/// How a tape is run.
43#[derive(Clone, Debug, Default)]
44pub struct Options {
45    /// Put first on `PATH`, so the binaries under test win.
46    pub bin_dir: Option<PathBuf>,
47    /// Exported as `$REPO` to the shell and to `Exec`, so tapes can copy
48    /// fixtures from the repository.
49    pub repo: Option<PathBuf>,
50    pub theme: Theme,
51}
52
53/// How a recording is presented: `Set WindowFrame`, `Set Caption` and
54/// `Set KeyOverlay`, which `rich record`'s flags can override.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct Presentation {
57    /// Draw the window frame (title bar and buttons) around stills and video.
58    pub window: bool,
59    /// A line of text under stills, video and the page's player.
60    pub caption: Option<String>,
61    /// Show each key as it is pressed, in video and the page's player.
62    pub key_overlay: bool,
63}
64
65impl Default for Presentation {
66    fn default() -> Self {
67        Presentation {
68            window: true,
69            caption: None,
70            key_overlay: true,
71        }
72    }
73}
74
75/// What a tape produced.
76#[derive(Clone, Debug)]
77pub struct Recording {
78    pub title: String,
79    pub presentation: Presentation,
80    /// The tape's `Output` words; empty for every format.
81    pub outputs: Vec<Output>,
82    pub columns: u16,
83    pub rows: u16,
84    /// Screenshots in the order they were taken.
85    pub shots: Vec<(String, Snapshot)>,
86    /// The tape's `Mask` rewrites, applied to text grids.
87    pub masks: Vec<(fancy_regex::Regex, String)>,
88    pub timeline: Timeline,
89}
90
91/// A temporary directory, removed on drop.
92struct Workspace(PathBuf);
93
94impl Workspace {
95    fn new(stem: &str) -> std::io::Result<Workspace> {
96        let nanos = SystemTime::now()
97            .duration_since(UNIX_EPOCH)
98            .map_or(0, |d| d.as_nanos());
99        let path =
100            std::env::temp_dir().join(format!("rich-record-{stem}-{}-{nanos}", std::process::id()));
101        std::fs::create_dir_all(path.join(".home"))?;
102        std::fs::write(
103            path.join(".home/.inputrc"),
104            "set enable-bracketed-paste off\n",
105        )?;
106        // zsh reads only this, through ZDOTDIR, when started with -d.
107        std::fs::write(
108            path.join(".home/.zshrc"),
109            format!(
110                "PROMPT=$'%{{\\e[1;35m%}}{PROMPT}%{{\\e[0m%}} '\nRPROMPT=''\n\
111                 unsetopt PROMPT_SP BEEP\nunset zle_bracketed_paste\nHISTFILE=/dev/null\n"
112            ),
113        )?;
114        Ok(Workspace(path))
115    }
116}
117
118impl Drop for Workspace {
119    fn drop(&mut self) {
120        let _ = std::fs::remove_dir_all(&self.0);
121    }
122}
123
124/// The session's environment: pinned so recordings repeat.
125fn environment(workspace: &Path, tape: &Tape, options: &Options) -> Vec<(String, String)> {
126    let home = workspace.join(".home");
127    let inherited = std::env::var_os("PATH").unwrap_or_else(|| "/usr/bin:/bin".into());
128    // The shell runs in the workspace, so a relative directory is resolved
129    // against where the recorder was started.
130    let mut dirs: Vec<PathBuf> = options
131        .bin_dir
132        .iter()
133        .map(|dir| std::path::absolute(dir).unwrap_or_else(|_| dir.clone()))
134        .collect();
135    dirs.extend(std::env::split_paths(&inherited));
136    let path = std::env::join_paths(dirs).map_or_else(
137        |_| inherited.to_string_lossy().into_owned(),
138        |p| p.to_string_lossy().into_owned(),
139    );
140    let mut env: Vec<(String, String)> = vec![
141        ("PATH".into(), path),
142        ("HOME".into(), home.display().to_string()),
143        (
144            "XDG_CONFIG_HOME".into(),
145            home.join(".config").display().to_string(),
146        ),
147        (
148            "INPUTRC".into(),
149            home.join(".inputrc").display().to_string(),
150        ),
151        ("TERM".into(), "xterm-256color".into()),
152        ("COLORTERM".into(), "truecolor".into()),
153        ("LANG".into(), "C.UTF-8".into()),
154        ("LC_ALL".into(), "C.UTF-8".into()),
155        ("TZ".into(), "UTC".into()),
156        ("PROMPT_COMMAND".into(), String::new()),
157        ("HISTFILE".into(), "/dev/null".into()),
158    ];
159    match tape.shell {
160        Shell::Bash => env.push((
161            "PS1".into(),
162            format!("\\[\\e[1;35m\\]{PROMPT}\\[\\e[0m\\] "),
163        )),
164        // POSIX sh has no escapes in PS1: the bytes themselves.
165        Shell::Sh => env.push(("PS1".into(), format!("\x1b[1;35m{PROMPT}\x1b[0m "))),
166        Shell::Zsh => env.push(("ZDOTDIR".into(), home.display().to_string())),
167        Shell::Fish => {}
168    }
169    if let Some(repo) = &options.repo {
170        let repo = std::path::absolute(repo).unwrap_or_else(|_| repo.clone());
171        env.push(("REPO".into(), repo.display().to_string()));
172    }
173    env.extend(tape.env.iter().cloned());
174    env
175}
176
177/// The command line that starts `shell` interactively, without the user's
178/// profile or rc files.
179fn shell_command(shell: Shell) -> Vec<String> {
180    let args: &[&str] = match shell {
181        Shell::Bash => &["--noprofile", "--norc", "-i"],
182        // -d skips /etc/zsh*; ZDOTDIR points at the workspace's .zshrc.
183        Shell::Zsh => &["-d", "-i"],
184        Shell::Fish => &[
185            "--no-config",
186            "--private",
187            "-i",
188            "-C",
189            "function fish_prompt; set_color -o magenta; echo -n '❯'; \
190             set_color normal; echo -n ' '; end; set -g fish_greeting ''; \
191             set -g fish_autosuggestion_enabled 0",
192        ],
193        Shell::Sh => &["-i"],
194    };
195    std::iter::once(shell.name())
196        .chain(args.iter().copied())
197        .map(str::to_string)
198        .collect()
199}
200
201/// The major version of the `bash` on `PATH`, if it runs.
202fn bash_major() -> Option<u32> {
203    let output = Command::new("bash")
204        .args(["-c", "echo ${BASH_VERSINFO[0]}"])
205        .stdin(Stdio::null())
206        .stderr(Stdio::null())
207        .output()
208        .ok()?;
209    String::from_utf8_lossy(&output.stdout).trim().parse().ok()
210}
211
212/// Warnings about the machine a tape is about to be recorded on: a shell
213/// that is missing, or a bash old enough (macOS ships 3.2) that its line
214/// editing may differ from recordings made elsewhere.
215pub fn warnings(tape: &Tape) -> Vec<String> {
216    let mut out = Vec::new();
217    if tape.shell == Shell::Bash {
218        match bash_major() {
219            Some(major) if major < 4 => out.push(format!(
220                "bash {major} is older than 4 (macOS ships 3.2): line editing may \
221                 differ from recordings made with a newer bash; install one (for \
222                 example `brew install bash`) and put it first on PATH"
223            )),
224            Some(_) => {}
225            None => out.push("bash was not found on PATH".into()),
226        }
227    }
228    out
229}
230
231/// A failure of the terminal emulator, reported at `line`.
232fn failed(session: &Session, line: usize) -> Result<(), TapeError> {
233    match session.error() {
234        Some(error) => Err(TapeError::new(
235            line,
236            format!("the terminal emulator failed: {error}"),
237        )),
238        None => Ok(()),
239    }
240}
241
242fn wait_for(
243    session: &Session,
244    pattern: &Pattern,
245    limit: Duration,
246    line: usize,
247) -> Result<(), TapeError> {
248    let end = Instant::now() + limit;
249    while Instant::now() < end {
250        if session.seen(|screen| pattern.is_match(screen)) {
251            return Ok(());
252        }
253        failed(session, line)?;
254        if !session.alive() {
255            break;
256        }
257        std::thread::sleep(Duration::from_millis(20));
258    }
259    failed(session, line)?;
260    Err(TapeError::new(
261        line,
262        format!(
263            "timed out waiting for {pattern}; the screen shows:\n{}",
264            session.contents()
265        ),
266    ))
267}
268
269/// Run `command` with `sh -c` in `workspace`, for at most `limit`. Its error
270/// output is read as it is written, so a chatty command cannot fill the pipe
271/// and hang, and the command is killed and reaped when it runs too long.
272fn exec(
273    command: &str,
274    workspace: &Path,
275    env: &[(String, String)],
276    limit: Duration,
277    line: usize,
278) -> Result<(), TapeError> {
279    let mut child = Command::new("sh")
280        .args(["-c", command])
281        .current_dir(workspace)
282        .env_clear()
283        .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
284        .stdin(Stdio::null())
285        .stdout(Stdio::null())
286        .stderr(Stdio::piped())
287        .spawn()
288        .map_err(|e| TapeError::new(line, format!("Exec could not start: {e}")))?;
289    let (sender, stderr) = std::sync::mpsc::channel();
290    if let Some(pipe) = child.stderr.take() {
291        std::thread::spawn(move || {
292            let mut pipe = pipe;
293            let mut kept = Vec::new();
294            let _ = (&mut pipe).take(EXEC_STDERR).read_to_end(&mut kept);
295            // Drain the rest, so the command never blocks on a full pipe.
296            let _ = std::io::copy(&mut pipe, &mut std::io::sink());
297            let _ = sender.send(kept);
298        });
299    }
300    // A command that leaves a background process holding the pipe open does
301    // not delay the report for long.
302    let stderr = || {
303        stderr
304            .recv_timeout(Duration::from_millis(500))
305            .map(|bytes| String::from_utf8_lossy(&bytes).trim().to_string())
306            .unwrap_or_default()
307    };
308    let end = Instant::now() + limit;
309    loop {
310        match child.try_wait() {
311            Ok(Some(status)) if status.success() => return Ok(()),
312            Ok(Some(status)) => {
313                return Err(TapeError::new(
314                    line,
315                    format!("Exec failed ({status}): {}", stderr()),
316                ));
317            }
318            Ok(None) if Instant::now() < end => std::thread::sleep(Duration::from_millis(20)),
319            Ok(None) => {
320                let _ = child.kill();
321                let _ = child.wait();
322                return Err(TapeError::new(
323                    line,
324                    format!("Exec timed out after {}s", limit.as_secs_f64()),
325                ));
326            }
327            Err(error) => {
328                let _ = child.kill();
329                let _ = child.wait();
330                return Err(TapeError::new(line, format!("Exec failed: {error}")));
331            }
332        }
333    }
334}
335
336/// Run `tape` (named `stem`) and record it.
337pub fn record(tape: &Tape, stem: &str, options: &Options) -> Result<Recording, TapeError> {
338    if !stem_allowed(stem) {
339        return Err(TapeError::new(0, stem_error(stem)));
340    }
341    let io = |e: std::io::Error| TapeError::new(0, e.to_string());
342    let workspace = Workspace::new(stem).map_err(io)?;
343    let env = environment(&workspace.0, tape, options);
344    let shell = shell_command(tape.shell);
345    let mut session = Session::start(
346        &shell,
347        &workspace.0,
348        tape.columns,
349        tape.rows,
350        &env,
351        options.theme.clone(),
352    )
353    .map_err(|e| TapeError::new(0, format!("cannot start {}: {e}", tape.shell.name())))?;
354    let mut typing = Duration::from_millis(40);
355    let mut timeout = Duration::from_secs(15);
356    let prompt = Pattern::Text(PROMPT.into());
357    let mut shots = Vec::new();
358    // Start hidden, on a cleared screen at the prompt.
359    wait_for(&session, &prompt, timeout, 0)?;
360    session.send("clear\r", None).map_err(io)?;
361    std::thread::sleep(Duration::from_millis(200));
362    wait_for(&session, &prompt, timeout, 0)?;
363    session.show();
364    for (line, step) in &tape.steps {
365        let io = |e: std::io::Error| TapeError::new(*line, e.to_string());
366        // A `Wait` matches anything shown since the step before it began, so
367        // fast output that scrolls past between polls is not missed.
368        if !matches!(step, Step::Wait { .. }) {
369            session.mark();
370        }
371        match step {
372            Step::TypingDelay(delay) => typing = *delay,
373            Step::Timeout(limit) => timeout = *limit,
374            Step::Type(text) => {
375                for c in text.chars() {
376                    session.send(&c.to_string(), None).map_err(io)?;
377                    std::thread::sleep(typing);
378                }
379            }
380            Step::Key { key, count } => {
381                for _ in 0..*count {
382                    session.send(&key.bytes(), Some(key.label())).map_err(io)?;
383                    std::thread::sleep(typing.max(Duration::from_millis(120)));
384                }
385            }
386            Step::Sleep(delay) => std::thread::sleep(*delay),
387            Step::Wait {
388                pattern,
389                timeout: limit,
390            } => wait_for(&session, pattern, limit.unwrap_or(timeout), *line)?,
391            Step::Screenshot(name) => {
392                // Let a repaint in flight land.
393                std::thread::sleep(Duration::from_millis(150));
394                shots.push((name.clone(), session.snapshot()));
395            }
396            Step::Hide => session.hide(),
397            Step::Show => session.show(),
398            Step::Resize { columns, rows } => session.resize(*columns, *rows).map_err(io)?,
399            Step::Write { path, content } => {
400                // `parse` checks this too; a tape built in code may not.
401                if !crate::tape::write_path_allowed(path) {
402                    return Err(TapeError::new(
403                        *line,
404                        format!("Write path {path:?} must be relative and stay in the workspace"),
405                    ));
406                }
407                let target = workspace.0.join(path);
408                if let Some(parent) = target.parent() {
409                    std::fs::create_dir_all(parent).map_err(io)?;
410                }
411                std::fs::write(target, content).map_err(io)?;
412            }
413            Step::Exec(command) => exec(command, &workspace.0, &env, EXEC_TIMEOUT, *line)?,
414        }
415        failed(&session, *line)?;
416    }
417    std::thread::sleep(Duration::from_millis(300));
418    failed(&session, 0)?;
419    let timeline = session.finish();
420    if shots.is_empty() {
421        return Err(TapeError::new(0, "the tape takes no Screenshot"));
422    }
423    Ok(Recording {
424        title: tape.title.clone().unwrap_or_else(|| stem.to_string()),
425        presentation: Presentation {
426            window: tape.window_frame.unwrap_or(true),
427            caption: tape.caption.clone(),
428            key_overlay: tape.key_overlay.unwrap_or(true),
429        },
430        outputs: tape.outputs.clone(),
431        columns: tape.columns,
432        rows: tape.rows,
433        shots,
434        masks: tape.masks.clone(),
435        timeline,
436    })
437}
438
439impl Recording {
440    /// A screenshot's text grid, with the tape's masks applied.
441    pub fn text_grid(&self, snapshot: &Snapshot) -> String {
442        crate::tape::apply_masks(&self.masks, &snapshot.text_grid())
443    }
444
445    /// The formats to write: the tape's `Output` formats (every format when
446    /// it has none) that `requested` also allows.
447    pub fn formats(&self, requested: Formats) -> Formats {
448        if self.outputs.is_empty() {
449            return requested;
450        }
451        let mut tape = Formats::NONE;
452        for output in &self.outputs {
453            tape.set(output.format, true);
454        }
455        tape.intersect(requested)
456    }
457
458    /// The file a per-tape `format` is written to, relative to the output
459    /// directory: the path the tape's `Output` gave, else `<stem>.<ext>`.
460    pub fn output_path(&self, format: Format, stem: &str) -> String {
461        self.outputs
462            .iter()
463            .rev()
464            .filter(|output| output.format == format)
465            .find_map(|output| output.path.clone())
466            .unwrap_or_else(|| format!("{stem}.{}", format.extension()))
467    }
468}
469
470/// Which files [`write`] produces. Text grids are always written: `--check`
471/// needs them.
472#[derive(Clone, Copy, Debug, PartialEq, Eq)]
473pub struct Formats {
474    pub png: bool,
475    pub svg: bool,
476    pub cast: bool,
477    pub gif: bool,
478    /// Only when FFmpeg is installed.
479    pub mp4: bool,
480    /// The page with a player and the screenshots.
481    pub html: bool,
482}
483
484impl Formats {
485    pub const ALL: Formats = Formats {
486        png: true,
487        svg: true,
488        cast: true,
489        gif: true,
490        mp4: true,
491        html: true,
492    };
493    /// Everything but video encoding.
494    pub const NO_VIDEO: Formats = Formats {
495        gif: false,
496        mp4: false,
497        ..Formats::ALL
498    };
499    /// Text grids only.
500    pub const NONE: Formats = Formats {
501        png: false,
502        svg: false,
503        cast: false,
504        gif: false,
505        mp4: false,
506        html: false,
507    };
508
509    pub fn contains(self, format: Format) -> bool {
510        match format {
511            Format::Png => self.png,
512            Format::Svg => self.svg,
513            Format::Cast => self.cast,
514            Format::Gif => self.gif,
515            Format::Mp4 => self.mp4,
516            Format::Html => self.html,
517        }
518    }
519
520    pub fn set(&mut self, format: Format, on: bool) {
521        match format {
522            Format::Png => self.png = on,
523            Format::Svg => self.svg = on,
524            Format::Cast => self.cast = on,
525            Format::Gif => self.gif = on,
526            Format::Mp4 => self.mp4 = on,
527            Format::Html => self.html = on,
528        }
529    }
530
531    /// The formats in both.
532    pub fn intersect(self, other: Formats) -> Formats {
533        let mut out = Formats::NONE;
534        for format in Format::ALL {
535            out.set(format, self.contains(format) && other.contains(format));
536        }
537        out
538    }
539}
540
541/// The screenshots [`write`] last wrote into `dir`, from the `screenshots`
542/// list in its `provenance.json`.
543fn manifest(dir: &Path) -> BTreeSet<String> {
544    let Ok(text) = std::fs::read_to_string(dir.join("provenance.json")) else {
545        return BTreeSet::new();
546    };
547    let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
548        return BTreeSet::new();
549    };
550    json.get("screenshots")
551        .and_then(serde_json::Value::as_array)
552        .into_iter()
553        .flatten()
554        .filter_map(serde_json::Value::as_str)
555        // A name that could leave `dir` is never ours.
556        .filter(|name| crate::tape::screenshot_name_allowed(name))
557        .map(str::to_string)
558        .collect()
559}
560
561/// Names of committed screenshots the recording no longer takes: those the
562/// last [`write`] listed in `provenance.json` and this recording does not
563/// take. Other files in the directory are never counted, or removed, however
564/// they are named.
565pub fn orphans(recording: &Recording, dir: &Path) -> Vec<String> {
566    let taken: BTreeSet<&str> = recording
567        .shots
568        .iter()
569        .map(|(name, _)| name.as_str())
570        .collect();
571    manifest(dir)
572        .into_iter()
573        .filter(|name| !taken.contains(name.as_str()))
574        .collect()
575}
576
577/// A difference `check` found.
578#[derive(Clone, Debug, PartialEq, Eq)]
579pub enum Problem {
580    /// The screenshot's text differs from `<name>.txt`: a unified-style diff.
581    Differs { name: String, diff: String },
582    /// A committed screenshot the tape no longer takes.
583    Orphaned { name: String },
584}
585
586impl std::fmt::Display for Problem {
587    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
588        match self {
589            Problem::Differs { name, diff } => write!(f, "screenshot {name} differs\n{diff}"),
590            Problem::Orphaned { name } => {
591                write!(
592                    f,
593                    "committed screenshot {name} is no longer taken; regenerate to remove it"
594                )
595            }
596        }
597    }
598}
599
600/// Compare each screenshot's text grid with `dir/<name>.txt`.
601pub fn check(recording: &Recording, dir: &Path) -> Vec<Problem> {
602    let mut problems = Vec::new();
603    for (name, snapshot) in &recording.shots {
604        let got = recording.text_grid(snapshot);
605        let want = std::fs::read_to_string(dir.join(format!("{name}.txt"))).unwrap_or_default();
606        if got != want {
607            let diff = rich_ext::diff::TextDiff::new(&want, &got).unified("committed", "this run");
608            problems.push(Problem::Differs {
609                name: name.clone(),
610                diff,
611            });
612        }
613    }
614    problems.extend(
615        orphans(recording, dir)
616            .into_iter()
617            .map(|name| Problem::Orphaned { name }),
618    );
619    problems
620}
621
622/// FNV-1a, 64-bit: a stable fingerprint of the tape for provenance.
623fn fingerprint(bytes: &[u8]) -> String {
624    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
625    for byte in bytes {
626        hash ^= *byte as u64;
627        hash = hash.wrapping_mul(0x0100_0000_01b3);
628    }
629    format!("{hash:016x}")
630}
631
632fn invalid(message: String) -> std::io::Error {
633    std::io::Error::new(std::io::ErrorKind::InvalidInput, message)
634}
635
636/// An error unless an image of `columns` x `rows` cells, drawn with
637/// `options`, fits in [`raster::MAX_PIXELS`].
638fn check_pixels(
639    columns: usize,
640    rows: usize,
641    fonts: &raster::Fonts,
642    options: &raster::Frame<'_>,
643) -> std::io::Result<()> {
644    let (width, height) = raster::size(columns, rows, fonts, options);
645    if width.saturating_mul(height) > raster::MAX_PIXELS {
646        return Err(invalid(format!(
647            "an image of {columns}x{rows} cells would be {width}x{height} pixels, more than \
648             {} million; use a smaller Size or font",
649            raster::MAX_PIXELS / 1_000_000
650        )));
651    }
652    Ok(())
653}
654
655/// Write the recording's files into `dir`, removing screenshots the last
656/// write listed that this recording no longer takes. With `provenance`, the
657/// written `provenance.json` lists the screenshots for the next write.
658/// Returns the paths written.
659///
660/// Refused before anything is written: a `stem` that [`stem_allowed`]
661/// refuses, a GIF, MP4 or HTML page of a recording longer than
662/// [`crate::session::MAX_VIDEO`], and images too large to draw.
663pub fn write(
664    recording: &Recording,
665    dir: &Path,
666    stem: &str,
667    formats: Formats,
668    fonts: &raster::Fonts,
669    theme: &Theme,
670    provenance: Option<(&Path, &[u8])>,
671) -> std::io::Result<Vec<PathBuf>> {
672    if !stem_allowed(stem) {
673        return Err(invalid(stem_error(stem)));
674    }
675    // The HTML page's player plays the same timeline, so it would stop at the
676    // cut as silently as a video would.
677    let video = formats.gif || formats.mp4 || formats.html;
678    if video && recording.timeline.truncated {
679        return Err(invalid(format!(
680            "the recording is longer than {}s, too long for video; record it without \
681             GIF, MP4 or the HTML player (--no-video), or shorten the tape",
682            MAX_VIDEO.as_secs()
683        )));
684    }
685    let presentation = &recording.presentation;
686    let look = Look {
687        title: &recording.title,
688        window: presentation.window,
689        caption: presentation.caption.as_deref(),
690    };
691    let still = raster::Frame {
692        title: &recording.title,
693        key: None,
694        size: 28.0,
695        window: look.window,
696        caption: look.caption,
697    };
698    if formats.png {
699        for (_, snapshot) in &recording.shots {
700            check_pixels(snapshot.columns(), snapshot.rows.len(), fonts, &still)?;
701        }
702    }
703    let samples = if video {
704        video::sample_with(&recording.timeline, FRAME_RATE, presentation.key_overlay)
705    } else {
706        Vec::new()
707    };
708    let (video_width, video_height) = video::size(&recording.timeline, &samples, fonts, &look);
709    if video_width.saturating_mul(video_height) > raster::MAX_PIXELS {
710        return Err(invalid(format!(
711            "video frames would be {video_width}x{video_height} pixels, more than {} million; \
712             use a smaller Size or font",
713            raster::MAX_PIXELS / 1_000_000
714        )));
715    }
716    std::fs::create_dir_all(dir)?;
717    for name in orphans(recording, dir) {
718        for suffix in ["txt", "png", "svg"] {
719            let _ = std::fs::remove_file(dir.join(format!("{name}.{suffix}")));
720        }
721    }
722    let mut written = Vec::new();
723    let save = |written: &mut Vec<PathBuf>, name: String, bytes: &[u8]| -> std::io::Result<()> {
724        let path = dir.join(name);
725        if let Some(parent) = path.parent() {
726            std::fs::create_dir_all(parent)?;
727        }
728        std::fs::write(&path, bytes)?;
729        written.push(path);
730        Ok(())
731    };
732    for (name, snapshot) in &recording.shots {
733        save(
734            &mut written,
735            format!("{name}.txt"),
736            recording.text_grid(snapshot).as_bytes(),
737        )?;
738        if formats.svg {
739            save(
740                &mut written,
741                format!("{name}.svg"),
742                svg::svg(snapshot, theme, &look).as_bytes(),
743            )?;
744        }
745        if formats.png {
746            save(
747                &mut written,
748                format!("{name}.png"),
749                &raster::render(snapshot, theme, fonts, &still).png(),
750            )?;
751        }
752    }
753    if formats.cast {
754        let cast = cast::cast(
755            &recording.timeline,
756            recording.columns,
757            recording.rows,
758            &recording.title,
759            theme,
760        );
761        save(
762            &mut written,
763            recording.output_path(Format::Cast, stem),
764            cast.as_bytes(),
765        )?;
766    }
767    if formats.html {
768        let page = html::page(
769            &look,
770            &recording.shots,
771            &recording.timeline,
772            theme,
773            presentation.key_overlay,
774        );
775        save(
776            &mut written,
777            recording.output_path(Format::Html, stem),
778            page.as_bytes(),
779        )?;
780    }
781    let mp4_path = dir.join(recording.output_path(Format::Mp4, stem));
782    if formats.mp4 {
783        if let Some(parent) = mp4_path.parent() {
784            std::fs::create_dir_all(parent)?;
785        }
786    }
787    let mut mp4 = if formats.mp4 && video::ffmpeg_available() {
788        Some(video::Mp4::start(&mp4_path, video_width, video_height)?)
789    } else {
790        None
791    };
792    let timeline = &recording.timeline;
793    if formats.gif {
794        let gif_path = dir.join(recording.output_path(Format::Gif, stem));
795        if let Some(parent) = gif_path.parent() {
796            std::fs::create_dir_all(parent)?;
797        }
798        let mut file = std::io::BufWriter::new(std::fs::File::create(&gif_path)?);
799        // The GIF draws every frame twice; the MP4 takes them on the first.
800        let mut pass = 0;
801        raster::gif_streamed(
802            video_width,
803            video_height,
804            |sink| {
805                pass += 1;
806                video::render_each(timeline, &samples, theme, fonts, &look, &mut |canvas, s| {
807                    if pass == 1 {
808                        if let Some(mp4) = mp4.as_mut() {
809                            mp4.write(&canvas, s)?;
810                        }
811                    }
812                    sink(canvas, s)
813                })
814            },
815            &mut file,
816        )?;
817        std::io::Write::flush(&mut file)?;
818        written.push(gif_path);
819    } else if let Some(mp4) = mp4.as_mut() {
820        video::render_each(timeline, &samples, theme, fonts, &look, &mut |canvas, s| {
821            mp4.write(&canvas, s)
822        })?;
823    }
824    let mp4_written = mp4.is_some();
825    if let Some(mp4) = mp4 {
826        mp4.finish()?;
827    }
828    if let Some((tape_path, tape_bytes)) = provenance {
829        let output = |program: &str, args: &[&str]| {
830            Command::new(program)
831                .args(args)
832                .output()
833                .ok()
834                .filter(|o| o.status.success())
835                .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
836        };
837        let screenshots: Vec<&str> = recording
838            .shots
839            .iter()
840            .map(|(name, _)| name.as_str())
841            .collect();
842        let json = json!({
843            "tape": tape_path.display().to_string(),
844            "tape_fnv1a64": fingerprint(tape_bytes),
845            "recorder": format!("rs-rich-record {}", env!("CARGO_PKG_VERSION")),
846            "rich": output("rich", &["--version"]),
847            "commit": output("git", &["rev-parse", "HEAD"]),
848            "screenshots": screenshots,
849            "note": "Every frame is real output of the program under test on a PTY.",
850        });
851        save(
852            &mut written,
853            "provenance.json".into(),
854            format!("{:#}\n", json).as_bytes(),
855        )?;
856    }
857    if mp4_written {
858        written.push(mp4_path);
859    }
860    Ok(written)
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866
867    fn recording() -> Recording {
868        let mut parser = vt100::Parser::new(3, 10, 0);
869        parser.process(b"hi");
870        let snapshot = Snapshot::from_screen(parser.screen(), &Theme::default());
871        Recording {
872            title: "t".into(),
873            presentation: Presentation::default(),
874            outputs: Vec::new(),
875            columns: 10,
876            rows: 3,
877            shots: vec![("shot".into(), snapshot.clone())],
878            masks: Vec::new(),
879            timeline: Timeline {
880                frames: vec![(0.0, snapshot)],
881                ..Timeline::default()
882            },
883        }
884    }
885
886    fn scratch(name: &str) -> PathBuf {
887        let dir =
888            std::env::temp_dir().join(format!("rich-record-unit-{name}-{}", std::process::id()));
889        let _ = std::fs::remove_dir_all(&dir);
890        dir
891    }
892
893    const TEXT_ONLY: Formats = Formats::NONE;
894
895    #[test]
896    fn stems_cannot_leave_the_output_directory() {
897        for stem in ["", ".", "..", "a/b", "a\\b", "../x", "x\u{0}"] {
898            assert!(!stem_allowed(stem), "{stem:?}");
899        }
900        for stem in ["demo", "my demo", ".hidden", "a..b", "v1.2"] {
901            assert!(stem_allowed(stem), "{stem:?}");
902        }
903        let dir = scratch("stem");
904        let fonts = raster::Fonts::embedded();
905        let error = write(
906            &recording(),
907            &dir,
908            "..",
909            TEXT_ONLY,
910            &fonts,
911            &Theme::default(),
912            None,
913        )
914        .unwrap_err();
915        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
916        // Refused before anything was written.
917        assert!(!dir.exists());
918        let tape = crate::tape::parse("Screenshot x").unwrap();
919        let error = record(&tape, "a/b", &Options::default()).unwrap_err();
920        assert!(error.message.contains("cannot name a recording"), "{error}");
921    }
922
923    #[test]
924    fn a_recording_too_long_for_video_says_so() {
925        let mut recording = recording();
926        recording.timeline.truncated = true;
927        let dir = scratch("long");
928        let fonts = raster::Fonts::embedded();
929        let gif = Formats {
930            gif: true,
931            ..TEXT_ONLY
932        };
933        let error = write(
934            &recording,
935            &dir,
936            "long",
937            gif,
938            &fonts,
939            &Theme::default(),
940            None,
941        )
942        .unwrap_err();
943        assert!(error.to_string().contains("too long for video"), "{error}");
944        assert!(!dir.exists());
945        // The HTML player plays the same cut timeline: refused too.
946        let html = Formats {
947            html: true,
948            ..TEXT_ONLY
949        };
950        let error = write(
951            &recording,
952            &dir,
953            "long",
954            html,
955            &fonts,
956            &Theme::default(),
957            None,
958        )
959        .unwrap_err();
960        assert!(error.to_string().contains("too long for video"), "{error}");
961        assert!(!dir.exists());
962        // Without video it is written.
963        write(
964            &recording,
965            &dir,
966            "long",
967            TEXT_ONLY,
968            &fonts,
969            &Theme::default(),
970            None,
971        )
972        .unwrap();
973        assert!(dir.join("shot.txt").exists());
974        let _ = std::fs::remove_dir_all(&dir);
975    }
976
977    #[test]
978    fn gif_is_written_by_streaming() {
979        let dir = scratch("gif");
980        let fonts = raster::Fonts::embedded();
981        let gif = Formats {
982            gif: true,
983            ..TEXT_ONLY
984        };
985        let written = write(
986            &recording(),
987            &dir,
988            "g",
989            gif,
990            &fonts,
991            &Theme::default(),
992            None,
993        )
994        .unwrap();
995        assert!(written.iter().any(|p| p.ends_with("g.gif")), "{written:?}");
996        let bytes = std::fs::read(dir.join("g.gif")).unwrap();
997        assert!(bytes.starts_with(b"GIF89a"));
998        let _ = std::fs::remove_dir_all(&dir);
999    }
1000
1001    #[test]
1002    fn outputs_choose_formats_and_files() {
1003        let mut recording = recording();
1004        assert_eq!(recording.formats(Formats::ALL), Formats::ALL);
1005        recording.outputs = crate::tape::parse("Output gif html\nOutput pages/t.html\n")
1006            .unwrap()
1007            .outputs;
1008        let chosen = recording.formats(Formats::ALL);
1009        assert!(chosen.gif && chosen.html && !chosen.png && !chosen.cast);
1010        // A run that skips video still skips it.
1011        let chosen = recording.formats(Formats::NO_VIDEO);
1012        assert!(!chosen.gif && chosen.html);
1013        assert_eq!(recording.output_path(Format::Html, "s"), "pages/t.html");
1014        assert_eq!(recording.output_path(Format::Gif, "s"), "s.gif");
1015        let dir = scratch("outputs");
1016        let fonts = raster::Fonts::embedded();
1017        let html = Formats {
1018            html: true,
1019            ..TEXT_ONLY
1020        };
1021        let written = write(
1022            &recording,
1023            &dir,
1024            "s",
1025            recording.formats(html),
1026            &fonts,
1027            &Theme::default(),
1028            None,
1029        )
1030        .unwrap();
1031        assert!(dir.join("pages/t.html").exists(), "{written:?}");
1032        assert!(dir.join("shot.txt").exists());
1033        let _ = std::fs::remove_dir_all(&dir);
1034    }
1035
1036    #[test]
1037    fn presentation_reaches_the_stills() {
1038        let mut recording = recording();
1039        let dir = scratch("look");
1040        let fonts = raster::Fonts::embedded();
1041        let stills = Formats {
1042            png: true,
1043            svg: true,
1044            ..TEXT_ONLY
1045        };
1046        write(
1047            &recording,
1048            &dir,
1049            "l",
1050            stills,
1051            &fonts,
1052            &Theme::default(),
1053            None,
1054        )
1055        .unwrap();
1056        let framed = std::fs::read(dir.join("shot.png")).unwrap();
1057        let svg = std::fs::read_to_string(dir.join("shot.svg")).unwrap();
1058        assert!(svg.contains("#ff5f57"));
1059        recording.presentation = Presentation {
1060            window: false,
1061            caption: Some("A caption".into()),
1062            key_overlay: false,
1063        };
1064        write(
1065            &recording,
1066            &dir,
1067            "l",
1068            stills,
1069            &fonts,
1070            &Theme::default(),
1071            None,
1072        )
1073        .unwrap();
1074        let bare = std::fs::read(dir.join("shot.png")).unwrap();
1075        let svg = std::fs::read_to_string(dir.join("shot.svg")).unwrap();
1076        assert!(!svg.contains("#ff5f57") && svg.contains("A&#160;caption"));
1077        assert_ne!(framed, bare);
1078        // The text grid does not change with the presentation.
1079        assert_eq!(
1080            std::fs::read_to_string(dir.join("shot.txt")).unwrap(),
1081            "hi\n\n\n"
1082        );
1083        let _ = std::fs::remove_dir_all(&dir);
1084    }
1085
1086    #[cfg(unix)]
1087    #[test]
1088    fn exec_reports_stderr_and_times_out() {
1089        let dir = std::env::temp_dir();
1090        let env = vec![("PATH".to_string(), "/usr/bin:/bin".to_string())];
1091        let limit = Duration::from_secs(10);
1092        assert!(exec("true", &dir, &env, limit, 3).is_ok());
1093        let error = exec("echo oops >&2; exit 2", &dir, &env, limit, 3).unwrap_err();
1094        assert_eq!(error.line, 3);
1095        assert!(error.message.contains("oops"), "{error}");
1096        // More error output than a pipe holds does not hang the command.
1097        let error = exec(
1098            "head -c 1000000 /dev/zero | tr '\\0' x >&2; exit 1",
1099            &dir,
1100            &env,
1101            limit,
1102            4,
1103        )
1104        .unwrap_err();
1105        assert!(
1106            error.message.starts_with("Exec failed"),
1107            "{}",
1108            error.message
1109        );
1110        assert!(error.message.len() < 70 * 1024);
1111        let started = Instant::now();
1112        let error = exec("sleep 30", &dir, &env, Duration::from_millis(300), 5).unwrap_err();
1113        assert!(error.message.contains("timed out"), "{error}");
1114        assert!(started.elapsed() < Duration::from_secs(5));
1115    }
1116}