use std::path::Path;
use crate::error;
use crate::annotation::AnnotationRegistry;
use crate::backend::FrameSnapshot;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SnapshotFormat {
#[default]
Plain,
Ansi,
#[cfg(feature = "serialization")]
Json,
#[cfg(feature = "serialization")]
JsonPretty,
}
#[derive(Clone, Debug)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct Snapshot {
pub frame: FrameSnapshot,
pub annotations: AnnotationRegistry,
}
impl Snapshot {
pub fn new(frame: FrameSnapshot, annotations: AnnotationRegistry) -> Self {
Self { frame, annotations }
}
pub fn to_plain(&self) -> String {
self.frame.to_plain()
}
pub fn to_ansi(&self) -> String {
self.frame.to_ansi()
}
#[cfg(feature = "serialization")]
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
#[cfg(feature = "serialization")]
pub fn to_json_pretty(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
pub fn format(&self, format: SnapshotFormat) -> String {
match format {
SnapshotFormat::Plain => self.to_plain(),
SnapshotFormat::Ansi => self.to_ansi(),
#[cfg(feature = "serialization")]
SnapshotFormat::Json => self.to_json().unwrap_or_default(),
#[cfg(feature = "serialization")]
SnapshotFormat::JsonPretty => self.to_json_pretty().unwrap_or_default(),
}
}
pub fn write_to_file(
&self,
path: impl AsRef<Path>,
format: SnapshotFormat,
) -> error::Result<()> {
let content = self.format(format);
Ok(std::fs::write(path, content)?)
}
#[cfg(feature = "serialization")]
pub fn load_from_file(path: impl AsRef<Path>) -> error::Result<Self> {
let content = std::fs::read_to_string(path)?;
serde_json::from_str(&content)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e).into())
}
pub fn diff(&self, other: &Snapshot) -> SnapshotDiff {
SnapshotDiff::compute(self, other)
}
pub fn matches(&self, other: &Snapshot) -> bool {
self.to_plain() == other.to_plain()
}
pub fn annotation_tree(&self) -> String {
self.annotations.format_tree()
}
pub fn annotation_count(&self) -> usize {
self.annotations.len()
}
}
#[derive(Debug, Clone)]
pub struct SnapshotDiff {
pub changed_lines: Vec<LineDiff>,
pub annotations_differ: bool,
pub changes: usize,
}
#[derive(Debug, Clone)]
pub struct LineDiff {
pub line: usize,
pub left: String,
pub right: String,
}
impl SnapshotDiff {
pub fn compute(left: &Snapshot, right: &Snapshot) -> Self {
let left_plain = left.to_plain();
let right_plain = right.to_plain();
let left_lines: Vec<&str> = left_plain.lines().collect();
let right_lines: Vec<&str> = right_plain.lines().collect();
let max_lines = left_lines.len().max(right_lines.len());
let mut changed_lines = Vec::new();
for i in 0..max_lines {
let l = left_lines.get(i).copied().unwrap_or("");
let r = right_lines.get(i).copied().unwrap_or("");
if l != r {
changed_lines.push(LineDiff {
line: i,
left: l.to_string(),
right: r.to_string(),
});
}
}
let annotations_differ = left.annotations.format_tree() != right.annotations.format_tree();
Self {
changes: changed_lines.len(),
changed_lines,
annotations_differ,
}
}
pub fn is_empty(&self) -> bool {
self.changes == 0 && !self.annotations_differ
}
pub fn format(&self) -> String {
let mut output = String::new();
if self.changed_lines.is_empty() && !self.annotations_differ {
output.push_str("No differences\n");
return output;
}
if !self.changed_lines.is_empty() {
output.push_str(&format!("Changed lines ({}):\n", self.changes));
for diff in &self.changed_lines {
output.push_str(&format!(" Line {}:\n", diff.line + 1));
output.push_str(&format!(" - {}\n", diff.left));
output.push_str(&format!(" + {}\n", diff.right));
}
}
if self.annotations_differ {
output.push_str("Annotations differ\n");
}
output
}
}
pub fn assert_snapshot_eq(left: &Snapshot, right: &Snapshot) {
let diff = left.diff(right);
if !diff.is_empty() {
panic!("Snapshots differ:\n{}", diff.format());
}
}
pub fn assert_snapshot_text(snapshot: &Snapshot, expected: &str) {
let actual = snapshot.to_plain();
if actual != expected {
panic!(
"Snapshot text differs:\n\nExpected:\n{}\n\nActual:\n{}",
expected, actual
);
}
}
#[derive(Debug)]
pub struct SnapshotTest {
pub snapshot_dir: std::path::PathBuf,
pub format: SnapshotFormat,
pub update: bool,
}
impl SnapshotTest {
pub fn new(snapshot_dir: impl AsRef<Path>) -> Self {
Self {
snapshot_dir: snapshot_dir.as_ref().to_path_buf(),
format: SnapshotFormat::Plain,
update: false,
}
}
pub fn with_format(mut self, format: SnapshotFormat) -> Self {
self.format = format;
self
}
pub fn with_update(mut self, update: bool) -> Self {
self.update = update;
self
}
pub fn snapshot_path(&self, name: &str) -> std::path::PathBuf {
let ext = match self.format {
SnapshotFormat::Plain => "txt",
SnapshotFormat::Ansi => "ansi",
#[cfg(feature = "serialization")]
SnapshotFormat::Json | SnapshotFormat::JsonPretty => "json",
};
self.snapshot_dir.join(format!("{}.{}", name, ext))
}
pub fn assert(&self, name: &str, snapshot: &Snapshot) -> error::Result<()> {
let path = self.snapshot_path(name);
if self.update || !path.exists() {
std::fs::create_dir_all(&self.snapshot_dir)?;
snapshot.write_to_file(&path, self.format)?;
return Ok(());
}
let expected = std::fs::read_to_string(&path)?;
let actual = snapshot.format(self.format);
if actual != expected {
let new_path = path.with_extension(format!(
"{}.new",
path.extension().unwrap_or_default().to_string_lossy()
));
std::fs::write(&new_path, &actual)?;
return Err(std::io::Error::other(format!(
"Snapshot '{}' differs. New snapshot written to {:?}",
name, new_path
))
.into());
}
Ok(())
}
}
#[cfg(test)]
mod tests;