Skip to main content

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    #[must_use]
76    pub fn with_style(mut self, style: TextStyle) -> Self {
77        self.style = style;
78        self
79    }
80
81    /// Enable or disable markdown formatting.
82    #[must_use]
83    pub fn with_markdown(mut self, markdown: bool) -> Self {
84        self.markdown = markdown;
85        self
86    }
87
88    /// Set the maximum number of items to display.
89    #[must_use]
90    pub fn with_max_items(mut self, max_items: Option<usize>) -> Self {
91        self.max_items = max_items;
92        self
93    }
94
95    /// Enable or disable search reminder suppression for grep/glob text output.
96    #[must_use]
97    pub fn with_suppress_search_reminder(mut self, suppress_search_reminder: bool) -> Self {
98        self.suppress_search_reminder = suppress_search_reminder;
99        self
100    }
101}
102
103/// Transport-agnostic text formatting for tool outputs.
104///
105/// Implement this trait to provide custom human-readable formatting for your
106/// tool output types. The formatting is used by both MCP and NAPI servers
107/// to produce text alongside JSON data.
108///
109/// The default implementation returns pretty-printed JSON. Types can override
110/// `fmt_text` to provide custom human-friendly formatting.
111pub trait TextFormat: serde::Serialize {
112    /// Format the value as human-readable text.
113    ///
114    /// Default: pretty-printed JSON. Types can override to provide custom
115    /// human-friendly formatting.
116    fn fmt_text(&self, _opts: &TextOptions) -> String {
117        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
118    }
119}
120
121/// Pretty JSON fallback used when a type does not implement [`TextFormat`].
122///
123/// This produces a nicely indented JSON string, or falls back to compact
124/// JSON if pretty-printing fails.
125pub fn fallback_text_from_json(v: &JsonValue) -> String {
126    serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
127}
128
129/// Identity formatter for String: returns the raw string without JSON quoting.
130impl TextFormat for String {
131    fn fmt_text(&self, _opts: &TextOptions) -> String {
132        self.clone()
133    }
134}
135
136// ============================================================================
137// Type-erased formatter infrastructure (compatibility API)
138// ============================================================================
139//
140// This infrastructure is preserved for compatibility with external crates.
141// The registry now calls `TextFormat::fmt_text()` directly on tool outputs,
142// so this type-erased machinery is no longer used internally.
143
144/// Type-erased formatter function signature.
145///
146/// Takes a reference to the wire output (as `&dyn Any`), the JSON data (for fallback),
147/// and formatting options. Returns `Some(text)` if formatting succeeded.
148type ErasedFmtFn = fn(&dyn Any, &JsonValue, &TextOptions) -> Option<String>;
149
150/// Type-erased formatter captured at tool registration time.
151///
152/// This stores an optional formatting function that will be called at runtime
153/// to produce human-readable text from tool output. If `None`, the registry
154/// falls back to pretty-printed JSON.
155#[derive(Clone, Copy)]
156pub struct ErasedFmt {
157    fmt_fn: Option<ErasedFmtFn>,
158}
159
160impl ErasedFmt {
161    /// Create an empty formatter (will use JSON fallback).
162    pub const fn none() -> Self {
163        Self { fmt_fn: None }
164    }
165
166    /// Attempt to format the given wire output.
167    ///
168    /// Returns `Some(text)` if this formatter has a function and it succeeded,
169    /// `None` otherwise (caller should use JSON fallback).
170    pub fn format(
171        &self,
172        wire_any: &dyn Any,
173        data: &JsonValue,
174        opts: &TextOptions,
175    ) -> Option<String> {
176        self.fmt_fn.and_then(|f| f(wire_any, data, opts))
177    }
178}
179
180impl std::fmt::Debug for ErasedFmt {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        f.debug_struct("ErasedFmt")
183            .field("has_formatter", &self.fmt_fn.is_some())
184            .finish()
185    }
186}
187
188/// Build a formatter for a type that implements [`TextFormat`].
189///
190/// This is the explicit builder used when you know the type implements `TextFormat`.
191/// Kept for compatibility with external crates that may use the `ErasedFmt` API.
192pub fn build_formatter_for_textformat<W>() -> ErasedFmt
193where
194    W: TextFormat + Send + 'static,
195{
196    ErasedFmt {
197        fmt_fn: Some(|any, _json, opts| any.downcast_ref::<W>().map(|w| w.fmt_text(opts))),
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_text_style_default() {
207        let style = TextStyle::default();
208        assert_eq!(style, TextStyle::Humanized);
209    }
210
211    #[test]
212    fn test_text_options_default() {
213        let opts = TextOptions::default();
214        assert_eq!(opts.style, TextStyle::Humanized);
215        assert!(!opts.markdown);
216        assert!(opts.max_items.is_none());
217        assert!(!opts.suppress_search_reminder);
218    }
219
220    #[test]
221    fn test_text_options_builder() {
222        let opts = TextOptions::new()
223            .with_style(TextStyle::Plain)
224            .with_markdown(true)
225            .with_max_items(Some(10))
226            .with_suppress_search_reminder(true);
227
228        assert_eq!(opts.style, TextStyle::Plain);
229        assert!(opts.markdown);
230        assert_eq!(opts.max_items, Some(10));
231        assert!(opts.suppress_search_reminder);
232    }
233
234    #[test]
235    fn test_fallback_text_from_json_object() {
236        let v = serde_json::json!({"name": "test", "count": 42});
237        let text = fallback_text_from_json(&v);
238        assert!(text.contains("\"name\": \"test\""));
239        assert!(text.contains("\"count\": 42"));
240    }
241
242    #[test]
243    fn test_fallback_text_from_json_array() {
244        let v = serde_json::json!([1, 2, 3]);
245        let text = fallback_text_from_json(&v);
246        assert!(text.contains('1'));
247        assert!(text.contains('2'));
248        assert!(text.contains('3'));
249    }
250
251    #[test]
252    fn test_fallback_text_from_json_null() {
253        let v = serde_json::json!(null);
254        let text = fallback_text_from_json(&v);
255        assert_eq!(text, "null");
256    }
257
258    #[test]
259    fn test_text_format_impl() {
260        #[derive(serde::Serialize)]
261        struct TestOutput {
262            message: String,
263        }
264
265        impl TextFormat for TestOutput {
266            fn fmt_text(&self, _opts: &TextOptions) -> String {
267                format!("Message: {}", self.message)
268            }
269        }
270
271        let output = TestOutput {
272            message: "Hello".to_string(),
273        };
274        let text = output.fmt_text(&TextOptions::default());
275        assert_eq!(text, "Message: Hello");
276    }
277}