coding_agent_search/
ftui_harness.rs1use std::fmt::Write as _;
7use std::path::{Path, PathBuf};
8
9use ftui::render::buffer::Buffer;
10use ftui::render::cell::{PackedRgba, StyleFlags};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum MatchMode {
15 Exact,
17 TrimTrailing,
19 Fuzzy,
21}
22
23pub 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
171pub 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
235pub 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
275pub 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}