shore_protocol/
tool_display.rs1use serde_json::Value;
2
3pub fn format_tool_input(input: &Value) -> Option<String> {
7 format_tool_input_with_limit(input, None)
8}
9
10pub 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
20pub fn format_tool_output(output: &str) -> String {
26 format_tool_output_with_limit(output, None)
27}
28
29pub 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}