mixtape_core/
tool.rs

1use schemars::JsonSchema;
2use serde::de::DeserializeOwned;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6/// Image formats supported for tool results
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum ImageFormat {
10    Png,
11    Jpeg,
12    Gif,
13    Webp,
14}
15
16/// Document formats supported for tool results
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum DocumentFormat {
20    Pdf,
21    Csv,
22    Doc,
23    Docx,
24    Html,
25    Md,
26    Txt,
27    Xls,
28    Xlsx,
29}
30
31/// Result types that tools can return.
32///
33/// Tools can return different content types depending on their purpose.
34/// All providers support Text and Json. Image and Document support varies by provider
35/// (Bedrock supports all types; future providers may fall back to text descriptions).
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub enum ToolResult {
38    /// Plain text response
39    Text(String),
40
41    /// Structured JSON data - use for complex responses
42    Json(Value),
43
44    /// Image data - supported by Bedrock (Claude, Nova models)
45    Image {
46        format: ImageFormat,
47        /// Raw image bytes (not base64 encoded)
48        data: Vec<u8>,
49    },
50
51    /// Document data - supported by Bedrock (Claude, Nova models)
52    Document {
53        format: DocumentFormat,
54        /// Raw document bytes
55        data: Vec<u8>,
56        /// Optional document name/filename
57        name: Option<String>,
58    },
59}
60
61impl ToolResult {
62    /// Create a JSON result from any serializable type
63    pub fn json<T: Serialize>(value: T) -> Result<Self, serde_json::Error> {
64        Ok(Self::Json(serde_json::to_value(value)?))
65    }
66
67    /// Create a text result from a string
68    pub fn text(s: impl Into<String>) -> Self {
69        Self::Text(s.into())
70    }
71
72    /// Create an image result from raw bytes
73    pub fn image(format: ImageFormat, data: Vec<u8>) -> Self {
74        Self::Image { format, data }
75    }
76
77    /// Create a document result from raw bytes
78    pub fn document(format: DocumentFormat, data: Vec<u8>) -> Self {
79        Self::Document {
80            format,
81            data,
82            name: None,
83        }
84    }
85
86    /// Create a document result with a filename
87    pub fn document_with_name(
88        format: DocumentFormat,
89        data: Vec<u8>,
90        name: impl Into<String>,
91    ) -> Self {
92        Self::Document {
93            format,
94            data,
95            name: Some(name.into()),
96        }
97    }
98
99    /// Get the text content if this is a Text variant, or convert to string description
100    pub fn as_text(&self) -> String {
101        match self {
102            ToolResult::Text(s) => s.clone(),
103            ToolResult::Json(v) => v.to_string(),
104            ToolResult::Image { format, data } => {
105                format!("[Image: {:?}, {} bytes]", format, data.len())
106            }
107            ToolResult::Document { format, data, name } => {
108                let name_str = name.as_deref().unwrap_or("unnamed");
109                format!(
110                    "[Document: {:?}, {}, {} bytes]",
111                    format,
112                    name_str,
113                    data.len()
114                )
115            }
116        }
117    }
118
119    /// Get a reference to the text content if this is a Text variant
120    pub fn as_str(&self) -> Option<&str> {
121        match self {
122            ToolResult::Text(s) => Some(s),
123            _ => None,
124        }
125    }
126}
127
128/// Convert strings directly to ToolResult::Text
129impl From<String> for ToolResult {
130    fn from(s: String) -> Self {
131        Self::Text(s)
132    }
133}
134
135impl From<&str> for ToolResult {
136    fn from(s: &str) -> Self {
137        Self::Text(s.to_string())
138    }
139}
140
141/// Errors that can occur during tool execution
142#[derive(Debug, thiserror::Error)]
143pub enum ToolError {
144    #[error("IO error: {0}")]
145    Io(#[from] std::io::Error),
146
147    #[error("Serialization error: {0}")]
148    Serialization(#[from] serde_json::Error),
149
150    #[error("Path validation failed: {0}")]
151    PathValidation(String),
152
153    #[error("{0}")]
154    Custom(String),
155}
156
157impl From<String> for ToolError {
158    fn from(s: String) -> Self {
159        Self::Custom(s)
160    }
161}
162
163impl From<&str> for ToolError {
164    fn from(s: &str) -> Self {
165        Self::Custom(s.to_string())
166    }
167}
168
169/// Trait for implementing tools that can be used by AI agents.
170///
171/// Tools define an input type with `#[derive(Deserialize, JsonSchema)]` to automatically
172/// generate JSON schemas from Rust types, providing excellent developer experience.
173///
174/// # Async Tools Example
175///
176/// ```rust
177/// use mixtape_core::{Tool, ToolResult, ToolError};
178/// use schemars::JsonSchema;
179/// use serde::Deserialize;
180/// use std::time::Duration;
181///
182/// #[derive(Deserialize, JsonSchema)]
183/// struct DelayInput {
184///     /// Duration in milliseconds
185///     ms: u64,
186/// }
187///
188/// struct DelayTool;
189///
190/// impl Tool for DelayTool {
191///     type Input = DelayInput;
192///
193///     fn name(&self) -> &str { "delay" }
194///     fn description(&self) -> &str { "Wait for a duration" }
195///
196///     fn execute(&self, input: Self::Input) -> impl std::future::Future<Output = Result<ToolResult, ToolError>> + Send {
197///         async move {
198///             // Async operations work naturally
199///             tokio::time::sleep(Duration::from_millis(input.ms)).await;
200///             Ok(format!("Waited {}ms", input.ms).into())  // Converts to ToolResult::Text
201///         }
202///     }
203/// }
204/// ```
205///
206/// # Returning JSON Data
207///
208/// Tools can return structured JSON data:
209///
210/// ```rust
211/// use mixtape_core::{Tool, ToolResult, ToolError};
212/// use schemars::JsonSchema;
213/// use serde::{Deserialize, Serialize};
214///
215/// #[derive(Deserialize, JsonSchema)]
216/// struct CalculateInput {
217///     a: i32,
218///     b: i32,
219/// }
220///
221/// #[derive(Serialize)]
222/// struct CalculateOutput {
223///     sum: i32,
224///     product: i32,
225/// }
226///
227/// struct CalculateTool;
228///
229/// impl Tool for CalculateTool {
230///     type Input = CalculateInput;
231///
232///     fn name(&self) -> &str { "calculate" }
233///     fn description(&self) -> &str { "Perform calculations" }
234///
235///     fn execute(&self, input: Self::Input) -> impl std::future::Future<Output = Result<ToolResult, ToolError>> + Send {
236///         async move {
237///             let output = CalculateOutput {
238///                 sum: input.a + input.b,
239///                 product: input.a * input.b,
240///             };
241///             ToolResult::json(output).map_err(Into::into)
242///         }
243///     }
244/// }
245/// ```
246pub trait Tool: Send + Sync {
247    /// The input type for this tool. Must implement `Deserialize` and `JsonSchema`.
248    type Input: DeserializeOwned + JsonSchema;
249
250    /// The name of the tool (e.g., "read_file", "calculator")
251    fn name(&self) -> &str;
252
253    /// A description of what the tool does
254    fn description(&self) -> &str;
255
256    /// Execute the tool with typed input
257    fn execute(
258        &self,
259        input: Self::Input,
260    ) -> impl std::future::Future<Output = Result<ToolResult, ToolError>> + Send;
261
262    /// Get the JSON schema for this tool's input.
263    ///
264    /// This is automatically implemented using the `JsonSchema` derive on `Input`.
265    /// The schema is generated at runtime from the type definition.
266    fn input_schema(&self) -> Value {
267        let schema = schemars::schema_for!(Self::Input);
268        serde_json::to_value(schema).expect("Failed to serialize schema")
269    }
270
271    // ========================================================================
272    // Formatting methods - override these for custom tool presentation
273    // ========================================================================
274
275    /// Format tool input as plain text (for JIRA, logs, copy/paste).
276    ///
277    /// Default implementation shows tool name and parameters with truncation.
278    fn format_input_plain(&self, params: &Value) -> String {
279        format_params_plain(self.name(), params)
280    }
281
282    /// Format tool input with ANSI colors (for terminal display).
283    ///
284    /// Default implementation shows tool name (bold) and parameters with colors.
285    fn format_input_ansi(&self, params: &Value) -> String {
286        format_params_ansi(self.name(), params)
287    }
288
289    /// Format tool input as Markdown (for docs, GitHub, rendered UIs).
290    ///
291    /// Default implementation shows tool name and parameters in markdown format.
292    fn format_input_markdown(&self, params: &Value) -> String {
293        format_params_markdown(self.name(), params)
294    }
295
296    /// Format tool output as plain text.
297    ///
298    /// Default implementation shows result text with truncation.
299    fn format_output_plain(&self, result: &ToolResult) -> String {
300        format_result_plain(result)
301    }
302
303    /// Format tool output with ANSI colors.
304    ///
305    /// Default implementation shows result with success indicator and truncation.
306    fn format_output_ansi(&self, result: &ToolResult) -> String {
307        format_result_ansi(result)
308    }
309
310    /// Format tool output as Markdown.
311    ///
312    /// Default implementation shows result in a code block with truncation.
313    fn format_output_markdown(&self, result: &ToolResult) -> String {
314        format_result_markdown(result)
315    }
316}
317
318/// Object-safe trait for dynamic tool dispatch (used internally by the agent).
319///
320/// Users should implement `Tool` instead and use `box_tool()` to convert.
321pub trait DynTool: Send + Sync {
322    fn name(&self) -> &str;
323    fn description(&self) -> &str;
324    fn input_schema(&self) -> Value;
325    fn execute_raw(
326        &self,
327        input: Value,
328    ) -> std::pin::Pin<
329        Box<dyn std::future::Future<Output = Result<ToolResult, ToolError>> + Send + '_>,
330    >;
331
332    // Formatting methods
333    fn format_input_plain(&self, params: &Value) -> String;
334    fn format_input_ansi(&self, params: &Value) -> String;
335    fn format_input_markdown(&self, params: &Value) -> String;
336    fn format_output_plain(&self, result: &ToolResult) -> String;
337    fn format_output_ansi(&self, result: &ToolResult) -> String;
338    fn format_output_markdown(&self, result: &ToolResult) -> String;
339}
340
341/// Convert a `Tool` into a type-erased `Box<dyn DynTool>` for storage in collections.
342pub fn box_tool<T: Tool + 'static>(tool: T) -> Box<dyn DynTool> {
343    Box::new(ToolWrapper(tool))
344}
345
346/// Create a `Vec<Box<dyn DynTool>>` from heterogeneous tool types.
347///
348/// This macro boxes each tool and collects them into a vector that can be
349/// passed to [`crate::AgentBuilder::add_tools()`].
350///
351/// # Example
352///
353/// ```ignore
354/// use mixtape_core::{Agent, box_tools, ClaudeSonnet4};
355///
356/// let agent = Agent::builder()
357///     .bedrock(ClaudeSonnet4)
358///     .add_tools(box_tools![Calculator, WeatherLookup, FileReader])
359///     .build()
360///     .await?;
361/// ```
362///
363/// This is equivalent to:
364///
365/// ```ignore
366/// .add_tool(Calculator)
367/// .add_tool(WeatherLookup)
368/// .add_tool(FileReader)
369/// ```
370#[macro_export]
371macro_rules! box_tools {
372    ($($tool:expr),* $(,)?) => {
373        vec![$($crate::tool::box_tool($tool)),*]
374    };
375}
376
377/// Internal wrapper that implements DynTool for any Tool
378struct ToolWrapper<T>(T);
379
380impl<T: Tool + 'static> DynTool for ToolWrapper<T> {
381    fn name(&self) -> &str {
382        self.0.name()
383    }
384
385    fn description(&self) -> &str {
386        self.0.description()
387    }
388
389    fn input_schema(&self) -> Value {
390        self.0.input_schema()
391    }
392
393    fn execute_raw(
394        &self,
395        input: Value,
396    ) -> std::pin::Pin<
397        Box<dyn std::future::Future<Output = Result<ToolResult, ToolError>> + Send + '_>,
398    > {
399        Box::pin(async move {
400            let typed_input: T::Input = serde_json::from_value(input)
401                .map_err(|e| ToolError::Custom(format!("Failed to deserialize input: {}", e)))?;
402
403            self.0.execute(typed_input).await
404        })
405    }
406
407    fn format_input_plain(&self, params: &Value) -> String {
408        self.0.format_input_plain(params)
409    }
410
411    fn format_input_ansi(&self, params: &Value) -> String {
412        self.0.format_input_ansi(params)
413    }
414
415    fn format_input_markdown(&self, params: &Value) -> String {
416        self.0.format_input_markdown(params)
417    }
418
419    fn format_output_plain(&self, result: &ToolResult) -> String {
420        self.0.format_output_plain(result)
421    }
422
423    fn format_output_ansi(&self, result: &ToolResult) -> String {
424        self.0.format_output_ansi(result)
425    }
426
427    fn format_output_markdown(&self, result: &ToolResult) -> String {
428        self.0.format_output_markdown(result)
429    }
430}
431
432// ============================================================================
433// Default formatting helpers
434// ============================================================================
435
436const MAX_PARAMS: usize = 10;
437const MAX_VALUE_LEN: usize = 80;
438const MAX_OUTPUT_LINES: usize = 12;
439
440/// Format a JSON value for display, with truncation
441fn format_value_preview(value: &Value) -> String {
442    match value {
443        Value::String(s) => {
444            if s.len() > MAX_VALUE_LEN {
445                format!("\"{}…\"", &s[..MAX_VALUE_LEN])
446            } else {
447                format!("\"{}\"", s)
448            }
449        }
450        Value::Array(arr) => format!("[{} items]", arr.len()),
451        Value::Object(obj) => format!("{{{} keys}}", obj.len()),
452        Value::Null => "null".to_string(),
453        Value::Bool(b) => b.to_string(),
454        Value::Number(n) => n.to_string(),
455    }
456}
457
458/// Format tool parameters as plain text
459pub fn format_params_plain(tool_name: &str, params: &Value) -> String {
460    let mut output = tool_name.to_string();
461
462    if let Some(obj) = params.as_object() {
463        for (key, value) in obj.iter().take(MAX_PARAMS) {
464            output.push_str(&format!("\n  {}: {}", key, format_value_preview(value)));
465        }
466        if obj.len() > MAX_PARAMS {
467            output.push_str(&format!("\n  … +{} more", obj.len() - MAX_PARAMS));
468        }
469    }
470
471    output
472}
473
474/// Format tool parameters with ANSI colors
475pub fn format_params_ansi(tool_name: &str, params: &Value) -> String {
476    // Bold tool name
477    let mut output = format!("\x1b[1m{}\x1b[0m", tool_name);
478
479    if let Some(obj) = params.as_object() {
480        for (key, value) in obj.iter().take(MAX_PARAMS) {
481            // Dim key, normal value
482            output.push_str(&format!(
483                "\n  \x1b[2m{}:\x1b[0m {}",
484                key,
485                format_value_preview(value)
486            ));
487        }
488        if obj.len() > MAX_PARAMS {
489            output.push_str(&format!(
490                "\n  \x1b[2m… +{} more\x1b[0m",
491                obj.len() - MAX_PARAMS
492            ));
493        }
494    }
495
496    output
497}
498
499/// Format tool parameters as Markdown
500pub fn format_params_markdown(tool_name: &str, params: &Value) -> String {
501    let mut output = format!("**{}**", tool_name);
502
503    if let Some(obj) = params.as_object() {
504        for (key, value) in obj.iter().take(MAX_PARAMS) {
505            output.push_str(&format!("\n- `{}`: {}", key, format_value_preview(value)));
506        }
507        if obj.len() > MAX_PARAMS {
508            output.push_str(&format!("\n- *… +{} more*", obj.len() - MAX_PARAMS));
509        }
510    }
511
512    output
513}
514
515/// Get text representation of a ToolResult
516fn result_to_text(result: &ToolResult) -> String {
517    match result {
518        ToolResult::Text(s) => s.clone(),
519        ToolResult::Json(v) => serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string()),
520        ToolResult::Image { format, data } => {
521            format!("[Image: {:?}, {} bytes]", format, data.len())
522        }
523        ToolResult::Document { format, data, name } => {
524            let name_str = name.as_deref().unwrap_or("unnamed");
525            format!(
526                "[Document: {:?}, {}, {} bytes]",
527                format,
528                name_str,
529                data.len()
530            )
531        }
532    }
533}
534
535/// Truncate text to max lines, returning (truncated_text, remaining_lines)
536fn truncate_lines(text: &str, max_lines: usize) -> (String, usize) {
537    let lines: Vec<&str> = text.lines().collect();
538    if lines.len() <= max_lines {
539        (text.to_string(), 0)
540    } else {
541        let truncated = lines[..max_lines].join("\n");
542        (truncated, lines.len() - max_lines)
543    }
544}
545
546/// Format tool result as plain text
547pub fn format_result_plain(result: &ToolResult) -> String {
548    let text = result_to_text(result);
549    let (truncated, remaining) = truncate_lines(&text, MAX_OUTPUT_LINES);
550
551    if remaining > 0 {
552        format!("{}\n… +{} more lines", truncated, remaining)
553    } else {
554        truncated
555    }
556}
557
558/// Format tool result with ANSI colors
559pub fn format_result_ansi(result: &ToolResult) -> String {
560    let text = result_to_text(result);
561    let (truncated, remaining) = truncate_lines(&text, MAX_OUTPUT_LINES);
562
563    if remaining > 0 {
564        format!(
565            "\x1b[32m✓\x1b[0m\n{}\n\x1b[2m… +{} more lines\x1b[0m",
566            truncated, remaining
567        )
568    } else {
569        format!("\x1b[32m✓\x1b[0m\n{}", truncated)
570    }
571}
572
573/// Format tool result as Markdown
574pub fn format_result_markdown(result: &ToolResult) -> String {
575    let text = result_to_text(result);
576    let (truncated, remaining) = truncate_lines(&text, MAX_OUTPUT_LINES);
577
578    let mut output = String::from("```\n");
579    output.push_str(&truncated);
580    output.push_str("\n```");
581
582    if remaining > 0 {
583        output.push_str(&format!("\n*… +{} more lines*", remaining));
584    }
585
586    output
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    // ===== format_value_preview tests =====
594
595    #[test]
596    fn test_format_value_preview_string_short() {
597        let value = serde_json::json!("hello");
598        assert_eq!(format_value_preview(&value), "\"hello\"");
599    }
600
601    #[test]
602    fn test_format_value_preview_string_long() {
603        let long_string = "x".repeat(100);
604        let value = serde_json::json!(long_string);
605        let preview = format_value_preview(&value);
606
607        // Should be truncated to MAX_VALUE_LEN (80) + quotes + ellipsis
608        assert!(preview.len() < 100);
609        assert!(preview.ends_with("…\""));
610    }
611
612    #[test]
613    fn test_format_value_preview_array() {
614        let value = serde_json::json!([1, 2, 3, 4, 5]);
615        assert_eq!(format_value_preview(&value), "[5 items]");
616    }
617
618    #[test]
619    fn test_format_value_preview_object() {
620        let value = serde_json::json!({"a": 1, "b": 2});
621        assert_eq!(format_value_preview(&value), "{2 keys}");
622    }
623
624    #[test]
625    fn test_format_value_preview_null() {
626        let value = serde_json::json!(null);
627        assert_eq!(format_value_preview(&value), "null");
628    }
629
630    #[test]
631    fn test_format_value_preview_bool() {
632        assert_eq!(format_value_preview(&serde_json::json!(true)), "true");
633        assert_eq!(format_value_preview(&serde_json::json!(false)), "false");
634    }
635
636    #[test]
637    fn test_format_value_preview_number() {
638        assert_eq!(format_value_preview(&serde_json::json!(42)), "42");
639        assert_eq!(format_value_preview(&serde_json::json!(1.5)), "1.5");
640    }
641
642    // ===== truncate_lines tests =====
643
644    #[test]
645    fn test_truncate_lines_no_truncation() {
646        let text = "line1\nline2\nline3";
647        let (result, remaining) = truncate_lines(text, 5);
648        assert_eq!(result, text);
649        assert_eq!(remaining, 0);
650    }
651
652    #[test]
653    fn test_truncate_lines_with_truncation() {
654        let text = "line1\nline2\nline3\nline4\nline5";
655        let (result, remaining) = truncate_lines(text, 3);
656        assert_eq!(result, "line1\nline2\nline3");
657        assert_eq!(remaining, 2);
658    }
659
660    #[test]
661    fn test_truncate_lines_exact_limit() {
662        let text = "line1\nline2\nline3";
663        let (result, remaining) = truncate_lines(text, 3);
664        assert_eq!(result, text);
665        assert_eq!(remaining, 0);
666    }
667
668    // ===== format_params tests =====
669
670    #[test]
671    fn test_format_params_plain_simple() {
672        let params = serde_json::json!({"path": "/tmp/test.txt"});
673        let output = format_params_plain("read_file", &params);
674
675        assert!(output.starts_with("read_file"));
676        assert!(output.contains("path:"));
677        assert!(output.contains("/tmp/test.txt"));
678    }
679
680    #[test]
681    fn test_format_params_plain_many_params() {
682        // More than MAX_PARAMS (10) parameters
683        let mut obj = serde_json::Map::new();
684        for i in 0..15 {
685            obj.insert(format!("key{}", i), serde_json::json!(i));
686        }
687        let params = serde_json::Value::Object(obj);
688        let output = format_params_plain("test_tool", &params);
689
690        assert!(output.contains("… +"));
691        assert!(output.contains("more"));
692    }
693
694    #[test]
695    fn test_format_params_ansi_has_codes() {
696        let params = serde_json::json!({"name": "test"});
697        let output = format_params_ansi("my_tool", &params);
698
699        // Should contain ANSI escape codes
700        assert!(output.contains("\x1b["));
701        // Should contain tool name
702        assert!(output.contains("my_tool"));
703    }
704
705    #[test]
706    fn test_format_params_markdown_format() {
707        let params = serde_json::json!({"file": "test.rs"});
708        let output = format_params_markdown("edit", &params);
709
710        // Should have bold tool name
711        assert!(output.starts_with("**edit**"));
712        // Should have markdown list items
713        assert!(output.contains("- `file`:"));
714    }
715
716    // ===== format_result tests =====
717
718    #[test]
719    fn test_format_result_plain_short() {
720        let result = ToolResult::Text("Success!".to_string());
721        let output = format_result_plain(&result);
722        assert_eq!(output, "Success!");
723    }
724
725    #[test]
726    fn test_format_result_plain_truncated() {
727        // More than MAX_OUTPUT_LINES (12) lines
728        let long_text = (0..20)
729            .map(|i| format!("line {}", i))
730            .collect::<Vec<_>>()
731            .join("\n");
732        let result = ToolResult::Text(long_text);
733        let output = format_result_plain(&result);
734
735        assert!(output.contains("… +"));
736        assert!(output.contains("more lines"));
737    }
738
739    #[test]
740    fn test_format_result_ansi_success_marker() {
741        let result = ToolResult::Text("Done".to_string());
742        let output = format_result_ansi(&result);
743
744        // Should have green checkmark
745        assert!(output.contains("\x1b[32m✓\x1b[0m"));
746    }
747
748    #[test]
749    fn test_format_result_markdown_code_block() {
750        let result = ToolResult::Text("code here".to_string());
751        let output = format_result_markdown(&result);
752
753        assert!(output.starts_with("```\n"));
754        assert!(output.contains("code here"));
755        assert!(output.contains("\n```"));
756    }
757
758    #[test]
759    fn test_format_result_json() {
760        let result = ToolResult::Json(serde_json::json!({"status": "ok"}));
761        let output = format_result_plain(&result);
762
763        // JSON should be pretty-printed
764        assert!(output.contains("status"));
765        assert!(output.contains("ok"));
766    }
767
768    #[test]
769    fn test_format_result_image() {
770        let result = ToolResult::Image {
771            format: ImageFormat::Png,
772            data: vec![0u8; 1000],
773        };
774        let output = format_result_plain(&result);
775
776        assert!(output.contains("Image"));
777        assert!(output.contains("Png"));
778        assert!(output.contains("1000 bytes"));
779    }
780
781    #[test]
782    fn test_format_result_document() {
783        let result = ToolResult::Document {
784            format: DocumentFormat::Pdf,
785            data: vec![0u8; 500],
786            name: Some("report.pdf".to_string()),
787        };
788        let output = format_result_plain(&result);
789
790        assert!(output.contains("Document"));
791        assert!(output.contains("Pdf"));
792        assert!(output.contains("report.pdf"));
793        assert!(output.contains("500 bytes"));
794    }
795
796    #[test]
797    fn test_format_result_document_unnamed() {
798        let result = ToolResult::Document {
799            format: DocumentFormat::Txt,
800            data: vec![0u8; 100],
801            name: None,
802        };
803        let output = format_result_plain(&result);
804
805        assert!(output.contains("unnamed"));
806    }
807
808    // ===== ToolResult factory tests =====
809
810    #[test]
811    fn test_tool_result_image_factory() {
812        let result = ToolResult::image(ImageFormat::Jpeg, vec![1, 2, 3]);
813
814        if let ToolResult::Image { format, data } = result {
815            assert_eq!(format, ImageFormat::Jpeg);
816            assert_eq!(data, vec![1, 2, 3]);
817        } else {
818            panic!("Expected Image variant");
819        }
820    }
821
822    #[test]
823    fn test_tool_result_document_factory() {
824        let result = ToolResult::document(DocumentFormat::Csv, vec![4, 5, 6]);
825
826        if let ToolResult::Document { format, data, name } = result {
827            assert_eq!(format, DocumentFormat::Csv);
828            assert_eq!(data, vec![4, 5, 6]);
829            assert!(name.is_none());
830        } else {
831            panic!("Expected Document variant");
832        }
833    }
834
835    #[test]
836    fn test_tool_result_document_with_name_factory() {
837        let result = ToolResult::document_with_name(DocumentFormat::Html, vec![7, 8], "page.html");
838
839        if let ToolResult::Document { format, data, name } = result {
840            assert_eq!(format, DocumentFormat::Html);
841            assert_eq!(data, vec![7, 8]);
842            assert_eq!(name, Some("page.html".to_string()));
843        } else {
844            panic!("Expected Document variant");
845        }
846    }
847
848    // ===== ToolResult::as_text for binary types =====
849
850    #[test]
851    fn test_tool_result_as_text_image() {
852        let result = ToolResult::Image {
853            format: ImageFormat::Gif,
854            data: vec![0u8; 2000],
855        };
856        let text = result.as_text();
857
858        assert!(text.contains("Image"));
859        assert!(text.contains("Gif"));
860        assert!(text.contains("2000 bytes"));
861    }
862
863    #[test]
864    fn test_tool_result_as_text_document() {
865        let result = ToolResult::Document {
866            format: DocumentFormat::Xlsx,
867            data: vec![0u8; 3000],
868            name: Some("data.xlsx".to_string()),
869        };
870        let text = result.as_text();
871
872        assert!(text.contains("Document"));
873        assert!(text.contains("Xlsx"));
874        assert!(text.contains("data.xlsx"));
875        assert!(text.contains("3000 bytes"));
876    }
877
878    #[test]
879    fn test_tool_result_as_str_binary_types() {
880        let image = ToolResult::Image {
881            format: ImageFormat::Webp,
882            data: vec![],
883        };
884        assert!(image.as_str().is_none());
885
886        let doc = ToolResult::Document {
887            format: DocumentFormat::Doc,
888            data: vec![],
889            name: None,
890        };
891        assert!(doc.as_str().is_none());
892    }
893}