use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use ftui::render::buffer::Buffer;
use ftui::render::cell::{PackedRgba, StyleFlags};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchMode {
Exact,
TrimTrailing,
Fuzzy,
}
pub fn buffer_to_text(buf: &Buffer) -> String {
let capacity = (buf.width() as usize + 1) * buf.height() as usize;
let mut out = String::with_capacity(capacity);
for y in 0..buf.height() {
if y > 0 {
out.push('\n');
}
for x in 0..buf.width() {
let cell = buf.get(x, y).expect("buffer coordinate should be valid");
if cell.is_continuation() {
continue;
}
if cell.is_empty() {
out.push(' ');
} else if let Some(c) = cell.content.as_char() {
out.push(c);
} else {
let w = cell.content.width();
for _ in 0..w.max(1) {
out.push('?');
}
}
}
}
out
}
fn buffer_to_ansi(buf: &Buffer) -> String {
let capacity = (buf.width() as usize + 32) * buf.height() as usize;
let mut out = String::with_capacity(capacity);
for y in 0..buf.height() {
if y > 0 {
out.push('\n');
}
let mut prev_fg = PackedRgba::WHITE;
let mut prev_bg = PackedRgba::TRANSPARENT;
let mut prev_flags = StyleFlags::empty();
let mut style_active = false;
for x in 0..buf.width() {
let cell = buf.get(x, y).expect("buffer coordinate should be valid");
if cell.is_continuation() {
continue;
}
let fg = cell.fg;
let bg = cell.bg;
let flags = cell.attrs.flags();
let style_changed = fg != prev_fg || bg != prev_bg || flags != prev_flags;
if style_changed {
let has_style =
fg != PackedRgba::WHITE || bg != PackedRgba::TRANSPARENT || !flags.is_empty();
if has_style {
if style_active {
out.push_str("\x1b[0m");
}
let mut params: Vec<String> = Vec::new();
if !flags.is_empty() {
if flags.contains(StyleFlags::BOLD) {
params.push("1".into());
}
if flags.contains(StyleFlags::DIM) {
params.push("2".into());
}
if flags.contains(StyleFlags::ITALIC) {
params.push("3".into());
}
if flags.contains(StyleFlags::UNDERLINE) {
params.push("4".into());
}
if flags.contains(StyleFlags::BLINK) {
params.push("5".into());
}
if flags.contains(StyleFlags::REVERSE) {
params.push("7".into());
}
if flags.contains(StyleFlags::HIDDEN) {
params.push("8".into());
}
if flags.contains(StyleFlags::STRIKETHROUGH) {
params.push("9".into());
}
}
if fg.a() > 0 && fg != PackedRgba::WHITE {
params.push(format!("38;2;{};{};{}", fg.r(), fg.g(), fg.b()));
}
if bg.a() > 0 && bg != PackedRgba::TRANSPARENT {
params.push(format!("48;2;{};{};{}", bg.r(), bg.g(), bg.b()));
}
if !params.is_empty() {
write!(out, "\x1b[{}m", params.join(";")).expect("write to String");
style_active = true;
}
} else if style_active {
out.push_str("\x1b[0m");
style_active = false;
}
prev_fg = fg;
prev_bg = bg;
prev_flags = flags;
}
if cell.is_empty() {
out.push(' ');
} else if let Some(c) = cell.content.as_char() {
out.push(c);
} else {
let w = cell.content.width();
for _ in 0..w.max(1) {
out.push('?');
}
}
}
if style_active {
out.push_str("\x1b[0m");
}
}
out
}
fn normalize(text: &str, mode: MatchMode) -> String {
match mode {
MatchMode::Exact => text.to_string(),
MatchMode::TrimTrailing => {
let mut lines = text.lines().map(str::trim_end).collect::<Vec<_>>();
while lines.last().is_some_and(|line| line.is_empty()) {
lines.pop();
}
lines.join("\n")
}
MatchMode::Fuzzy => text
.lines()
.map(|line| line.split_whitespace().collect::<Vec<_>>().join(" "))
.collect::<Vec<_>>()
.join("\n"),
}
}
pub fn diff_text(expected: &str, actual: &str) -> String {
let expected_lines: Vec<&str> = expected.lines().collect();
let actual_lines: Vec<&str> = actual.lines().collect();
let max_lines = expected_lines.len().max(actual_lines.len());
let mut out = String::new();
let mut has_diff = false;
for i in 0..max_lines {
let exp = expected_lines.get(i).copied();
let act = actual_lines.get(i).copied();
match (exp, act) {
(Some(e), Some(a)) if e == a => {
writeln!(out, " {e}").expect("write to String");
}
(Some(e), Some(a)) => {
writeln!(out, "-{e}").expect("write to String");
writeln!(out, "+{a}").expect("write to String");
has_diff = true;
}
(Some(e), None) => {
writeln!(out, "-{e}").expect("write to String");
has_diff = true;
}
(None, Some(a)) => {
writeln!(out, "+{a}").expect("write to String");
has_diff = true;
}
(None, None) => {}
}
}
if has_diff { out } else { String::new() }
}
fn snapshot_name_with_profile(name: &str) -> String {
let profile = std::env::var("FTUI_TEST_PROFILE").ok();
if let Some(profile) = profile {
let profile = profile.trim();
if !profile.is_empty() && !profile.eq_ignore_ascii_case("detected") {
let suffix = format!("__{profile}");
if name.ends_with(&suffix) {
return name.to_string();
}
return format!("{name}{suffix}");
}
}
name.to_string()
}
fn snapshot_path(base_dir: &Path, name: &str) -> PathBuf {
let resolved_name = snapshot_name_with_profile(name);
base_dir
.join("tests")
.join("snapshots")
.join(format!("{resolved_name}.snap"))
}
fn is_bless() -> bool {
std::env::var("BLESS")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
pub fn assert_buffer_snapshot(name: &str, buf: &Buffer, base_dir: &str, mode: MatchMode) {
let base = Path::new(base_dir);
let path = snapshot_path(base, name);
let actual = buffer_to_text(buf);
if is_bless() {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("failed to create snapshot directory");
}
std::fs::write(&path, normalize(&actual, mode)).expect("failed to write snapshot");
return;
}
match std::fs::read_to_string(&path) {
Ok(expected) => {
let norm_expected = normalize(&expected, mode);
let norm_actual = normalize(&actual, mode);
if norm_expected != norm_actual {
let diff = diff_text(&norm_expected, &norm_actual);
panic!(
"\n=== Snapshot mismatch: '{name}' ===\nFile: {}\nMode: {mode:?}\nSet BLESS=1 to update.\n\nDiff (- expected, + actual):\n{diff}",
path.display()
);
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
panic!(
"\n=== No snapshot found: '{name}' ===\nExpected at: {}\nRun with BLESS=1 to create it.\n\nActual output ({w}x{h}):\n{actual}",
path.display(),
w = buf.width(),
h = buf.height(),
);
}
Err(e) => {
panic!("Failed to read snapshot '{}': {e}", path.display());
}
}
}
pub fn assert_buffer_snapshot_ansi(name: &str, buf: &Buffer, base_dir: &str) {
let base = Path::new(base_dir);
let resolved_name = snapshot_name_with_profile(name);
let path = base
.join("tests")
.join("snapshots")
.join(format!("{resolved_name}.ansi.snap"));
let actual = buffer_to_ansi(buf);
if is_bless() {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("failed to create snapshot directory");
}
std::fs::write(&path, &actual).expect("failed to write snapshot");
return;
}
match std::fs::read_to_string(&path) {
Ok(expected) => {
if expected != actual {
let diff = diff_text(&expected, &actual);
panic!(
"\n=== ANSI snapshot mismatch: '{name}' ===\nFile: {}\nSet BLESS=1 to update.\n\nDiff (- expected, + actual):\n{diff}",
path.display()
);
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
panic!(
"\n=== No ANSI snapshot found: '{resolved_name}' ===\nExpected at: {}\nRun with BLESS=1 to create it.\n\nActual output:\n{actual}",
path.display(),
);
}
Err(e) => {
panic!("Failed to read snapshot '{}': {e}", path.display());
}
}
}