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
655pub 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 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 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 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 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 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 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 caption"));
1077 assert_ne!(framed, bare);
1078 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 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}