1use colored::Colorize;
2
3use crate::observe::structured::StackFrame;
4
5const ARG_VALUE_MAX: usize = 60;
6const ELLIPSIS: char = '\u{2026}';
7
8pub fn format_call_stack(frames: &[StackFrame]) -> Option<String> {
11 if frames.is_empty() {
12 return None;
13 }
14 let mut out = format!(" {}", "Call stack:".bold());
15 for frame in frames.iter().rev() {
16 out.push('\n');
17 out.push_str(&format_frame(frame));
18 }
19 Some(out)
20}
21
22pub fn format_buffer_tail(tail: &str, max_lines: usize) -> Option<String> {
23 let mut lines: Vec<&str> = tail.split('\n').map(strip_trailing_cr).collect();
24 if matches!(lines.last(), Some(last) if last.is_empty()) {
25 lines.pop();
26 }
27 if lines.iter().all(|l| l.trim().is_empty()) {
28 return None;
29 }
30 let truncated = lines.len() > max_lines;
31 let shown = if truncated {
32 &lines[lines.len() - max_lines..]
33 } else {
34 &lines[..]
35 };
36 let header = if truncated {
37 format!("Last output (last {max_lines} lines):")
38 } else {
39 "Last output:".to_string()
40 };
41 let mut out = format!(" {}", header.bold());
42 for line in shown {
43 out.push('\n');
44 out.push_str(&format!(" {line}").dimmed().to_string());
45 }
46 Some(out)
47}
48
49pub fn format_vars_in_scope(vars: &[(String, String)]) -> Option<String> {
50 if vars.is_empty() {
51 return None;
52 }
53 let mut out = format!(" {}", "Vars in scope:".bold());
54 for (k, v) in vars {
55 out.push('\n');
56 let prefix = format!("{k} =").dimmed();
57 out.push_str(&format!(" {prefix} {v:?}"));
58 }
59 Some(out)
60}
61
62fn format_frame(frame: &StackFrame) -> String {
65 let body = format_frame_body(frame);
66 match &frame.location {
67 Some(loc) => format!(" {body}\n {}", loc.to_string().dimmed()),
68 None => format!(" {body}"),
69 }
70}
71
72fn format_frame_body(frame: &StackFrame) -> String {
73 let kind = frame.kind.as_str();
74 match (kind, &frame.name) {
75 ("fn-call" | "pure-fn-call", Some(name)) => {
76 if frame.args.is_empty() {
77 format!("call {name}")
78 } else {
79 format!("call {name}({})", format_args_pairs(&frame.args))
80 }
81 }
82 ("shell-block", Some(name)) => format!("in shell '{name}'"),
83 ("effect-setup", Some(name)) => {
84 let alias_part = match &frame.alias {
85 Some(alias) => format!(" (as '{alias}')"),
86 None => String::new(),
87 };
88 if frame.args.is_empty() {
89 format!("in effect '{name}'{alias_part}")
90 } else {
91 format!(
92 "in effect '{name}'({}){alias_part}",
93 format_args_pairs(&frame.args)
94 )
95 }
96 }
97 ("effect-cleanup", Some(name)) => format!("in effect-cleanup '{name}'"),
98 ("test", Some(name)) => format!("in test '{name}'"),
99 ("test", None) => "in test".to_string(),
100 ("cleanup-block", _) => "in cleanup".to_string(),
101 (kind, Some(name)) => format!("in {kind} '{name}'"),
102 (kind, None) => format!("in {kind}"),
103 }
104}
105
106fn format_args_pairs(args: &[(String, String)]) -> String {
107 args.iter()
108 .map(|(k, v)| format!("{k}={}", quoted_truncated(v)))
109 .collect::<Vec<_>>()
110 .join(", ")
111}
112
113fn quoted_truncated(value: &str) -> String {
114 let first_line = value.split_once('\n');
115 let (head, multi_line) = match first_line {
116 Some((before, _)) => (before, true),
117 None => (value, false),
118 };
119 let mut s = String::with_capacity(head.len() + 4);
120 s.push('"');
121 let char_count = head.chars().count();
122 if char_count > ARG_VALUE_MAX {
123 let head: String = head.chars().take(ARG_VALUE_MAX - 1).collect();
124 s.push_str(&head);
125 s.push(ELLIPSIS);
126 } else {
127 s.push_str(head);
128 if multi_line {
129 s.push(ELLIPSIS);
130 }
131 }
132 s.push('"');
133 s
134}
135
136fn strip_trailing_cr(s: &str) -> &str {
137 s.strip_suffix('\r').unwrap_or(s)
138}
139
140#[cfg(test)]
143mod tests {
144 use super::*;
145 use crate::observe::structured::SourceLocation;
146
147 fn frame(
148 kind: &str,
149 name: Option<&str>,
150 args: &[(&str, &str)],
151 location: Option<(&str, usize)>,
152 ) -> StackFrame {
153 frame_with_alias(kind, name, args, None, location)
154 }
155
156 fn frame_with_alias(
157 kind: &str,
158 name: Option<&str>,
159 args: &[(&str, &str)],
160 alias: Option<&str>,
161 location: Option<(&str, usize)>,
162 ) -> StackFrame {
163 StackFrame {
164 span: 0,
165 kind: kind.to_string(),
166 name: name.map(|s| s.to_string()),
167 args: args
168 .iter()
169 .map(|(k, v)| (k.to_string(), v.to_string()))
170 .collect(),
171 alias: alias.map(|s| s.to_string()),
172 location: location.map(|(file, line)| SourceLocation {
173 file: file.to_string(),
174 line,
175 start: 0,
176 end: 0,
177 }),
178 }
179 }
180
181 fn force_no_color() {
182 colored::control::set_override(false);
183 }
184
185 #[test]
188 fn call_stack_empty_returns_none() {
189 force_no_color();
190 assert!(format_call_stack(&[]).is_none());
191 }
192
193 #[test]
194 fn call_stack_renders_leaf_first() {
195 force_no_color();
196 let root = frame("test", None, &[], Some(("tests/api.relux", 3)));
197 let mid = frame(
198 "shell-block",
199 Some("default"),
200 &[],
201 Some(("tests/api.relux", 5)),
202 );
203 let leaf = frame(
204 "fn-call",
205 Some("check_status"),
206 &[("expected", "200")],
207 Some(("lib/api.relux", 42)),
208 );
209 let out = format_call_stack(&[root, mid, leaf]).unwrap();
210 let expected = " Call stack:\n call check_status(expected=\"200\")\n lib/api.relux:42\n in shell 'default'\n tests/api.relux:5\n in test\n tests/api.relux:3";
211 assert_eq!(out, expected);
212 }
213
214 #[test]
215 fn call_stack_omits_empty_args() {
216 force_no_color();
217 let f = frame("fn-call", Some("noop"), &[], Some(("lib/util.relux", 1)));
218 let out = format_call_stack(&[f]).unwrap();
219 let expected = " Call stack:\n call noop\n lib/util.relux:1";
220 assert_eq!(out, expected);
221 }
222
223 #[test]
224 fn call_stack_omits_location_when_absent() {
225 force_no_color();
226 let f = frame("test", None, &[], None);
227 let out = format_call_stack(&[f]).unwrap();
228 assert_eq!(out, " Call stack:\n in test");
229 }
230
231 #[test]
232 fn call_stack_truncates_long_arg_value() {
233 force_no_color();
234 let long = "x".repeat(200);
235 let f = frame("fn-call", Some("f"), &[("body", long.as_str())], None);
236 let out = format_call_stack(&[f]).unwrap();
237 let expected_head: String = std::iter::repeat_n('x', ARG_VALUE_MAX - 1).collect();
238 assert!(
239 out.contains(&format!("body=\"{expected_head}{ELLIPSIS}\"")),
240 "got: {out}"
241 );
242 }
243
244 #[test]
245 fn call_stack_renders_effect_setup_with_alias_and_overlay() {
246 force_no_color();
247 let f = frame_with_alias(
248 "effect-setup",
249 Some("FailingService"),
250 &[("URL", "http://x")],
251 Some("Svc"),
252 Some(("relux/tests/effect_smoke.relux", 4)),
253 );
254 let out = format_call_stack(&[f]).unwrap();
255 let expected = " Call stack:\n in effect 'FailingService'(URL=\"http://x\") (as 'Svc')\n relux/tests/effect_smoke.relux:4";
256 assert_eq!(out, expected);
257 }
258
259 #[test]
260 fn call_stack_renders_effect_setup_without_alias() {
261 force_no_color();
262 let f = frame_with_alias(
263 "effect-setup",
264 Some("Db"),
265 &[],
266 None,
267 Some(("relux/tests/t.relux", 1)),
268 );
269 let out = format_call_stack(&[f]).unwrap();
270 let expected = " Call stack:\n in effect 'Db'\n relux/tests/t.relux:1";
271 assert_eq!(out, expected);
272 }
273
274 #[test]
275 fn call_stack_collapses_multi_line_arg_value() {
276 force_no_color();
277 let f = frame(
278 "fn-call",
279 Some("f"),
280 &[("body", "first\nsecond\nthird")],
281 None,
282 );
283 let out = format_call_stack(&[f]).unwrap();
284 assert!(
285 out.contains(&format!("body=\"first{ELLIPSIS}\"")),
286 "got: {out}"
287 );
288 }
289
290 #[test]
293 fn buffer_tail_empty_returns_none() {
294 force_no_color();
295 assert!(format_buffer_tail("", 12).is_none());
296 assert!(format_buffer_tail(" \n \n", 12).is_none());
297 }
298
299 #[test]
300 fn buffer_tail_strips_crlf_endings() {
301 force_no_color();
302 let tail = "$ echo hi\r\nhi\r\nrelux> \r\n";
303 let out = format_buffer_tail(tail, 12).unwrap();
304 let expected = " Last output:\n $ echo hi\n hi\n relux> ";
305 assert_eq!(out, expected);
306 }
307
308 #[test]
309 fn buffer_tail_trailing_newline_no_phantom_blank_line() {
310 force_no_color();
311 let tail = "one\ntwo\n";
312 let out = format_buffer_tail(tail, 12).unwrap();
313 assert_eq!(out, " Last output:\n one\n two");
314 }
315
316 #[test]
317 fn buffer_tail_truncates_above_max_lines() {
318 force_no_color();
319 let tail = (1..=15)
320 .map(|n| format!("line {n}"))
321 .collect::<Vec<_>>()
322 .join("\n");
323 let out = format_buffer_tail(&tail, 5).unwrap();
324 assert!(out.starts_with(" Last output (last 5 lines):"));
325 assert!(out.contains(" line 15"));
326 assert!(out.contains(" line 11"));
327 assert!(!out.contains(" line 10"));
328 }
329
330 #[test]
331 fn buffer_tail_below_threshold_uses_plain_header() {
332 force_no_color();
333 let out = format_buffer_tail("a\nb", 12).unwrap();
334 assert!(out.starts_with(" Last output:\n"));
335 assert!(!out.contains("(last"));
336 }
337
338 #[test]
341 fn vars_in_scope_empty_returns_none() {
342 force_no_color();
343 assert!(format_vars_in_scope(&[]).is_none());
344 }
345
346 #[test]
347 fn vars_in_scope_uses_debug_formatting_for_values() {
348 force_no_color();
349 let vars = vec![
350 ("expected".to_string(), "200".to_string()),
351 ("note".to_string(), "line one\nline two".to_string()),
352 ];
353 let out = format_vars_in_scope(&vars).unwrap();
354 let expected =
355 " Vars in scope:\n expected = \"200\"\n note = \"line one\\nline two\"";
356 assert_eq!(out, expected);
357 }
358
359 #[test]
360 fn vars_in_scope_preserves_input_order() {
361 force_no_color();
362 let vars = vec![
363 ("z".to_string(), "1".to_string()),
364 ("a".to_string(), "2".to_string()),
365 ];
366 let out = format_vars_in_scope(&vars).unwrap();
367 let z_pos = out.find("z =").unwrap();
368 let a_pos = out.find("a =").unwrap();
369 assert!(z_pos < a_pos);
370 }
371}