agentic_tools_core/fmt.rs
1//! Transport-agnostic text formatting for tool outputs.
2//!
3//! This module provides the [`TextFormat`] trait for converting tool outputs to
4//! human-readable text, along with supporting types and helpers.
5//!
6//! # Usage
7//!
8//! Tool outputs must implement [`TextFormat`]. The trait provides a default
9//! implementation that returns pretty-printed JSON via the `Serialize` supertrait.
10//! Types can override `fmt_text` for custom human-friendly formatting:
11//!
12//! ```ignore
13//! use agentic_tools_core::fmt::{TextFormat, TextOptions};
14//! use serde::Serialize;
15//!
16//! #[derive(Serialize)]
17//! struct MyOutput {
18//! count: usize,
19//! items: Vec<String>,
20//! }
21//!
22//! // Use default (pretty JSON):
23//! impl TextFormat for MyOutput {}
24//!
25//! // Or provide custom formatting:
26//! impl TextFormat for MyOutput {
27//! fn fmt_text(&self, opts: &TextOptions) -> String {
28//! format!("Found {} items:\n{}", self.count, self.items.join("\n"))
29//! }
30//! }
31//! ```
32//!
33//! # Default TextFormat Fallback
34//!
35//! The [`TextFormat`] trait requires `Serialize` and provides a default `fmt_text`
36//! implementation that produces pretty-printed JSON. This means:
37//!
38//! - Types with custom formatting override `fmt_text()`
39//! - Types wanting JSON fallback use an empty impl: `impl TextFormat for T {}`
40//! - The registry always calls `fmt_text()` on the native output—no detection needed
41
42use serde_json::Value as JsonValue;
43use std::any::Any;
44
45/// Text rendering style.
46#[derive(Clone, Debug, Default, PartialEq, Eq)]
47pub enum TextStyle {
48 /// Human-friendly formatting with Unicode symbols and formatting.
49 #[default]
50 Humanized,
51 /// Plain text without special formatting.
52 Plain,
53}
54
55/// Options controlling text formatting behavior.
56#[derive(Clone, Debug, Default)]
57pub struct TextOptions {
58 /// The rendering style to use.
59 pub style: TextStyle,
60 /// Whether to wrap output in markdown formatting.
61 pub markdown: bool,
62 /// Maximum number of items to display in collections.
63 pub max_items: Option<usize>,
64 /// Whether search tools should omit the search reminder footer.
65 pub suppress_search_reminder: bool,
66}
67
68impl TextOptions {
69 /// Create new text options with default settings.
70 pub fn new() -> Self {
71 Self::default()
72 }
73
74 /// Set the text style.
75 pub fn with_style(mut self, style: TextStyle) -> Self {
76 self.style = style;
77 self
78 }
79
80 /// Enable or disable markdown formatting.
81 pub fn with_markdown(mut self, markdown: bool) -> Self {
82 self.markdown = markdown;
83 self
84 }
85
86 /// Set the maximum number of items to display.
87 pub fn with_max_items(mut self, max_items: Option<usize>) -> Self {
88 self.max_items = max_items;
89 self
90 }
91
92 /// Enable or disable search reminder suppression for grep/glob text output.
93 pub fn with_suppress_search_reminder(mut self, suppress_search_reminder: bool) -> Self {
94 self.suppress_search_reminder = suppress_search_reminder;
95 self
96 }
97}
98
99/// Transport-agnostic text formatting for tool outputs.
100///
101/// Implement this trait to provide custom human-readable formatting for your
102/// tool output types. The formatting is used by both MCP and NAPI servers
103/// to produce text alongside JSON data.
104///
105/// The default implementation returns pretty-printed JSON. Types can override
106/// `fmt_text` to provide custom human-friendly formatting.
107pub trait TextFormat: serde::Serialize {
108 /// Format the value as human-readable text.
109 ///
110 /// Default: pretty-printed JSON. Types can override to provide custom
111 /// human-friendly formatting.
112 fn fmt_text(&self, _opts: &TextOptions) -> String {
113 serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
114 }
115}
116
117/// Pretty JSON fallback used when a type does not implement [`TextFormat`].
118///
119/// This produces a nicely indented JSON string, or falls back to compact
120/// JSON if pretty-printing fails.
121pub fn fallback_text_from_json(v: &JsonValue) -> String {
122 serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
123}
124
125/// Identity formatter for String: returns the raw string without JSON quoting.
126impl TextFormat for String {
127 fn fmt_text(&self, _opts: &TextOptions) -> String {
128 self.clone()
129 }
130}
131
132// ============================================================================
133// Type-erased formatter infrastructure (compatibility API)
134// ============================================================================
135//
136// This infrastructure is preserved for compatibility with external crates.
137// The registry now calls `TextFormat::fmt_text()` directly on tool outputs,
138// so this type-erased machinery is no longer used internally.
139
140/// Type-erased formatter function signature.
141///
142/// Takes a reference to the wire output (as `&dyn Any`), the JSON data (for fallback),
143/// and formatting options. Returns `Some(text)` if formatting succeeded.
144type ErasedFmtFn = fn(&dyn Any, &JsonValue, &TextOptions) -> Option<String>;
145
146/// Type-erased formatter captured at tool registration time.
147///
148/// This stores an optional formatting function that will be called at runtime
149/// to produce human-readable text from tool output. If `None`, the registry
150/// falls back to pretty-printed JSON.
151#[derive(Clone, Copy)]
152pub struct ErasedFmt {
153 fmt_fn: Option<ErasedFmtFn>,
154}
155
156impl ErasedFmt {
157 /// Create an empty formatter (will use JSON fallback).
158 pub const fn none() -> Self {
159 Self { fmt_fn: None }
160 }
161
162 /// Attempt to format the given wire output.
163 ///
164 /// Returns `Some(text)` if this formatter has a function and it succeeded,
165 /// `None` otherwise (caller should use JSON fallback).
166 pub fn format(
167 &self,
168 wire_any: &dyn Any,
169 data: &JsonValue,
170 opts: &TextOptions,
171 ) -> Option<String> {
172 self.fmt_fn.and_then(|f| f(wire_any, data, opts))
173 }
174}
175
176impl std::fmt::Debug for ErasedFmt {
177 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178 f.debug_struct("ErasedFmt")
179 .field("has_formatter", &self.fmt_fn.is_some())
180 .finish()
181 }
182}
183
184/// Build a formatter for a type that implements [`TextFormat`].
185///
186/// This is the explicit builder used when you know the type implements `TextFormat`.
187/// Kept for compatibility with external crates that may use the `ErasedFmt` API.
188pub fn build_formatter_for_textformat<W>() -> ErasedFmt
189where
190 W: TextFormat + Send + 'static,
191{
192 ErasedFmt {
193 fmt_fn: Some(|any, _json, opts| any.downcast_ref::<W>().map(|w| w.fmt_text(opts))),
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn test_text_style_default() {
203 let style = TextStyle::default();
204 assert_eq!(style, TextStyle::Humanized);
205 }
206
207 #[test]
208 fn test_text_options_default() {
209 let opts = TextOptions::default();
210 assert_eq!(opts.style, TextStyle::Humanized);
211 assert!(!opts.markdown);
212 assert!(opts.max_items.is_none());
213 assert!(!opts.suppress_search_reminder);
214 }
215
216 #[test]
217 fn test_text_options_builder() {
218 let opts = TextOptions::new()
219 .with_style(TextStyle::Plain)
220 .with_markdown(true)
221 .with_max_items(Some(10))
222 .with_suppress_search_reminder(true);
223
224 assert_eq!(opts.style, TextStyle::Plain);
225 assert!(opts.markdown);
226 assert_eq!(opts.max_items, Some(10));
227 assert!(opts.suppress_search_reminder);
228 }
229
230 #[test]
231 fn test_fallback_text_from_json_object() {
232 let v = serde_json::json!({"name": "test", "count": 42});
233 let text = fallback_text_from_json(&v);
234 assert!(text.contains("\"name\": \"test\""));
235 assert!(text.contains("\"count\": 42"));
236 }
237
238 #[test]
239 fn test_fallback_text_from_json_array() {
240 let v = serde_json::json!([1, 2, 3]);
241 let text = fallback_text_from_json(&v);
242 assert!(text.contains("1"));
243 assert!(text.contains("2"));
244 assert!(text.contains("3"));
245 }
246
247 #[test]
248 fn test_fallback_text_from_json_null() {
249 let v = serde_json::json!(null);
250 let text = fallback_text_from_json(&v);
251 assert_eq!(text, "null");
252 }
253
254 #[test]
255 fn test_text_format_impl() {
256 #[derive(serde::Serialize)]
257 struct TestOutput {
258 message: String,
259 }
260
261 impl TextFormat for TestOutput {
262 fn fmt_text(&self, _opts: &TextOptions) -> String {
263 format!("Message: {}", self.message)
264 }
265 }
266
267 let output = TestOutput {
268 message: "Hello".to_string(),
269 };
270 let text = output.fmt_text(&TextOptions::default());
271 assert_eq!(text, "Message: Hello");
272 }
273}