Skip to main content

coding_agent_search/
ftui_harness.rs

1//! Lightweight in-repo FTUI test harness.
2//!
3//! This shim replaces the external `ftui-harness` dev-dependency so the cass
4//! repo no longer pulls legacy crossterm compatibility into its test graph.
5
6use std::fmt::Write as _;
7use std::path::{Path, PathBuf};
8
9use ftui::render::buffer::Buffer;
10use ftui::render::cell::{PackedRgba, StyleFlags};
11
12/// Comparison mode for snapshot testing.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum MatchMode {
15    /// Byte-exact string comparison.
16    Exact,
17    /// Trim trailing whitespace on each line before comparing.
18    TrimTrailing,
19    /// Collapse all whitespace runs to single spaces and trim each line.
20    Fuzzy,
21}
22
23/// Convert a render buffer to plain text.
24pub fn buffer_to_text(buf: &Buffer) -> String {
25    let capacity = (buf.width() as usize + 1) * buf.height() as usize;
26    let mut out = String::with_capacity(capacity);
27
28    for y in 0..buf.height() {
29        if y > 0 {
30            out.push('\n');
31        }
32        for x in 0..buf.width() {
33            let cell = buf.get(x, y).expect("buffer coordinate should be valid");
34            if cell.is_continuation() {
35                continue;
36            }
37            if cell.is_empty() {
38                out.push(' ');
39            } else if let Some(c) = cell.content.as_char() {
40                out.push(c);
41            } else {
42                let w = cell.content.width();
43                for _ in 0..w.max(1) {
44                    out.push('?');
45                }
46            }
47        }
48    }
49    out
50}
51
52fn buffer_to_ansi(buf: &Buffer) -> String {
53    let capacity = (buf.width() as usize + 32) * buf.height() as usize;
54    let mut out = String::with_capacity(capacity);
55
56    for y in 0..buf.height() {
57        if y > 0 {
58            out.push('\n');
59        }
60
61        let mut prev_fg = PackedRgba::WHITE;
62        let mut prev_bg = PackedRgba::TRANSPARENT;
63        let mut prev_flags = StyleFlags::empty();
64        let mut style_active = false;
65
66        for x in 0..buf.width() {
67            let cell = buf.get(x, y).expect("buffer coordinate should be valid");
68            if cell.is_continuation() {
69                continue;
70            }
71
72            let fg = cell.fg;
73            let bg = cell.bg;
74            let flags = cell.attrs.flags();
75            let style_changed = fg != prev_fg || bg != prev_bg || flags != prev_flags;
76
77            if style_changed {
78                let has_style =
79                    fg != PackedRgba::WHITE || bg != PackedRgba::TRANSPARENT || !flags.is_empty();
80
81                if has_style {
82                    if style_active {
83                        out.push_str("\x1b[0m");
84                    }
85
86                    let mut params: Vec<String> = Vec::new();
87                    if !flags.is_empty() {
88                        if flags.contains(StyleFlags::BOLD) {
89                            params.push("1".into());
90                        }
91                        if flags.contains(StyleFlags::DIM) {
92                            params.push("2".into());
93                        }
94                        if flags.contains(StyleFlags::ITALIC) {
95                            params.push("3".into());
96                        }
97                        if flags.contains(StyleFlags::UNDERLINE) {
98                            params.push("4".into());
99                        }
100                        if flags.contains(StyleFlags::BLINK) {
101                            params.push("5".into());
102                        }
103                        if flags.contains(StyleFlags::REVERSE) {
104                            params.push("7".into());
105                        }
106                        if flags.contains(StyleFlags::HIDDEN) {
107                            params.push("8".into());
108                        }
109                        if flags.contains(StyleFlags::STRIKETHROUGH) {
110                            params.push("9".into());
111                        }
112                    }
113                    if fg.a() > 0 && fg != PackedRgba::WHITE {
114                        params.push(format!("38;2;{};{};{}", fg.r(), fg.g(), fg.b()));
115                    }
116                    if bg.a() > 0 && bg != PackedRgba::TRANSPARENT {
117                        params.push(format!("48;2;{};{};{}", bg.r(), bg.g(), bg.b()));
118                    }
119
120                    if !params.is_empty() {
121                        write!(out, "\x1b[{}m", params.join(";")).expect("write to String");
122                        style_active = true;
123                    }
124                } else if style_active {
125                    out.push_str("\x1b[0m");
126                    style_active = false;
127                }
128
129                prev_fg = fg;
130                prev_bg = bg;
131                prev_flags = flags;
132            }
133
134            if cell.is_empty() {
135                out.push(' ');
136            } else if let Some(c) = cell.content.as_char() {
137                out.push(c);
138            } else {
139                let w = cell.content.width();
140                for _ in 0..w.max(1) {
141                    out.push('?');
142                }
143            }
144        }
145
146        if style_active {
147            out.push_str("\x1b[0m");
148        }
149    }
150    out
151}
152
153fn normalize(text: &str, mode: MatchMode) -> String {
154    match mode {
155        MatchMode::Exact => text.to_string(),
156        MatchMode::TrimTrailing => {
157            let mut lines = text.lines().map(str::trim_end).collect::<Vec<_>>();
158            while lines.last().is_some_and(|line| line.is_empty()) {
159                lines.pop();
160            }
161            lines.join("\n")
162        }
163        MatchMode::Fuzzy => text
164            .lines()
165            .map(|line| line.split_whitespace().collect::<Vec<_>>().join(" "))
166            .collect::<Vec<_>>()
167            .join("\n"),
168    }
169}
170
171/// Compute a simple line-by-line diff between two text strings.
172pub fn diff_text(expected: &str, actual: &str) -> String {
173    let expected_lines: Vec<&str> = expected.lines().collect();
174    let actual_lines: Vec<&str> = actual.lines().collect();
175    let max_lines = expected_lines.len().max(actual_lines.len());
176    let mut out = String::new();
177    let mut has_diff = false;
178
179    for i in 0..max_lines {
180        let exp = expected_lines.get(i).copied();
181        let act = actual_lines.get(i).copied();
182        match (exp, act) {
183            (Some(e), Some(a)) if e == a => {
184                writeln!(out, " {e}").expect("write to String");
185            }
186            (Some(e), Some(a)) => {
187                writeln!(out, "-{e}").expect("write to String");
188                writeln!(out, "+{a}").expect("write to String");
189                has_diff = true;
190            }
191            (Some(e), None) => {
192                writeln!(out, "-{e}").expect("write to String");
193                has_diff = true;
194            }
195            (None, Some(a)) => {
196                writeln!(out, "+{a}").expect("write to String");
197                has_diff = true;
198            }
199            (None, None) => {}
200        }
201    }
202
203    if has_diff { out } else { String::new() }
204}
205
206fn snapshot_name_with_profile(name: &str) -> String {
207    let profile = std::env::var("FTUI_TEST_PROFILE").ok();
208    if let Some(profile) = profile {
209        let profile = profile.trim();
210        if !profile.is_empty() && !profile.eq_ignore_ascii_case("detected") {
211            let suffix = format!("__{profile}");
212            if name.ends_with(&suffix) {
213                return name.to_string();
214            }
215            return format!("{name}{suffix}");
216        }
217    }
218    name.to_string()
219}
220
221fn snapshot_path(base_dir: &Path, name: &str) -> PathBuf {
222    let resolved_name = snapshot_name_with_profile(name);
223    base_dir
224        .join("tests")
225        .join("snapshots")
226        .join(format!("{resolved_name}.snap"))
227}
228
229fn is_bless() -> bool {
230    std::env::var("BLESS")
231        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
232        .unwrap_or(false)
233}
234
235/// Assert that a buffer's text representation matches a stored snapshot.
236pub fn assert_buffer_snapshot(name: &str, buf: &Buffer, base_dir: &str, mode: MatchMode) {
237    let base = Path::new(base_dir);
238    let path = snapshot_path(base, name);
239    let actual = buffer_to_text(buf);
240
241    if is_bless() {
242        if let Some(parent) = path.parent() {
243            std::fs::create_dir_all(parent).expect("failed to create snapshot directory");
244        }
245        std::fs::write(&path, normalize(&actual, mode)).expect("failed to write snapshot");
246        return;
247    }
248
249    match std::fs::read_to_string(&path) {
250        Ok(expected) => {
251            let norm_expected = normalize(&expected, mode);
252            let norm_actual = normalize(&actual, mode);
253            if norm_expected != norm_actual {
254                let diff = diff_text(&norm_expected, &norm_actual);
255                panic!(
256                    "\n=== Snapshot mismatch: '{name}' ===\nFile: {}\nMode: {mode:?}\nSet BLESS=1 to update.\n\nDiff (- expected, + actual):\n{diff}",
257                    path.display()
258                );
259            }
260        }
261        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
262            panic!(
263                "\n=== No snapshot found: '{name}' ===\nExpected at: {}\nRun with BLESS=1 to create it.\n\nActual output ({w}x{h}):\n{actual}",
264                path.display(),
265                w = buf.width(),
266                h = buf.height(),
267            );
268        }
269        Err(e) => {
270            panic!("Failed to read snapshot '{}': {e}", path.display());
271        }
272    }
273}
274
275/// Assert that a buffer's ANSI-styled representation matches a stored snapshot.
276pub fn assert_buffer_snapshot_ansi(name: &str, buf: &Buffer, base_dir: &str) {
277    let base = Path::new(base_dir);
278    let resolved_name = snapshot_name_with_profile(name);
279    let path = base
280        .join("tests")
281        .join("snapshots")
282        .join(format!("{resolved_name}.ansi.snap"));
283    let actual = buffer_to_ansi(buf);
284
285    if is_bless() {
286        if let Some(parent) = path.parent() {
287            std::fs::create_dir_all(parent).expect("failed to create snapshot directory");
288        }
289        std::fs::write(&path, &actual).expect("failed to write snapshot");
290        return;
291    }
292
293    match std::fs::read_to_string(&path) {
294        Ok(expected) => {
295            if expected != actual {
296                let diff = diff_text(&expected, &actual);
297                panic!(
298                    "\n=== ANSI snapshot mismatch: '{name}' ===\nFile: {}\nSet BLESS=1 to update.\n\nDiff (- expected, + actual):\n{diff}",
299                    path.display()
300                );
301            }
302        }
303        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
304            panic!(
305                "\n=== No ANSI snapshot found: '{resolved_name}' ===\nExpected at: {}\nRun with BLESS=1 to create it.\n\nActual output:\n{actual}",
306                path.display(),
307            );
308        }
309        Err(e) => {
310            panic!("Failed to read snapshot '{}': {e}", path.display());
311        }
312    }
313}