Skip to main content

shore_protocol/
tool_display.rs

1use serde_json::Value;
2
3/// Format a tool input for human-facing clients.
4///
5/// Empty object inputs are omitted because they add noise without information.
6pub fn format_tool_input(input: &Value) -> Option<String> {
7    format_tool_input_with_limit(input, None)
8}
9
10/// Format a tool input, truncating the rendered text when `max_bytes` is set.
11pub fn format_tool_input_with_limit(input: &Value, max_bytes: Option<usize>) -> Option<String> {
12    if input.as_object().is_some_and(|o| o.is_empty()) {
13        return None;
14    }
15
16    let formatted = format_json_value(input);
17    Some(truncate_with_notice(formatted, max_bytes))
18}
19
20/// Format tool output for human-facing clients.
21///
22/// When the output is a JSON value serialized into a string, it is rendered with
23/// the same compact, label-oriented shape as tool inputs. Plain text output is
24/// preserved.
25pub fn format_tool_output(output: &str) -> String {
26    format_tool_output_with_limit(output, None)
27}
28
29/// Format tool output, truncating the rendered text when `max_bytes` is set.
30pub fn format_tool_output_with_limit(output: &str, max_bytes: Option<usize>) -> String {
31    let trimmed = output.trim_end();
32    let formatted = match serde_json::from_str::<Value>(trimmed) {
33        Ok(value) => format_json_value(&value),
34        Err(_) => trimmed.to_string(),
35    };
36    truncate_with_notice(formatted, max_bytes)
37}
38
39fn format_json_value(value: &Value) -> String {
40    let mut lines = Vec::new();
41    push_value(&mut lines, value, 0);
42    lines.join("\n")
43}
44
45fn push_value(lines: &mut Vec<String>, value: &Value, indent: usize) {
46    match value {
47        Value::Object(map) => {
48            if map.is_empty() {
49                lines.push(format!("{}{{}}", spaces(indent)));
50                return;
51            }
52            for (key, value) in map {
53                push_key_value(lines, key, value, indent);
54            }
55        }
56        Value::Array(values) => push_array(lines, values, indent),
57        _ => push_scalar(lines, value, indent),
58    }
59}
60
61fn push_key_value(lines: &mut Vec<String>, key: &str, value: &Value, indent: usize) {
62    let prefix = spaces(indent);
63    if let Some(inline) = inline_value(value) {
64        lines.push(format!("{prefix}{key}: {inline}"));
65        return;
66    }
67
68    lines.push(format!("{prefix}{key}:"));
69    push_value(lines, value, indent + 2);
70}
71
72fn push_array(lines: &mut Vec<String>, values: &[Value], indent: usize) {
73    let prefix = spaces(indent);
74    if values.is_empty() {
75        lines.push(format!("{prefix}[]"));
76        return;
77    }
78
79    if values.iter().all(|value| inline_value(value).is_some()) {
80        let joined = values
81            .iter()
82            .filter_map(inline_value)
83            .collect::<Vec<_>>()
84            .join(", ");
85        lines.push(format!("{prefix}[{joined}]"));
86        return;
87    }
88
89    for value in values {
90        if let Some(inline) = inline_value(value) {
91            lines.push(format!("{prefix}- {inline}"));
92        } else {
93            lines.push(format!("{prefix}-"));
94            push_value(lines, value, indent + 2);
95        }
96    }
97}
98
99fn push_scalar(lines: &mut Vec<String>, value: &Value, indent: usize) {
100    let prefix = spaces(indent);
101    match value {
102        Value::String(value) => {
103            for line in value.lines() {
104                lines.push(format!("{prefix}{line}"));
105            }
106        }
107        _ => {
108            if let Some(inline) = inline_value(value) {
109                lines.push(format!("{prefix}{inline}"));
110            }
111        }
112    }
113}
114
115fn inline_value(value: &Value) -> Option<String> {
116    match value {
117        Value::Null => Some("null".to_string()),
118        Value::Bool(value) => Some(value.to_string()),
119        Value::Number(value) => Some(value.to_string()),
120        Value::String(value) if !value.contains('\n') => Some(format_string(value)),
121        Value::Array(values) if values.iter().all(|value| inline_value(value).is_some()) => {
122            let joined = values
123                .iter()
124                .filter_map(inline_value)
125                .collect::<Vec<_>>()
126                .join(", ");
127            Some(format!("[{joined}]"))
128        }
129        Value::Object(map) if map.is_empty() => Some("{}".to_string()),
130        _ => None,
131    }
132}
133
134fn format_string(value: &str) -> String {
135    if value.is_empty() {
136        "\"\"".to_string()
137    } else {
138        value.to_string()
139    }
140}
141
142fn truncate_with_notice(mut text: String, max_bytes: Option<usize>) -> String {
143    let Some(max_bytes) = max_bytes else {
144        return text;
145    };
146    let original_len = text.len();
147    if original_len <= max_bytes {
148        return text;
149    }
150
151    let end = floor_char_boundary(&text, max_bytes);
152    text.truncate(end);
153    text.push_str("\n... truncated, ");
154    text.push_str(&original_len.to_string());
155    text.push_str(" bytes total");
156    text
157}
158
159fn floor_char_boundary(s: &str, max: usize) -> usize {
160    if max >= s.len() {
161        return s.len();
162    }
163    let mut i = max;
164    while i > 0 && !s.is_char_boundary(i) {
165        i -= 1;
166    }
167    i
168}
169
170fn spaces(count: usize) -> String {
171    " ".repeat(count)
172}
173
174#[cfg(test)]
175mod tests {
176    use serde_json::json;
177
178    use super::*;
179
180    #[test]
181    fn empty_tool_input_is_omitted() {
182        assert_eq!(format_tool_input(&json!({})), None);
183    }
184
185    #[test]
186    fn object_input_uses_key_value_lines() {
187        let input = json!({
188            "query": "rust tui frameworks",
189            "max_results": 3,
190            "include_answer": true
191        });
192
193        let formatted = format_tool_input(&input).unwrap();
194
195        assert!(formatted.contains("query: rust tui frameworks"));
196        assert!(formatted.contains("max_results: 3"));
197        assert!(formatted.contains("include_answer: true"));
198        assert!(!formatted.contains("\"query\""));
199        assert!(!formatted.contains('{'));
200    }
201
202    #[test]
203    fn nested_values_are_indented() {
204        let input = json!({
205            "request": {
206                "path": "/tmp/report.md",
207                "tags": ["draft", "notes"]
208            }
209        });
210
211        let formatted = format_tool_input(&input).unwrap();
212
213        assert!(formatted.contains("request:\n  path: /tmp/report.md"));
214        assert!(formatted.contains("tags: [draft, notes]"));
215    }
216
217    #[test]
218    fn json_tool_output_is_formatted() {
219        let output = r#"{"ok":true,"items":["alpha","beta"]}"#;
220
221        let formatted = format_tool_output(output);
222
223        assert!(formatted.contains("ok: true"));
224        assert!(formatted.contains("items: [alpha, beta]"));
225    }
226
227    #[test]
228    fn fixture_json_escaped_newlines_expand_to_lines() {
229        let output = r#"{"content":"line one\nline two"}"#;
230
231        let formatted = format_tool_output(output);
232
233        assert_eq!(formatted, "content:\n  line one\n  line two");
234    }
235
236    #[test]
237    fn fixture_plain_literal_backslash_n_stays_literal() {
238        let output = r#"line one\nline two"#;
239
240        let formatted = format_tool_output(output);
241
242        assert_eq!(formatted, r#"line one\nline two"#);
243    }
244
245    #[test]
246    fn plain_tool_output_is_preserved() {
247        let output = "Found 3 results\n";
248
249        assert_eq!(format_tool_output(output), "Found 3 results");
250    }
251
252    #[test]
253    fn formatting_respects_utf8_when_truncated() {
254        let input = json!({"text": "cafe: caf\u{00e9} caf\u{00e9} caf\u{00e9}"});
255
256        let formatted = format_tool_input_with_limit(&input, Some(15)).unwrap();
257
258        assert!(formatted.contains("truncated"));
259        assert!(std::str::from_utf8(formatted.as_bytes()).is_ok());
260    }
261}