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)]
pub enum GifStatus {
Generated(PathBuf),
CacheHit(PathBuf),
VhsNotInstalled,
NoOutputDir,
DirCreateFailed(std::io::Error),
TapeExecutionFailed(VhsError),
}
impl GifStatus {
pub fn gif_path(&self) -> Option<&Path> {
match self {
Self::Generated(path) | Self::CacheHit(path) => Some(path),
_ => None,
}
}
pub fn is_failure(&self) -> bool {
matches!(
self,
Self::DirCreateFailed(_) | Self::TapeExecutionFailed(_)
)
}
}
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,
}
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(),
}
}
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 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_settings,
binary_path,
env_pairs,
),
None => GifStatus::NoOutputDir,
};
Ok(FeatureResult {
frame,
report,
meta: self.meta,
gif_status,
})
}
}
fn generate_gif(
scenario: &Scenario,
report: &ProofReport,
name: &str,
output_dir: &Path,
settings: &VhsTapeSettings,
binary_path: &Path,
env_pairs: &[(&str, &str)],
) -> GifStatus {
if check_vhs_installed().is_err() {
return GifStatus::VhsNotInstalled;
}
if let Err(err) = std::fs::create_dir_all(output_dir) {
return GifStatus::DirCreateFailed(err);
}
let hash_path = output_dir.join(format!(".{name}.hash"));
let gif_path = output_dir.join(format!("{name}.gif"));
let current_hash = compute_frame_hash(report);
let hash_string = current_hash.to_string();
if gif_path.exists()
&& let Ok(cached) = std::fs::read_to_string(&hash_path)
&& cached.trim() == hash_string
{
return GifStatus::CacheHit(gif_path);
}
let screenshot_path = output_dir.join(format!("{name}.png"));
let tape = VhsTape::from_scenario_with_settings(
scenario,
binary_path,
&screenshot_path,
env_pairs,
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 compute_frame_hash(report: &ProofReport) -> u64 {
let mut hasher = DefaultHasher::new();
for capture in &report.captures {
capture.frame_bytes.hash(&mut hasher);
}
hasher.finish()
}
#[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 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 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());
}
#[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());
}
#[test]
fn gif_status_vhs_not_installed_is_not_failure() {
let status = GifStatus::VhsNotInstalled;
assert!(status.gif_path().is_none());
assert!(!status.is_failure());
}
#[test]
fn gif_status_no_output_dir_is_not_failure() {
let status = GifStatus::NoOutputDir;
assert!(status.gif_path().is_none());
assert!(!status.is_failure());
}
#[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());
}
#[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());
}
}