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/// What [`write_selected`] produced.
656#[derive(Clone, Debug, PartialEq, Eq)]
657pub struct Written {
658    /// The formats written: the tape's `Output` formats that the caller also
659    /// allowed (see [`Recording::formats`]).
660    pub formats: Formats,
661    /// The files written.
662    pub paths: Vec<PathBuf>,
663    /// Files those formats called for that could not be made.
664    pub skipped: Vec<Skipped>,
665}
666
667impl Written {
668    /// Whether every file the selected formats called for was written.
669    pub fn is_complete(&self) -> bool {
670        self.skipped.is_empty()
671    }
672}
673
674/// A file [`write_selected`] could not make, and why.
675#[derive(Clone, Debug, PartialEq, Eq)]
676pub struct Skipped {
677    /// Its format.
678    pub format: Format,
679    /// Where it would have been written.
680    pub path: PathBuf,
681    /// Why it was not.
682    pub reason: String,
683}
684
685/// Write the files the tape asks for: [`write`] with the tape's `Output`
686/// formats that `allowed` also permits ([`Recording::formats`]), so a tape
687/// that names `Output demo.png` writes a PNG and not every format. Pass
688/// [`Formats::ALL`] to honour the tape alone.
689///
690/// An MP4 needs FFmpeg. When the selected formats include one and FFmpeg is
691/// not installed, nothing fails: the MP4 is listed in [`Written::skipped`],
692/// so a caller that needs the video can check [`Written::is_complete`]
693/// rather than equate a successful write with a produced file.
694pub fn write_selected(
695    recording: &Recording,
696    dir: &Path,
697    stem: &str,
698    allowed: Formats,
699    fonts: &raster::Fonts,
700    theme: &Theme,
701    provenance: Option<(&Path, &[u8])>,
702) -> std::io::Result<Written> {
703    let formats = recording.formats(allowed);
704    // Without FFmpeg no MP4 is encoded, so none is validated either: a
705    // recording too long or too large for video still writes the rest and
706    // reports the MP4 skipped.
707    let encode = Formats {
708        mp4: formats.mp4 && video::ffmpeg_available(),
709        ..formats
710    };
711    let paths = write(recording, dir, stem, encode, fonts, theme, provenance)?;
712    let mut skipped = Vec::new();
713    if formats.mp4 {
714        let path = dir.join(recording.output_path(Format::Mp4, stem));
715        if !paths.contains(&path) {
716            skipped.push(Skipped {
717                format: Format::Mp4,
718                path,
719                reason: "ffmpeg not found".into(),
720            });
721        }
722    }
723    Ok(Written {
724        formats,
725        paths,
726        skipped,
727    })
728}
729
730/// Write the recording's files into `dir`, removing screenshots the last
731/// write listed that this recording no longer takes. With `provenance`, the
732/// written `provenance.json` lists the screenshots for the next write.
733/// Returns the paths written.
734///
735/// `formats` is used as given: a tape's `Output` lines are not applied here.
736/// [`write_selected`] applies them, and reports a skipped MP4.
737///
738/// Refused before anything is written: a `stem` that [`stem_allowed`]
739/// refuses, a GIF, MP4 or HTML page of a recording longer than
740/// [`crate::session::MAX_VIDEO`], and images too large to draw.
741pub fn write(
742    recording: &Recording,
743    dir: &Path,
744    stem: &str,
745    formats: Formats,
746    fonts: &raster::Fonts,
747    theme: &Theme,
748    provenance: Option<(&Path, &[u8])>,
749) -> std::io::Result<Vec<PathBuf>> {
750    if !stem_allowed(stem) {
751        return Err(invalid(stem_error(stem)));
752    }
753    // The HTML page's player plays the same timeline, so it would stop at the
754    // cut as silently as a video would.
755    let video = formats.gif || formats.mp4 || formats.html;
756    if video && recording.timeline.truncated {
757        return Err(invalid(format!(
758            "the recording is longer than {}s, too long for video; record it without \
759             GIF, MP4 or the HTML player (--no-video), or shorten the tape",
760            MAX_VIDEO.as_secs()
761        )));
762    }
763    let presentation = &recording.presentation;
764    let look = Look {
765        title: &recording.title,
766        window: presentation.window,
767        caption: presentation.caption.as_deref(),
768    };
769    let still = raster::Frame {
770        title: &recording.title,
771        key: None,
772        size: 28.0,
773        window: look.window,
774        caption: look.caption,
775    };
776    if formats.png {
777        for (_, snapshot) in &recording.shots {
778            check_pixels(snapshot.columns(), snapshot.rows.len(), fonts, &still)?;
779        }
780    }
781    let samples = if video {
782        video::sample_with(&recording.timeline, FRAME_RATE, presentation.key_overlay)
783    } else {
784        Vec::new()
785    };
786    let (video_width, video_height) = video::size(&recording.timeline, &samples, fonts, &look);
787    if video_width.saturating_mul(video_height) > raster::MAX_PIXELS {
788        return Err(invalid(format!(
789            "video frames would be {video_width}x{video_height} pixels, more than {} million; \
790             use a smaller Size or font",
791            raster::MAX_PIXELS / 1_000_000
792        )));
793    }
794    std::fs::create_dir_all(dir)?;
795    for name in orphans(recording, dir) {
796        for suffix in ["txt", "png", "svg"] {
797            let _ = std::fs::remove_file(dir.join(format!("{name}.{suffix}")));
798        }
799    }
800    let mut written = Vec::new();
801    let save = |written: &mut Vec<PathBuf>, name: String, bytes: &[u8]| -> std::io::Result<()> {
802        let path = dir.join(name);
803        if let Some(parent) = path.parent() {
804            std::fs::create_dir_all(parent)?;
805        }
806        std::fs::write(&path, bytes)?;
807        written.push(path);
808        Ok(())
809    };
810    for (name, snapshot) in &recording.shots {
811        save(
812            &mut written,
813            format!("{name}.txt"),
814            recording.text_grid(snapshot).as_bytes(),
815        )?;
816        if formats.svg {
817            save(
818                &mut written,
819                format!("{name}.svg"),
820                svg::svg(snapshot, theme, &look).as_bytes(),
821            )?;
822        }
823        if formats.png {
824            save(
825                &mut written,
826                format!("{name}.png"),
827                &raster::render(snapshot, theme, fonts, &still).png(),
828            )?;
829        }
830    }
831    if formats.cast {
832        let cast = cast::cast(
833            &recording.timeline,
834            recording.columns,
835            recording.rows,
836            &recording.title,
837            theme,
838        );
839        save(
840            &mut written,
841            recording.output_path(Format::Cast, stem),
842            cast.as_bytes(),
843        )?;
844    }
845    if formats.html {
846        let page = html::page(
847            &look,
848            &recording.shots,
849            &recording.timeline,
850            theme,
851            presentation.key_overlay,
852        );
853        save(
854            &mut written,
855            recording.output_path(Format::Html, stem),
856            page.as_bytes(),
857        )?;
858    }
859    let mp4_path = dir.join(recording.output_path(Format::Mp4, stem));
860    if formats.mp4 {
861        if let Some(parent) = mp4_path.parent() {
862            std::fs::create_dir_all(parent)?;
863        }
864    }
865    let mut mp4 = if formats.mp4 && video::ffmpeg_available() {
866        Some(video::Mp4::start(&mp4_path, video_width, video_height)?)
867    } else {
868        None
869    };
870    let timeline = &recording.timeline;
871    if formats.gif {
872        let gif_path = dir.join(recording.output_path(Format::Gif, stem));
873        if let Some(parent) = gif_path.parent() {
874            std::fs::create_dir_all(parent)?;
875        }
876        let mut file = std::io::BufWriter::new(std::fs::File::create(&gif_path)?);
877        // The GIF draws every frame twice; the MP4 takes them on the first.
878        let mut pass = 0;
879        raster::gif_streamed(
880            video_width,
881            video_height,
882            |sink| {
883                pass += 1;
884                video::render_each(timeline, &samples, theme, fonts, &look, &mut |canvas, s| {
885                    if pass == 1 {
886                        if let Some(mp4) = mp4.as_mut() {
887                            mp4.write(&canvas, s)?;
888                        }
889                    }
890                    sink(canvas, s)
891                })
892            },
893            &mut file,
894        )?;
895        std::io::Write::flush(&mut file)?;
896        written.push(gif_path);
897    } else if let Some(mp4) = mp4.as_mut() {
898        video::render_each(timeline, &samples, theme, fonts, &look, &mut |canvas, s| {
899            mp4.write(&canvas, s)
900        })?;
901    }
902    let mp4_written = mp4.is_some();
903    if let Some(mp4) = mp4 {
904        mp4.finish()?;
905    }
906    if let Some((tape_path, tape_bytes)) = provenance {
907        let output = |program: &str, args: &[&str]| {
908            Command::new(program)
909                .args(args)
910                .output()
911                .ok()
912                .filter(|o| o.status.success())
913                .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
914        };
915        let screenshots: Vec<&str> = recording
916            .shots
917            .iter()
918            .map(|(name, _)| name.as_str())
919            .collect();
920        let json = json!({
921            "tape": tape_path.display().to_string(),
922            "tape_fnv1a64": fingerprint(tape_bytes),
923            "recorder": format!("rs-rich-record {}", env!("CARGO_PKG_VERSION")),
924            "rich": output("rich", &["--version"]),
925            "commit": output("git", &["rev-parse", "HEAD"]),
926            "screenshots": screenshots,
927            "note": "Every frame is real output of the program under test on a PTY.",
928        });
929        save(
930            &mut written,
931            "provenance.json".into(),
932            format!("{:#}\n", json).as_bytes(),
933        )?;
934    }
935    if mp4_written {
936        written.push(mp4_path);
937    }
938    Ok(written)
939}
940
941#[cfg(test)]
942mod tests {
943    use super::*;
944
945    fn recording() -> Recording {
946        let mut parser = vt100::Parser::new(3, 10, 0);
947        parser.process(b"hi");
948        let snapshot = Snapshot::from_screen(parser.screen(), &Theme::default());
949        Recording {
950            title: "t".into(),
951            presentation: Presentation::default(),
952            outputs: Vec::new(),
953            columns: 10,
954            rows: 3,
955            shots: vec![("shot".into(), snapshot.clone())],
956            masks: Vec::new(),
957            timeline: Timeline {
958                frames: vec![(0.0, snapshot)],
959                ..Timeline::default()
960            },
961        }
962    }
963
964    fn scratch(name: &str) -> PathBuf {
965        let dir =
966            std::env::temp_dir().join(format!("rich-record-unit-{name}-{}", std::process::id()));
967        let _ = std::fs::remove_dir_all(&dir);
968        dir
969    }
970
971    const TEXT_ONLY: Formats = Formats::NONE;
972
973    #[test]
974    fn stems_cannot_leave_the_output_directory() {
975        for stem in ["", ".", "..", "a/b", "a\\b", "../x", "x\u{0}"] {
976            assert!(!stem_allowed(stem), "{stem:?}");
977        }
978        for stem in ["demo", "my demo", ".hidden", "a..b", "v1.2"] {
979            assert!(stem_allowed(stem), "{stem:?}");
980        }
981        let dir = scratch("stem");
982        let fonts = raster::Fonts::embedded();
983        let error = write(
984            &recording(),
985            &dir,
986            "..",
987            TEXT_ONLY,
988            &fonts,
989            &Theme::default(),
990            None,
991        )
992        .unwrap_err();
993        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
994        // Refused before anything was written.
995        assert!(!dir.exists());
996        let tape = crate::tape::parse("Screenshot x").unwrap();
997        let error = record(&tape, "a/b", &Options::default()).unwrap_err();
998        assert!(error.message.contains("cannot name a recording"), "{error}");
999    }
1000
1001    #[test]
1002    fn a_recording_too_long_for_video_says_so() {
1003        let mut recording = recording();
1004        recording.timeline.truncated = true;
1005        let dir = scratch("long");
1006        let fonts = raster::Fonts::embedded();
1007        let gif = Formats {
1008            gif: true,
1009            ..TEXT_ONLY
1010        };
1011        let error = write(
1012            &recording,
1013            &dir,
1014            "long",
1015            gif,
1016            &fonts,
1017            &Theme::default(),
1018            None,
1019        )
1020        .unwrap_err();
1021        assert!(error.to_string().contains("too long for video"), "{error}");
1022        assert!(!dir.exists());
1023        // The HTML player plays the same cut timeline: refused too.
1024        let html = Formats {
1025            html: true,
1026            ..TEXT_ONLY
1027        };
1028        let error = write(
1029            &recording,
1030            &dir,
1031            "long",
1032            html,
1033            &fonts,
1034            &Theme::default(),
1035            None,
1036        )
1037        .unwrap_err();
1038        assert!(error.to_string().contains("too long for video"), "{error}");
1039        assert!(!dir.exists());
1040        // Without video it is written.
1041        write(
1042            &recording,
1043            &dir,
1044            "long",
1045            TEXT_ONLY,
1046            &fonts,
1047            &Theme::default(),
1048            None,
1049        )
1050        .unwrap();
1051        assert!(dir.join("shot.txt").exists());
1052        let _ = std::fs::remove_dir_all(&dir);
1053    }
1054
1055    #[test]
1056    fn gif_is_written_by_streaming() {
1057        let dir = scratch("gif");
1058        let fonts = raster::Fonts::embedded();
1059        let gif = Formats {
1060            gif: true,
1061            ..TEXT_ONLY
1062        };
1063        let written = write(
1064            &recording(),
1065            &dir,
1066            "g",
1067            gif,
1068            &fonts,
1069            &Theme::default(),
1070            None,
1071        )
1072        .unwrap();
1073        assert!(written.iter().any(|p| p.ends_with("g.gif")), "{written:?}");
1074        let bytes = std::fs::read(dir.join("g.gif")).unwrap();
1075        assert!(bytes.starts_with(b"GIF89a"));
1076        let _ = std::fs::remove_dir_all(&dir);
1077    }
1078
1079    #[test]
1080    fn outputs_choose_formats_and_files() {
1081        let mut recording = recording();
1082        assert_eq!(recording.formats(Formats::ALL), Formats::ALL);
1083        recording.outputs = crate::tape::parse("Output gif html\nOutput pages/t.html\n")
1084            .unwrap()
1085            .outputs;
1086        let chosen = recording.formats(Formats::ALL);
1087        assert!(chosen.gif && chosen.html && !chosen.png && !chosen.cast);
1088        // A run that skips video still skips it.
1089        let chosen = recording.formats(Formats::NO_VIDEO);
1090        assert!(!chosen.gif && chosen.html);
1091        assert_eq!(recording.output_path(Format::Html, "s"), "pages/t.html");
1092        assert_eq!(recording.output_path(Format::Gif, "s"), "s.gif");
1093        let dir = scratch("outputs");
1094        let fonts = raster::Fonts::embedded();
1095        let html = Formats {
1096            html: true,
1097            ..TEXT_ONLY
1098        };
1099        let written = write(
1100            &recording,
1101            &dir,
1102            "s",
1103            recording.formats(html),
1104            &fonts,
1105            &Theme::default(),
1106            None,
1107        )
1108        .unwrap();
1109        assert!(dir.join("pages/t.html").exists(), "{written:?}");
1110        assert!(dir.join("shot.txt").exists());
1111        let _ = std::fs::remove_dir_all(&dir);
1112    }
1113
1114    #[test]
1115    fn without_ffmpeg_a_long_recording_reports_the_video_skipped() {
1116        let mut recording = recording();
1117        recording.timeline.truncated = true;
1118        recording.outputs = crate::tape::parse("Output mp4\n").unwrap().outputs;
1119        let dir = scratch("selected-long");
1120        let result = write_selected(
1121            &recording,
1122            &dir,
1123            "s",
1124            Formats::ALL,
1125            &raster::Fonts::embedded(),
1126            &Theme::default(),
1127            None,
1128        );
1129        if video::ffmpeg_available() {
1130            let error = result.unwrap_err();
1131            assert!(error.to_string().contains("too long for video"), "{error}");
1132        } else {
1133            let written = result.unwrap();
1134            assert!(written.formats.mp4, "{written:?}");
1135            assert_eq!(written.skipped.len(), 1, "{written:?}");
1136            assert_eq!(written.skipped[0].reason, "ffmpeg not found");
1137        }
1138        let _ = std::fs::remove_dir_all(&dir);
1139    }
1140
1141    #[test]
1142    fn write_selected_honours_the_tape_and_reports_a_skipped_video() {
1143        let mut recording = recording();
1144        recording.outputs = crate::tape::parse("Output png\n").unwrap().outputs;
1145        let dir = scratch("selected");
1146        let fonts = raster::Fonts::embedded();
1147        let written = write_selected(
1148            &recording,
1149            &dir,
1150            "s",
1151            Formats::ALL,
1152            &fonts,
1153            &Theme::default(),
1154            None,
1155        )
1156        .unwrap();
1157        // The tape names a PNG, so ALL does not add the other formats.
1158        assert!(written.formats.png && !written.formats.svg && !written.formats.gif);
1159        assert!(dir.join("shot.png").exists() && !dir.join("shot.svg").exists());
1160        assert!(!dir.join("s.cast").exists());
1161        assert!(written.is_complete(), "{written:?}");
1162        let _ = std::fs::remove_dir_all(&dir);
1163
1164        recording.outputs = crate::tape::parse("Output mp4\n").unwrap().outputs;
1165        let dir = scratch("selected-mp4");
1166        let written = write_selected(
1167            &recording,
1168            &dir,
1169            "s",
1170            Formats::ALL,
1171            &fonts,
1172            &Theme::default(),
1173            None,
1174        )
1175        .unwrap();
1176        let mp4 = dir.join("s.mp4");
1177        if video::ffmpeg_available() {
1178            assert!(
1179                written.is_complete() && written.paths.contains(&mp4),
1180                "{written:?}"
1181            );
1182        } else {
1183            assert_eq!(
1184                written.skipped,
1185                [Skipped {
1186                    format: Format::Mp4,
1187                    path: mp4.clone(),
1188                    reason: "ffmpeg not found".into(),
1189                }]
1190            );
1191            assert!(!written.is_complete() && !mp4.exists());
1192        }
1193        // Allowing no video drops the MP4 rather than reporting it skipped.
1194        let written = write_selected(
1195            &recording,
1196            &dir,
1197            "s",
1198            Formats::NO_VIDEO,
1199            &fonts,
1200            &Theme::default(),
1201            None,
1202        )
1203        .unwrap();
1204        assert!(!written.formats.mp4 && written.is_complete());
1205        let _ = std::fs::remove_dir_all(&dir);
1206    }
1207
1208    #[test]
1209    fn presentation_reaches_the_stills() {
1210        let mut recording = recording();
1211        let dir = scratch("look");
1212        let fonts = raster::Fonts::embedded();
1213        let stills = Formats {
1214            png: true,
1215            svg: true,
1216            ..TEXT_ONLY
1217        };
1218        write(
1219            &recording,
1220            &dir,
1221            "l",
1222            stills,
1223            &fonts,
1224            &Theme::default(),
1225            None,
1226        )
1227        .unwrap();
1228        let framed = std::fs::read(dir.join("shot.png")).unwrap();
1229        let svg = std::fs::read_to_string(dir.join("shot.svg")).unwrap();
1230        assert!(svg.contains("#ff5f57"));
1231        recording.presentation = Presentation {
1232            window: false,
1233            caption: Some("A caption".into()),
1234            key_overlay: false,
1235        };
1236        write(
1237            &recording,
1238            &dir,
1239            "l",
1240            stills,
1241            &fonts,
1242            &Theme::default(),
1243            None,
1244        )
1245        .unwrap();
1246        let bare = std::fs::read(dir.join("shot.png")).unwrap();
1247        let svg = std::fs::read_to_string(dir.join("shot.svg")).unwrap();
1248        assert!(!svg.contains("#ff5f57") && svg.contains("A&#160;caption"));
1249        assert_ne!(framed, bare);
1250        // The text grid does not change with the presentation.
1251        assert_eq!(
1252            std::fs::read_to_string(dir.join("shot.txt")).unwrap(),
1253            "hi\n\n\n"
1254        );
1255        let _ = std::fs::remove_dir_all(&dir);
1256    }
1257
1258    #[cfg(unix)]
1259    #[test]
1260    fn exec_reports_stderr_and_times_out() {
1261        let dir = std::env::temp_dir();
1262        let env = vec![("PATH".to_string(), "/usr/bin:/bin".to_string())];
1263        let limit = Duration::from_secs(10);
1264        assert!(exec("true", &dir, &env, limit, 3).is_ok());
1265        let error = exec("echo oops >&2; exit 2", &dir, &env, limit, 3).unwrap_err();
1266        assert_eq!(error.line, 3);
1267        assert!(error.message.contains("oops"), "{error}");
1268        // More error output than a pipe holds does not hang the command.
1269        let error = exec(
1270            "head -c 1000000 /dev/zero | tr '\\0' x >&2; exit 1",
1271            &dir,
1272            &env,
1273            limit,
1274            4,
1275        )
1276        .unwrap_err();
1277        assert!(
1278            error.message.starts_with("Exec failed"),
1279            "{}",
1280            error.message
1281        );
1282        assert!(error.message.len() < 70 * 1024);
1283        let started = Instant::now();
1284        let error = exec("sleep 30", &dir, &env, Duration::from_millis(300), 5).unwrap_err();
1285        assert!(error.message.contains("timed out"), "{error}");
1286        assert!(started.elapsed() < Duration::from_secs(5));
1287    }
1288}