Skip to main content

oxi_agent/tools/
render_utils.rs

1//! Tool output rendering utilities
2//! Provides helpers for formatting tool results for display.
3
4/// Shorten a file path by replacing the home directory prefix with `~`.
5pub fn shorten_path(path: &str) -> String {
6    if let Some(home) = dirs::home_dir() {
7        let home_str = home.to_string_lossy();
8        if let Some(rest) = path.strip_prefix(home_str.as_ref()) {
9            return format!("~{}", rest);
10        }
11    }
12    path.to_string()
13}
14
15/// Replace tabs with spaces for display.
16pub fn replace_tabs(text: &str) -> &str {
17    // Fast path: if no tabs, return the original string
18    if !text.contains('\t') {
19        return text;
20    }
21    // Caller should use the owned version if tabs exist
22    text
23}
24
25/// Replace tabs with spaces, returning an owned String.
26pub fn replace_tabs_owned(text: &str) -> String {
27    text.replace('\t', "   ")
28}
29
30/// Normalize display text by removing carriage returns.
31pub fn normalize_display_text(text: &str) -> String {
32    text.replace('\r', "")
33}
34
35/// Sanitize binary output by replacing non-printable bytes.
36/// Keeps newlines, tabs, and printable ASCII. Replaces everything else.
37pub fn sanitize_binary_output(text: &str) -> String {
38    let mut result = String::with_capacity(text.len());
39    for ch in text.chars() {
40        if ch == '\n' || ch == '\t' || ch.is_ascii_graphic() || ch == ' ' {
41            result.push(ch);
42        } else if ch == '\r' {
43            // Skip carriage returns
44        } else {
45            result.push('�');
46        }
47    }
48    result
49}
50
51/// Extract text output from tool result content, stripping ANSI codes.
52pub fn get_text_output(content: &str) -> String {
53    let sanitized = sanitize_binary_output(content);
54    normalize_display_text(&sanitized)
55}
56
57/// Format a string as a truncated preview for tool output display.
58pub fn truncate_output_preview(text: &str, max_lines: usize, max_bytes: usize) -> String {
59    let lines: Vec<&str> = text.lines().collect();
60
61    if lines.len() <= max_lines && text.len() <= max_bytes {
62        return text.to_string();
63    }
64
65    let selected: Vec<&str> = lines.into_iter().take(max_lines).collect();
66    let mut result = selected.join("\n");
67
68    if result.len() > max_bytes {
69        result.truncate(max_bytes);
70        // Ensure we don't cut in the middle of a multi-byte character
71        while !result.is_char_boundary(result.len()) {
72            result.pop();
73        }
74    }
75
76    result
77}
78
79/// Format an "invalid argument" indicator for tool output.
80pub fn invalid_arg_text() -> &'static str {
81    "[invalid arg]"
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn test_shorten_path_with_home() {
90        let home = dirs::home_dir().unwrap();
91        let home_str = home.to_string_lossy().to_string();
92        let path = format!("{}/foo/bar.txt", home_str);
93        assert_eq!(shorten_path(&path), "~/foo/bar.txt");
94    }
95
96    #[test]
97    fn test_shorten_path_no_home() {
98        assert_eq!(shorten_path("/tmp/foo.txt"), "/tmp/foo.txt");
99    }
100
101    #[test]
102    fn test_replace_tabs_no_tabs() {
103        assert_eq!(replace_tabs("hello world"), "hello world");
104    }
105
106    #[test]
107    fn test_replace_tabs_owned_with_tabs() {
108        assert_eq!(replace_tabs_owned("hello\tworld"), "hello   world");
109    }
110
111    #[test]
112    fn test_normalize_display_text() {
113        assert_eq!(normalize_display_text("hello\r\nworld"), "hello\nworld");
114        assert_eq!(normalize_display_text("no-cr"), "no-cr");
115    }
116
117    #[test]
118    fn test_sanitize_binary_output() {
119        assert_eq!(sanitize_binary_output("hello\nworld"), "hello\nworld");
120        assert_eq!(sanitize_binary_output("tab\there"), "tab\there");
121
122        // Non-printable character gets replaced
123        let with_control = "hello\x01world";
124        let result = sanitize_binary_output(with_control);
125        assert!(result.contains('�'));
126    }
127
128    #[test]
129    fn test_get_text_output() {
130        assert_eq!(get_text_output("hello\r\nworld"), "hello\nworld");
131    }
132
133    #[test]
134    fn test_truncate_output_preview_within_limits() {
135        let text = "line1\nline2\nline3";
136        assert_eq!(truncate_output_preview(text, 10, 1000), text);
137    }
138
139    #[test]
140    fn test_truncate_output_preview_by_lines() {
141        let text = "line1\nline2\nline3\nline4\nline5";
142        let result = truncate_output_preview(text, 2, 1000);
143        assert!(result.contains("line1"));
144        assert!(result.contains("line2"));
145        assert!(!result.contains("line5"));
146    }
147
148    #[test]
149    fn test_invalid_arg_text() {
150        assert_eq!(invalid_arg_text(), "[invalid arg]");
151    }
152}