Skip to main content

relux_runtime/report/
console.rs

1use colored::Colorize;
2
3use crate::observe::structured::StackFrame;
4
5const ARG_VALUE_MAX: usize = 60;
6const ELLIPSIS: char = '\u{2026}';
7
8// --- Public API ------------------------------------------
9
10pub 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
62// --- Helpers ---------------------------------------------
63
64fn 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// --- Tests -----------------------------------------------
141
142#[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    // --- Call stack --------------------------------------
186
187    #[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    // --- Buffer tail -------------------------------------
291
292    #[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    // --- Vars in scope -----------------------------------
339
340    #[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}