use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::scenario::Scenario;
use crate::step::Step;
const MAX_VHS_RETRIES: u8 = 3;
#[derive(Debug, Clone)]
pub struct VhsTapeSettings {
pub width: u16,
pub height: u16,
pub font_size: u16,
pub theme: String,
pub framerate: u16,
pub padding: u16,
}
impl Default for VhsTapeSettings {
fn default() -> Self {
Self {
width: 1200,
height: 600,
font_size: 14,
theme: String::new(),
framerate: 0,
padding: 0,
}
}
}
impl VhsTapeSettings {
pub fn feature_demo() -> Self {
Self {
width: 3200,
height: 1600,
font_size: 36,
theme: "OneDark".to_string(),
framerate: 30,
padding: 0,
}
}
}
pub struct VhsTape {
content: String,
screenshot_path: PathBuf,
}
impl VhsTape {
pub fn from_scenario(
scenario: &Scenario,
binary_path: &Path,
screenshot_path: &Path,
env_vars: &[(&str, &str)],
) -> Self {
Self::from_scenario_with_settings(
scenario,
binary_path,
screenshot_path,
env_vars,
&VhsTapeSettings::default(),
)
}
pub fn from_scenario_with_settings(
scenario: &Scenario,
binary_path: &Path,
screenshot_path: &Path,
env_vars: &[(&str, &str)],
settings: &VhsTapeSettings,
) -> Self {
let content = compile_tape(scenario, binary_path, screenshot_path, env_vars, settings);
Self {
content,
screenshot_path: screenshot_path.to_path_buf(),
}
}
pub fn render(&self) -> &str {
&self.content
}
pub fn write_to(&self, tape_path: &Path) -> Result<(), std::io::Error> {
std::fs::write(tape_path, &self.content)
}
pub fn execute(&self, tape_path: &Path) -> Result<PathBuf, VhsError> {
check_vhs_installed()?;
self.write_to(tape_path)
.map_err(|err| VhsError::IoError(err.to_string()))?;
let mut last_error = String::new();
for attempt in 1..=MAX_VHS_RETRIES {
let _ = std::fs::remove_file(&self.screenshot_path);
let output = Command::new("vhs")
.arg(tape_path)
.output()
.map_err(|err| VhsError::ExecutionFailed(err.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(VhsError::ExecutionFailed(format!(
"VHS exited with error: {stderr}"
)));
}
if self.screenshot_path.exists() {
return Ok(self.screenshot_path.clone());
}
last_error = format!(
"Attempt {attempt}/{MAX_VHS_RETRIES}: screenshot not produced at {}",
self.screenshot_path.display()
);
}
Err(VhsError::ScreenshotNotProduced(last_error))
}
pub fn screenshot_path(&self) -> &Path {
&self.screenshot_path
}
}
#[derive(Debug, thiserror::Error)]
pub enum VhsError {
#[error("VHS not installed: {0}")]
NotInstalled(String),
#[error("VHS execution failed: {0}")]
ExecutionFailed(String),
#[error("Screenshot not produced: {0}")]
ScreenshotNotProduced(String),
#[error("I/O error: {0}")]
IoError(String),
}
fn compile_tape(
scenario: &Scenario,
binary_path: &Path,
screenshot_path: &Path,
env_vars: &[(&str, &str)],
settings: &VhsTapeSettings,
) -> String {
let mut tape = String::new();
let gif_stem = screenshot_path
.file_stem()
.unwrap_or_default()
.to_string_lossy();
let gif_path = screenshot_path
.parent()
.unwrap_or(Path::new("."))
.join(format!("{gif_stem}.gif"));
let _ = writeln!(tape, "Set Shell \"bash\"");
let _ = writeln!(tape, "Set FontSize {}", settings.font_size);
let _ = writeln!(tape, "Set Width {}", settings.width);
let _ = writeln!(tape, "Set Height {}", settings.height);
let _ = writeln!(tape, "Set Padding {}", settings.padding);
let _ = writeln!(tape, "Set TypingSpeed 0");
if !settings.theme.is_empty() {
let _ = writeln!(
tape,
"Set Theme \"{}\"",
escape_vhs_double_quote(&settings.theme)
);
}
if settings.framerate > 0 {
let _ = writeln!(tape, "Set Framerate {}", settings.framerate);
}
let _ = writeln!(tape);
let _ = writeln!(
tape,
"Output \"{}\"",
escape_vhs_double_quote(&gif_path.display().to_string())
);
let _ = writeln!(tape);
let _ = writeln!(tape, "Hide");
for (key, value) in env_vars {
let escaped_value = escape_shell_single_quote(value);
let export_cmd = format!("export {key}='{escaped_value}'");
let _ = writeln!(tape, "Type \"{}\"", escape_vhs_double_quote(&export_cmd));
let _ = writeln!(tape, "Enter");
let _ = writeln!(tape, "Sleep 200ms");
}
let _ = writeln!(tape, "Type \"clear\"");
let _ = writeln!(tape, "Enter");
let _ = writeln!(tape, "Sleep 200ms");
let escaped_binary = escape_shell_single_quote(&binary_path.display().to_string());
let _ = writeln!(
tape,
"Type \"{}\"",
escape_vhs_double_quote(&format!("'{escaped_binary}'"))
);
let _ = writeln!(tape, "Enter");
let _ = writeln!(tape, "Sleep 2s");
let _ = writeln!(tape, "Show");
let _ = writeln!(tape);
for step in &scenario.steps {
compile_step(&mut tape, step, screenshot_path);
}
let _ = writeln!(tape);
let _ = writeln!(tape, "Hide");
let _ = writeln!(tape, "Type \"q\"");
let _ = writeln!(tape, "Sleep 1s");
tape
}
fn compile_step(tape: &mut String, step: &Step, screenshot_path: &Path) {
match step {
Step::WriteText(text) => {
let _ = writeln!(tape, "Type \"{}\"", escape_vhs_double_quote(text));
}
Step::PressKey(key) => {
let vhs_key = key_to_vhs_command(key);
let _ = writeln!(tape, "{vhs_key}");
}
Step::Sleep(duration) | Step::ViewingPause(duration) => {
let ms = duration.as_millis();
if ms >= 1000 && ms % 1000 == 0 {
let _ = writeln!(tape, "Sleep {}s", ms / 1000);
} else {
let _ = writeln!(tape, "Sleep {ms}ms");
}
}
Step::WaitForText { needle, timeout_ms } => {
let timeout = format_vhs_duration(*timeout_ms);
let _ = writeln!(
tape,
"Wait+Screen@{timeout} /{needle}/",
needle = escape_vhs_regex(needle)
);
}
Step::WaitForStableFrame {
stable_ms,
timeout_ms: _,
} => {
let _ = writeln!(tape, "Sleep {stable_ms}ms");
}
Step::Capture | Step::CaptureLabeled { .. } => {
let _ = writeln!(
tape,
"Screenshot \"{}\"",
escape_vhs_double_quote(&screenshot_path.display().to_string())
);
}
Step::Eventually { timeout, .. } => {
let ms = timeout.as_millis();
if ms >= 1000 && ms % 1000 == 0 {
let _ = writeln!(tape, "Sleep {}s", ms / 1000);
} else {
let _ = writeln!(tape, "Sleep {ms}ms");
}
}
}
}
fn key_to_vhs_command(key: &str) -> String {
match key.to_lowercase().as_str() {
"enter" | "return" => "Enter".to_string(),
"tab" => "Tab".to_string(),
"escape" | "esc" => "Escape".to_string(),
"backspace" => "Backspace".to_string(),
"up" => "Up".to_string(),
"down" => "Down".to_string(),
"right" => "Right".to_string(),
"left" => "Left".to_string(),
"space" => "Space".to_string(),
"pageup" => "PageUp".to_string(),
"pagedown" => "PageDown".to_string(),
other => {
if let Some(character) = other.strip_prefix("ctrl+") {
format!("Ctrl+{}", character.to_uppercase())
} else {
format!("Type \"{}\"", escape_vhs_double_quote(other))
}
}
}
}
fn escape_vhs_double_quote(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn escape_shell_single_quote(value: &str) -> String {
value.replace('\'', "'\\''")
}
fn format_vhs_duration(milliseconds: u32) -> String {
if milliseconds >= 1000 && milliseconds.is_multiple_of(1000) {
format!("{}s", milliseconds / 1000)
} else {
format!("{milliseconds}ms")
}
}
fn escape_vhs_regex(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
if matches!(
character,
'/' | '.'
| '*'
| '+'
| '?'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
| '|'
| '^'
| '$'
| '\\'
) {
escaped.push('\\');
}
escaped.push(character);
}
escaped
}
pub fn check_vhs_installed() -> Result<(), VhsError> {
Command::new("vhs").arg("--version").output().map_err(|_| {
VhsError::NotInstalled("VHS is not installed. Install with: brew install vhs".to_string())
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compile_tape_includes_header_settings() {
let scenario = Scenario::new("test").sleep_ms(100).capture();
let settings = VhsTapeSettings::default();
let tape = compile_tape(
&scenario,
Path::new("/usr/bin/echo"),
Path::new("/tmp/shot.png"),
&[],
&settings,
);
assert!(tape.contains("Set Shell \"bash\""));
assert!(tape.contains(&format!("Set FontSize {}", settings.font_size)));
assert!(tape.contains(&format!("Set Width {}", settings.width)));
assert!(tape.contains("Set Padding 0"));
}
#[test]
fn compile_tape_includes_env_vars() {
let scenario = Scenario::new("test").capture();
let tape = compile_tape(
&scenario,
Path::new("/usr/bin/echo"),
Path::new("/tmp/shot.png"),
&[("AGENTTY_ROOT", "/tmp/root")],
&VhsTapeSettings::default(),
);
assert!(tape.contains("export AGENTTY_ROOT='/tmp/root'"));
}
#[test]
fn compile_tape_includes_screenshot() {
let scenario = Scenario::new("test").capture();
let tape = compile_tape(
&scenario,
Path::new("/usr/bin/echo"),
Path::new("/tmp/shot.png"),
&[],
&VhsTapeSettings::default(),
);
assert!(tape.contains("Screenshot \"/tmp/shot.png\""));
}
#[test]
fn key_to_vhs_command_maps_common_keys() {
assert_eq!(key_to_vhs_command("Enter"), "Enter");
assert_eq!(key_to_vhs_command("tab"), "Tab");
assert_eq!(key_to_vhs_command("escape"), "Escape");
assert_eq!(key_to_vhs_command("up"), "Up");
assert_eq!(key_to_vhs_command("ctrl+c"), "Ctrl+C");
}
#[test]
fn compile_step_wait_for_text_emits_wait_screen_with_regex() {
let step = Step::wait_for_text("Loading", 5000);
let mut tape = String::new();
compile_step(&mut tape, &step, Path::new("/tmp/shot.png"));
assert!(tape.contains("Wait+Screen@5s /Loading/"));
}
#[test]
fn compile_step_wait_for_text_formats_fractional_timeout_as_milliseconds() {
let step = Step::wait_for_text("Startup", 1500);
let mut tape = String::new();
compile_step(&mut tape, &step, Path::new("/tmp/shot.png"));
assert!(tape.contains("Wait+Screen@1500ms /Startup/"));
}
#[test]
fn compile_step_wait_for_text_escapes_regex_metacharacters() {
let step = Step::wait_for_text("[test] foo.bar", 3000);
let mut tape = String::new();
compile_step(&mut tape, &step, Path::new("/tmp/shot.png"));
assert!(tape.contains(r"Wait+Screen@3s /\[test\] foo\.bar/"));
}
#[test]
fn compile_step_viewing_pause_emits_sleep_seconds() {
let step = Step::viewing_pause_ms(2000);
let mut tape = String::new();
compile_step(&mut tape, &step, Path::new("/tmp/shot.png"));
assert!(tape.contains("Sleep 2s"));
}
#[test]
fn compile_step_viewing_pause_emits_sleep_milliseconds() {
let step = Step::viewing_pause_ms(1500);
let mut tape = String::new();
compile_step(&mut tape, &step, Path::new("/tmp/shot.png"));
assert!(tape.contains("Sleep 1500ms"));
}
#[test]
fn compile_step_eventually_emits_sleep_seconds_for_even_timeout() {
let step = Step::eventually(
std::time::Duration::from_secs(5),
std::time::Duration::from_millis(50),
|_frame| Ok(()),
);
let mut tape = String::new();
compile_step(&mut tape, &step, Path::new("/tmp/shot.png"));
assert!(
tape.contains("Sleep 5s"),
"expected Sleep 5s fallback, got: {tape}"
);
}
#[test]
fn compile_step_eventually_emits_sleep_milliseconds_for_fractional_timeout() {
let step = Step::eventually(
std::time::Duration::from_millis(750),
std::time::Duration::from_millis(25),
|_frame| Ok(()),
);
let mut tape = String::new();
compile_step(&mut tape, &step, Path::new("/tmp/shot.png"));
assert!(
tape.contains("Sleep 750ms"),
"expected Sleep 750ms fallback, got: {tape}"
);
}
#[test]
fn compile_step_sleep_uses_seconds_when_even() {
let step = Step::sleep_ms(3000);
let mut tape = String::new();
compile_step(&mut tape, &step, Path::new("/tmp/shot.png"));
assert!(tape.contains("Sleep 3s"));
}
#[test]
fn compile_step_sleep_uses_milliseconds_when_fractional() {
let step = Step::sleep_ms(500);
let mut tape = String::new();
compile_step(&mut tape, &step, Path::new("/tmp/shot.png"));
assert!(tape.contains("Sleep 500ms"));
}
#[test]
fn escape_vhs_double_quote_escapes_quotes_and_backslashes() {
assert_eq!(escape_vhs_double_quote(r#"hello"world"#), r#"hello\"world"#);
assert_eq!(escape_vhs_double_quote(r"back\slash"), r"back\\slash");
assert_eq!(escape_vhs_double_quote("clean"), "clean");
}
#[test]
fn escape_shell_single_quote_wraps_internal_quotes() {
assert_eq!(escape_shell_single_quote("it's"), "it'\\''s");
assert_eq!(escape_shell_single_quote("clean"), "clean");
}
#[test]
fn escape_vhs_regex_escapes_metacharacters() {
assert_eq!(escape_vhs_regex("plain"), "plain");
assert_eq!(escape_vhs_regex("a.b"), r"a\.b");
assert_eq!(escape_vhs_regex("[x]"), r"\[x\]");
assert_eq!(escape_vhs_regex("a/b"), r"a\/b");
assert_eq!(escape_vhs_regex("a+b*c?"), r"a\+b\*c\?");
assert_eq!(escape_vhs_regex(r"back\slash"), r"back\\slash");
}
#[test]
fn format_vhs_duration_uses_seconds_for_even_multiples() {
assert_eq!(format_vhs_duration(1000), "1s");
assert_eq!(format_vhs_duration(5000), "5s");
assert_eq!(format_vhs_duration(30000), "30s");
}
#[test]
fn format_vhs_duration_uses_milliseconds_for_fractional() {
assert_eq!(format_vhs_duration(500), "500ms");
assert_eq!(format_vhs_duration(1500), "1500ms");
assert_eq!(format_vhs_duration(100), "100ms");
}
#[test]
fn compile_tape_escapes_env_value_with_single_quote() {
let scenario = Scenario::new("test").capture();
let tape = compile_tape(
&scenario,
Path::new("/usr/bin/echo"),
Path::new("/tmp/shot.png"),
&[("KEY", "it's a value")],
&VhsTapeSettings::default(),
);
assert!(tape.contains(r"it'\\''s a value"));
}
#[test]
fn compile_tape_shell_quotes_binary_path() {
let scenario = Scenario::new("test").capture();
let tape = compile_tape(
&scenario,
Path::new("/usr/bin/echo"),
Path::new("/tmp/shot.png"),
&[],
&VhsTapeSettings::default(),
);
assert!(tape.contains("Type \"'/usr/bin/echo'\""));
}
#[test]
fn compile_tape_clears_terminal_and_launches_binary_before_show() {
let scenario = Scenario::new("test").capture();
let tape = compile_tape(
&scenario,
Path::new("/usr/bin/echo"),
Path::new("/tmp/shot.png"),
&[("KEY", "val")],
&VhsTapeSettings::default(),
);
let hide_pos = tape.find("Hide").expect("tape must contain Hide");
let clear_pos = tape
.find("Type \"clear\"")
.expect("tape must contain clear");
let binary_pos = tape
.find("Type \"'/usr/bin/echo'\"")
.expect("tape must contain binary launch");
let show_pos = tape.find("Show").expect("tape must contain Show");
assert!(hide_pos < clear_pos, "Hide must precede clear");
assert!(clear_pos < binary_pos, "clear must precede binary launch");
assert!(binary_pos < show_pos, "binary launch must precede Show");
}
#[test]
fn compile_tape_shell_quotes_binary_path_with_spaces() {
let scenario = Scenario::new("test").capture();
let tape = compile_tape(
&scenario,
Path::new("/path with spaces/bin"),
Path::new("/tmp/shot.png"),
&[],
&VhsTapeSettings::default(),
);
assert!(tape.contains("Type \"'/path with spaces/bin'\""));
}
#[test]
fn feature_demo_settings_have_expected_values() {
let settings = VhsTapeSettings::feature_demo();
assert_eq!(settings.width, 3200);
assert_eq!(settings.height, 1600);
assert_eq!(settings.font_size, 36);
assert_eq!(settings.theme, "OneDark");
assert_eq!(settings.framerate, 30);
assert_eq!(settings.padding, 0);
}
#[test]
fn default_settings_match_legacy_constants() {
let settings = VhsTapeSettings::default();
assert_eq!(settings.width, 1200);
assert_eq!(settings.height, 600);
assert_eq!(settings.font_size, 14);
assert!(settings.theme.is_empty());
assert_eq!(settings.framerate, 0);
assert_eq!(settings.padding, 0);
}
#[test]
fn from_scenario_with_settings_applies_feature_demo() {
let scenario = Scenario::new("feature_test").sleep_ms(100).capture();
let settings = VhsTapeSettings::feature_demo();
let tape = VhsTape::from_scenario_with_settings(
&scenario,
Path::new("/usr/bin/echo"),
Path::new("/tmp/shot.png"),
&[],
&settings,
);
let content = tape.render();
assert!(content.contains("Set FontSize 36"));
assert!(content.contains("Set Width 3200"));
assert!(content.contains("Set Height 1600"));
assert!(content.contains("Set Theme \"OneDark\""));
assert!(content.contains("Set Framerate 30"));
}
#[test]
fn from_scenario_with_settings_omits_empty_theme() {
let scenario = Scenario::new("no_theme").capture();
let settings = VhsTapeSettings::default();
let tape = VhsTape::from_scenario_with_settings(
&scenario,
Path::new("/usr/bin/echo"),
Path::new("/tmp/shot.png"),
&[],
&settings,
);
let content = tape.render();
assert!(!content.contains("Set Theme"));
}
#[test]
fn from_scenario_with_settings_omits_zero_framerate() {
let scenario = Scenario::new("no_framerate").capture();
let settings = VhsTapeSettings::default();
let tape = VhsTape::from_scenario_with_settings(
&scenario,
Path::new("/usr/bin/echo"),
Path::new("/tmp/shot.png"),
&[],
&settings,
);
let content = tape.render();
assert!(!content.contains("Set Framerate"));
}
}