1use 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
16const PROMPT: &str = "❯";
18const EXEC_TIMEOUT: Duration = Duration::from_secs(60);
20const EXEC_STDERR: u64 = 64 * 1024;
22
23pub 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#[derive(Clone, Debug, Default)]
44pub struct Options {
45 pub bin_dir: Option<PathBuf>,
47 pub repo: Option<PathBuf>,
50 pub theme: Theme,
51}
52
53#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct Presentation {
57 pub window: bool,
59 pub caption: Option<String>,
61 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#[derive(Clone, Debug)]
77pub struct Recording {
78 pub title: String,
79 pub presentation: Presentation,
80 pub outputs: Vec<Output>,
82 pub columns: u16,
83 pub rows: u16,
84 pub shots: Vec<(String, Snapshot)>,
86 pub masks: Vec<(fancy_regex::Regex, String)>,
88 pub timeline: Timeline,
89}
90
91struct 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 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
124fn 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 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 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
177fn shell_command(shell: Shell) -> Vec<String> {
180 let args: &[&str] = match shell {
181 Shell::Bash => &["--noprofile", "--norc", "-i"],
182 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
201fn 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
212pub 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
231fn 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
269fn 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 let _ = std::io::copy(&mut pipe, &mut std::io::sink());
297 let _ = sender.send(kept);
298 });
299 }
300 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
336pub 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 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 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 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 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 pub fn text_grid(&self, snapshot: &Snapshot) -> String {
442 crate::tape::apply_masks(&self.masks, &snapshot.text_grid())
443 }
444
445 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 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#[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 pub mp4: bool,
480 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 pub const NO_VIDEO: Formats = Formats {
495 gif: false,
496 mp4: false,
497 ..Formats::ALL
498 };
499 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 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
541fn 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 .filter(|name| crate::tape::screenshot_name_allowed(name))
557 .map(str::to_string)
558 .collect()
559}
560
561pub 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#[derive(Clone, Debug, PartialEq, Eq)]
579pub enum Problem {
580 Differs { name: String, diff: String },
582 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
600pub 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
622fn 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
636fn 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#[derive(Clone, Debug, PartialEq, Eq)]
657pub struct Written {
658 pub formats: Formats,
661 pub paths: Vec<PathBuf>,
663 pub skipped: Vec<Skipped>,
665}
666
667impl Written {
668 pub fn is_complete(&self) -> bool {
670 self.skipped.is_empty()
671 }
672}
673
674#[derive(Clone, Debug, PartialEq, Eq)]
676pub struct Skipped {
677 pub format: Format,
679 pub path: PathBuf,
681 pub reason: String,
683}
684
685pub 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 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
730pub 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 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 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 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 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 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 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 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 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 caption"));
1249 assert_ne!(framed, bare);
1250 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 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}