use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use crate::frame::TerminalFrame;
use crate::proof::report::ProofReport;
use crate::scenario::Scenario;
use crate::session::{PtySessionBuilder, PtySessionError};
use crate::vhs::{VhsError, VhsTape, VhsTapeSettings, check_vhs_installed};
#[derive(Debug, Clone)]
pub struct FeatureMeta {
pub name: String,
pub title: String,
pub description: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum GifMode {
#[default]
GenerateIfStale,
CheckOnly,
AlwaysGenerate,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum GifStatus {
Generated(PathBuf),
CacheHit(PathBuf),
VhsNotInstalled,
NoOutputDir,
DirCreateFailed(std::io::Error),
TapeExecutionFailed(VhsError),
Fresh {
gif_path: PathBuf,
hash: u64,
},
Stale {
gif_path: PathBuf,
current: u64,
committed: Option<u64>,
},
}
impl GifStatus {
pub fn gif_path(&self) -> Option<&Path> {
match self {
Self::Generated(path) | Self::CacheHit(path) => Some(path),
Self::Fresh { gif_path, .. } | Self::Stale { gif_path, .. } => Some(gif_path),
_ => None,
}
}
pub fn is_failure(&self) -> bool {
matches!(
self,
Self::DirCreateFailed(_) | Self::TapeExecutionFailed(_)
)
}
pub fn is_stale(&self) -> bool {
matches!(self, Self::Stale { .. })
}
}
pub struct FeatureResult {
pub frame: TerminalFrame,
pub report: ProofReport,
pub meta: FeatureMeta,
pub gif_status: GifStatus,
}
#[must_use]
pub struct FeatureDemo {
meta: FeatureMeta,
gif_output_dir: Option<PathBuf>,
gif_settings: VhsTapeSettings,
gif_mode: GifMode,
}
impl FeatureDemo {
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
Self {
meta: FeatureMeta {
title: name.clone(),
description: String::new(),
name,
},
gif_output_dir: None,
gif_settings: VhsTapeSettings::feature_demo(),
gif_mode: GifMode::default(),
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.meta.title = title.into();
self
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.meta.description = description.into();
self
}
pub fn gif_output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.gif_output_dir = Some(dir.into());
self
}
pub fn gif_settings(mut self, settings: VhsTapeSettings) -> Self {
self.gif_settings = settings;
self
}
pub fn gif_mode(mut self, mode: GifMode) -> Self {
self.gif_mode = mode;
self
}
pub fn run(
self,
scenario: &Scenario,
builder: PtySessionBuilder,
binary_path: &Path,
env_pairs: &[(&str, &str)],
) -> Result<FeatureResult, PtySessionError> {
let (frame, report) = scenario.run_with_proof(builder)?;
let gif_status = match self.gif_output_dir.as_deref() {
Some(output_dir) => generate_gif(
scenario,
&report,
&self.meta.name,
output_dir,
self.gif_mode,
VhsContext {
settings: &self.gif_settings,
binary_path,
env_pairs,
},
),
None => GifStatus::NoOutputDir,
};
Ok(FeatureResult {
frame,
report,
meta: self.meta,
gif_status,
})
}
}
pub fn compute_frame_hash(report: &ProofReport) -> u64 {
let mut hasher = DefaultHasher::new();
for capture in &report.captures {
capture.frame_bytes.hash(&mut hasher);
}
hasher.finish()
}
pub fn hash_sidecar_path(output_dir: &Path, name: &str) -> PathBuf {
output_dir.join(format!(".{name}.hash"))
}
#[derive(Clone, Copy)]
struct VhsContext<'a> {
settings: &'a VhsTapeSettings,
binary_path: &'a Path,
env_pairs: &'a [(&'a str, &'a str)],
}
fn generate_gif(
scenario: &Scenario,
report: &ProofReport,
name: &str,
output_dir: &Path,
mode: GifMode,
vhs: VhsContext<'_>,
) -> GifStatus {
let hash_path = hash_sidecar_path(output_dir, name);
let gif_path = output_dir.join(format!("{name}.gif"));
let current_hash = compute_frame_hash(report);
let committed_hash = read_committed_hash(&hash_path);
if matches!(mode, GifMode::CheckOnly) {
let gif_present = gif_path.exists();
let hash_matches = committed_hash == Some(current_hash);
return if gif_present && hash_matches {
GifStatus::Fresh {
gif_path,
hash: current_hash,
}
} else {
GifStatus::Stale {
gif_path,
current: current_hash,
committed: committed_hash,
}
};
}
if let Err(err) = check_vhs_installed() {
return vhs_missing_status(mode, err);
}
if let Err(err) = std::fs::create_dir_all(output_dir) {
return GifStatus::DirCreateFailed(err);
}
if matches!(mode, GifMode::GenerateIfStale)
&& gif_path.exists()
&& committed_hash == Some(current_hash)
{
return GifStatus::CacheHit(gif_path);
}
let hash_string = current_hash.to_string();
let screenshot_path = output_dir.join(format!("{name}.png"));
let tape = VhsTape::from_scenario_with_settings(
scenario,
vhs.binary_path,
&screenshot_path,
vhs.env_pairs,
vhs.settings,
);
let tape_path = output_dir.join(format!("{name}.tape"));
match tape.execute(&tape_path) {
Ok(_) => {
let _ = std::fs::write(&hash_path, &hash_string);
let _ = std::fs::remove_file(&tape_path);
let _ = std::fs::remove_file(&screenshot_path);
GifStatus::Generated(gif_path)
}
Err(err) => {
let _ = std::fs::remove_file(&tape_path);
GifStatus::TapeExecutionFailed(err)
}
}
}
fn vhs_missing_status(mode: GifMode, err: VhsError) -> GifStatus {
match mode {
GifMode::AlwaysGenerate => GifStatus::TapeExecutionFailed(err),
_ => GifStatus::VhsNotInstalled,
}
}
fn read_committed_hash(hash_path: &Path) -> Option<u64> {
let raw = std::fs::read_to_string(hash_path).ok()?;
raw.trim().parse::<u64>().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feature_demo_builder_sets_metadata() {
let demo = FeatureDemo::new("test_feature")
.title("Test Feature")
.description("A test description.");
assert_eq!(demo.meta.name, "test_feature");
assert_eq!(demo.meta.title, "Test Feature");
assert_eq!(demo.meta.description, "A test description.");
}
#[test]
fn feature_demo_defaults_title_to_name() {
let demo = FeatureDemo::new("my_feature");
assert_eq!(demo.meta.title, "my_feature");
assert_eq!(demo.meta.description, "");
}
#[test]
fn feature_demo_defaults_to_feature_demo_gif_settings() {
let demo = FeatureDemo::new("settings_check");
let expected = VhsTapeSettings::feature_demo();
assert_eq!(demo.gif_settings.width, expected.width);
assert_eq!(demo.gif_settings.height, expected.height);
assert_eq!(demo.gif_settings.font_size, expected.font_size);
assert_eq!(demo.gif_settings.theme, expected.theme);
}
#[test]
fn feature_demo_gif_output_dir_configurable() {
let demo = FeatureDemo::new("dir_check").gif_output_dir("/tmp/gifs");
assert_eq!(demo.gif_output_dir.as_deref(), Some(Path::new("/tmp/gifs")));
}
#[test]
fn feature_demo_no_gif_dir_means_none() {
let demo = FeatureDemo::new("no_gif");
assert!(demo.gif_output_dir.is_none());
}
#[test]
fn feature_demo_default_mode_is_generate_if_stale() {
let demo = FeatureDemo::new("mode_check");
assert_eq!(demo.gif_mode, GifMode::GenerateIfStale);
}
#[test]
fn feature_demo_gif_mode_configurable() {
let demo = FeatureDemo::new("mode_check").gif_mode(GifMode::CheckOnly);
assert_eq!(demo.gif_mode, GifMode::CheckOnly);
}
#[test]
fn vhs_missing_status_always_generate_is_hard_failure() {
let err = VhsError::NotInstalled("missing".to_string());
let status = vhs_missing_status(GifMode::AlwaysGenerate, err);
assert!(
matches!(status, GifStatus::TapeExecutionFailed(_)),
"AlwaysGenerate must surface missing VHS as a hard failure, got {status:?}",
);
assert!(status.is_failure());
}
#[test]
fn vhs_missing_status_generate_if_stale_is_benign_skip() {
let err = VhsError::NotInstalled("missing".to_string());
let status = vhs_missing_status(GifMode::GenerateIfStale, err);
assert!(matches!(status, GifStatus::VhsNotInstalled));
assert!(!status.is_failure());
}
#[test]
fn vhs_missing_status_check_only_is_benign_skip() {
let err = VhsError::NotInstalled("missing".to_string());
let status = vhs_missing_status(GifMode::CheckOnly, err);
assert!(matches!(status, GifStatus::VhsNotInstalled));
assert!(!status.is_failure());
}
#[test]
fn compute_frame_hash_deterministic() {
let frame = TerminalFrame::new(80, 24, b"Hello");
let mut report = ProofReport::new("hash_test");
report.add_capture("snap", "Snapshot", &frame);
let hash_a = compute_frame_hash(&report);
let hash_b = compute_frame_hash(&report);
assert_eq!(hash_a, hash_b);
}
#[test]
fn compute_frame_hash_differs_for_different_content() {
let frame_a = TerminalFrame::new(80, 24, b"Hello");
let frame_b = TerminalFrame::new(80, 24, b"World");
let mut report_a = ProofReport::new("a");
report_a.add_capture("snap", "A", &frame_a);
let mut report_b = ProofReport::new("b");
report_b.add_capture("snap", "B", &frame_b);
let hash_a = compute_frame_hash(&report_a);
let hash_b = compute_frame_hash(&report_b);
assert_ne!(hash_a, hash_b);
}
#[test]
fn compute_frame_hash_empty_report() {
let report = ProofReport::new("empty");
let hash = compute_frame_hash(&report);
assert_eq!(hash, compute_frame_hash(&report));
}
#[test]
fn hash_sidecar_path_uses_dot_prefix_next_to_gif() {
let dir = Path::new("/tmp/features");
let sidecar = hash_sidecar_path(dir, "session_creation");
assert_eq!(sidecar, Path::new("/tmp/features/.session_creation.hash"));
}
#[test]
fn read_committed_hash_returns_none_for_missing_file() {
let dir = tempfile::TempDir::new().expect("failed to create temp dir");
let missing = dir.path().join(".missing.hash");
let parsed = read_committed_hash(&missing);
assert!(parsed.is_none());
}
#[test]
fn read_committed_hash_parses_trimmed_decimal() {
let dir = tempfile::TempDir::new().expect("failed to create temp dir");
let path = dir.path().join(".valid.hash");
std::fs::write(&path, " 12345\n").expect("write hash");
let parsed = read_committed_hash(&path);
assert_eq!(parsed, Some(12345));
}
#[test]
fn generate_gif_check_only_does_not_create_output_dir() {
let temp = tempfile::TempDir::new().expect("failed to create temp dir");
let missing_dir = temp.path().join("never_created");
let report = ProofReport::new("check_only_readonly");
let scenario = Scenario::new("check_only_readonly");
let settings = VhsTapeSettings::feature_demo();
let binary = Path::new("/usr/bin/true");
let env_pairs: &[(&str, &str)] = &[];
let vhs = VhsContext {
settings: &settings,
binary_path: binary,
env_pairs,
};
let status = generate_gif(
&scenario,
&report,
"check_only_readonly",
&missing_dir,
GifMode::CheckOnly,
vhs,
);
assert!(status.is_stale(), "expected stale verdict, got {status:?}");
assert!(
!missing_dir.exists(),
"CheckOnly must not create the output directory",
);
}
#[test]
fn generate_gif_check_only_returns_fresh_when_gif_and_sidecar_match() {
let temp = tempfile::TempDir::new().expect("failed to create temp dir");
let output_dir = temp.path();
let name = "check_only_fresh";
let frame = TerminalFrame::new(80, 24, b"Hello");
let mut report = ProofReport::new(name);
report.add_capture("snap", "Snapshot", &frame);
let expected_hash = compute_frame_hash(&report);
let gif_path = output_dir.join(format!("{name}.gif"));
std::fs::write(&gif_path, b"fake-gif-bytes").expect("write fake gif");
let sidecar = hash_sidecar_path(output_dir, name);
std::fs::write(&sidecar, expected_hash.to_string()).expect("write sidecar");
let scenario = Scenario::new(name);
let settings = VhsTapeSettings::feature_demo();
let binary = Path::new("/usr/bin/true");
let env_pairs: &[(&str, &str)] = &[];
let vhs = VhsContext {
settings: &settings,
binary_path: binary,
env_pairs,
};
let status = generate_gif(
&scenario,
&report,
name,
output_dir,
GifMode::CheckOnly,
vhs,
);
let GifStatus::Fresh {
gif_path: returned_path,
hash,
} = status
else {
unreachable!("expected Fresh verdict, got {status:?}");
};
assert_eq!(returned_path, gif_path);
assert_eq!(hash, expected_hash);
}
#[test]
fn read_committed_hash_returns_none_for_garbage() {
let dir = tempfile::TempDir::new().expect("failed to create temp dir");
let path = dir.path().join(".garbage.hash");
std::fs::write(&path, "not-a-number").expect("write hash");
let parsed = read_committed_hash(&path);
assert!(parsed.is_none());
}
#[test]
fn gif_status_generated_returns_path() {
let status = GifStatus::Generated(PathBuf::from("/tmp/test.gif"));
assert_eq!(status.gif_path(), Some(Path::new("/tmp/test.gif")));
assert!(!status.is_failure());
assert!(!status.is_stale());
}
#[test]
fn gif_status_cache_hit_returns_path() {
let status = GifStatus::CacheHit(PathBuf::from("/tmp/cached.gif"));
assert_eq!(status.gif_path(), Some(Path::new("/tmp/cached.gif")));
assert!(!status.is_failure());
assert!(!status.is_stale());
}
#[test]
fn gif_status_vhs_not_installed_is_not_failure() {
let status = GifStatus::VhsNotInstalled;
assert!(status.gif_path().is_none());
assert!(!status.is_failure());
assert!(!status.is_stale());
}
#[test]
fn gif_status_no_output_dir_is_not_failure() {
let status = GifStatus::NoOutputDir;
assert!(status.gif_path().is_none());
assert!(!status.is_failure());
assert!(!status.is_stale());
}
#[test]
fn gif_status_dir_create_failed_is_failure() {
let err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let status = GifStatus::DirCreateFailed(err);
assert!(status.gif_path().is_none());
assert!(status.is_failure());
assert!(!status.is_stale());
}
#[test]
fn gif_status_tape_execution_failed_is_failure() {
let err = VhsError::ExecutionFailed("vhs crashed".to_string());
let status = GifStatus::TapeExecutionFailed(err);
assert!(status.gif_path().is_none());
assert!(status.is_failure());
assert!(!status.is_stale());
}
#[test]
fn gif_status_fresh_exposes_path_and_is_not_stale() {
let status = GifStatus::Fresh {
gif_path: PathBuf::from("/tmp/feature.gif"),
hash: 42,
};
assert_eq!(status.gif_path(), Some(Path::new("/tmp/feature.gif")));
assert!(!status.is_failure());
assert!(!status.is_stale());
}
#[test]
fn gif_status_stale_exposes_path_and_is_stale() {
let status = GifStatus::Stale {
gif_path: PathBuf::from("/tmp/feature.gif"),
current: 42,
committed: Some(7),
};
assert_eq!(status.gif_path(), Some(Path::new("/tmp/feature.gif")));
assert!(!status.is_failure());
assert!(status.is_stale());
}
}