Skip to main content

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) => format_json_truncated(v),
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/// Format JSON with truncated string values and limited object keys
536fn format_json_truncated(value: &Value) -> String {
537    format_json_truncated_inner(value, 0)
538}
539
540fn format_json_truncated_inner(value: &Value, depth: usize) -> String {
541    let indent = "  ".repeat(depth);
542    let child_indent = "  ".repeat(depth + 1);
543
544    match value {
545        Value::String(s) => {
546            if s.len() > MAX_VALUE_LEN {
547                format!("\"{}…\"", &s[..MAX_VALUE_LEN])
548            } else {
549                format!("\"{}\"", s)
550            }
551        }
552        Value::Array(arr) => {
553            if arr.is_empty() {
554                "[]".to_string()
555            } else if arr.len() > MAX_PARAMS {
556                format!("[{} items]", arr.len())
557            } else {
558                let items: Vec<String> = arr
559                    .iter()
560                    .take(MAX_PARAMS)
561                    .map(|v| {
562                        format!(
563                            "{}{}",
564                            child_indent,
565                            format_json_truncated_inner(v, depth + 1)
566                        )
567                    })
568                    .collect();
569                format!("[\n{}\n{}]", items.join(",\n"), indent)
570            }
571        }
572        Value::Object(obj) => {
573            if obj.is_empty() {
574                "{}".to_string()
575            } else {
576                let mut items: Vec<String> = obj
577                    .iter()
578                    .take(MAX_PARAMS)
579                    .map(|(k, v)| {
580                        format!(
581                            "{}\"{}\": {}",
582                            child_indent,
583                            k,
584                            format_json_truncated_inner(v, depth + 1)
585                        )
586                    })
587                    .collect();
588                if obj.len() > MAX_PARAMS {
589                    items.push(format!(
590                        "{}… +{} more",
591                        child_indent,
592                        obj.len() - MAX_PARAMS
593                    ));
594                }
595                format!("{{\n{}\n{}}}", items.join(",\n"), indent)
596            }
597        }
598        Value::Null => "null".to_string(),
599        Value::Bool(b) => b.to_string(),
600        Value::Number(n) => n.to_string(),
601    }
602}
603
604/// Truncate text to max lines, returning (truncated_text, remaining_lines)
605fn truncate_lines(text: &str, max_lines: usize) -> (String, usize) {
606    let lines: Vec<&str> = text.lines().collect();
607    if lines.len() <= max_lines {
608        (text.to_string(), 0)
609    } else {
610        let truncated = lines[..max_lines].join("\n");
611        (truncated, lines.len() - max_lines)
612    }
613}
614
615/// Format tool result as plain text
616pub fn format_result_plain(result: &ToolResult) -> String {
617    let text = result_to_text(result);
618    let (truncated, remaining) = truncate_lines(&text, MAX_OUTPUT_LINES);
619
620    if remaining > 0 {
621        format!("{}\n… +{} more lines", truncated, remaining)
622    } else {
623        truncated
624    }
625}
626
627/// Format tool result with ANSI colors
628pub fn format_result_ansi(result: &ToolResult) -> String {
629    let text = result_to_text(result);
630    let (truncated, remaining) = truncate_lines(&text, MAX_OUTPUT_LINES);
631
632    if remaining > 0 {
633        format!(
634            "\x1b[32m✓\x1b[0m\n{}\n\x1b[2m… +{} more lines\x1b[0m",
635            truncated, remaining
636        )
637    } else {
638        format!("\x1b[32m✓\x1b[0m\n{}", truncated)
639    }
640}
641
642/// Format tool result as Markdown
643pub fn format_result_markdown(result: &ToolResult) -> String {
644    let text = result_to_text(result);
645    let (truncated, remaining) = truncate_lines(&text, MAX_OUTPUT_LINES);
646
647    let mut output = String::from("```\n");
648    output.push_str(&truncated);
649    output.push_str("\n```");
650
651    if remaining > 0 {
652        output.push_str(&format!("\n*… +{} more lines*", remaining));
653    }
654
655    output
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    // ===== format_value_preview tests =====
663
664    #[test]
665    fn test_format_value_preview_string_short() {
666        let value = serde_json::json!("hello");
667        assert_eq!(format_value_preview(&value), "\"hello\"");
668    }
669
670    #[test]
671    fn test_format_value_preview_string_long() {
672        let long_string = "x".repeat(100);
673        let value = serde_json::json!(long_string);
674        let preview = format_value_preview(&value);
675
676        // Should be truncated to MAX_VALUE_LEN (80) + quotes + ellipsis
677        assert!(preview.len() < 100);
678        assert!(preview.ends_with("…\""));
679    }
680
681    #[test]
682    fn test_format_value_preview_array() {
683        let value = serde_json::json!([1, 2, 3, 4, 5]);
684        assert_eq!(format_value_preview(&value), "[5 items]");
685    }
686
687    #[test]
688    fn test_format_value_preview_object() {
689        let value = serde_json::json!({"a": 1, "b": 2});
690        assert_eq!(format_value_preview(&value), "{2 keys}");
691    }
692
693    #[test]
694    fn test_format_value_preview_null() {
695        let value = serde_json::json!(null);
696        assert_eq!(format_value_preview(&value), "null");
697    }
698
699    #[test]
700    fn test_format_value_preview_bool() {
701        assert_eq!(format_value_preview(&serde_json::json!(true)), "true");
702        assert_eq!(format_value_preview(&serde_json::json!(false)), "false");
703    }
704
705    #[test]
706    fn test_format_value_preview_number() {
707        assert_eq!(format_value_preview(&serde_json::json!(42)), "42");
708        assert_eq!(format_value_preview(&serde_json::json!(1.5)), "1.5");
709    }
710
711    // ===== truncate_lines tests =====
712
713    #[test]
714    fn test_truncate_lines_no_truncation() {
715        let text = "line1\nline2\nline3";
716        let (result, remaining) = truncate_lines(text, 5);
717        assert_eq!(result, text);
718        assert_eq!(remaining, 0);
719    }
720
721    #[test]
722    fn test_truncate_lines_with_truncation() {
723        let text = "line1\nline2\nline3\nline4\nline5";
724        let (result, remaining) = truncate_lines(text, 3);
725        assert_eq!(result, "line1\nline2\nline3");
726        assert_eq!(remaining, 2);
727    }
728
729    #[test]
730    fn test_truncate_lines_exact_limit() {
731        let text = "line1\nline2\nline3";
732        let (result, remaining) = truncate_lines(text, 3);
733        assert_eq!(result, text);
734        assert_eq!(remaining, 0);
735    }
736
737    // ===== format_params tests =====
738
739    #[test]
740    fn test_format_params_plain_simple() {
741        let params = serde_json::json!({"path": "/tmp/test.txt"});
742        let output = format_params_plain("read_file", &params);
743
744        assert!(output.starts_with("read_file"));
745        assert!(output.contains("path:"));
746        assert!(output.contains("/tmp/test.txt"));
747    }
748
749    #[test]
750    fn test_format_params_plain_many_params() {
751        // More than MAX_PARAMS (10) parameters
752        let mut obj = serde_json::Map::new();
753        for i in 0..15 {
754            obj.insert(format!("key{}", i), serde_json::json!(i));
755        }
756        let params = serde_json::Value::Object(obj);
757        let output = format_params_plain("test_tool", &params);
758
759        assert!(output.contains("… +"));
760        assert!(output.contains("more"));
761    }
762
763    #[test]
764    fn test_format_params_ansi_has_codes() {
765        let params = serde_json::json!({"name": "test"});
766        let output = format_params_ansi("my_tool", &params);
767
768        // Should contain ANSI escape codes
769        assert!(output.contains("\x1b["));
770        // Should contain tool name
771        assert!(output.contains("my_tool"));
772    }
773
774    #[test]
775    fn test_format_params_markdown_format() {
776        let params = serde_json::json!({"file": "test.rs"});
777        let output = format_params_markdown("edit", &params);
778
779        // Should have bold tool name
780        assert!(output.starts_with("**edit**"));
781        // Should have markdown list items
782        assert!(output.contains("- `file`:"));
783    }
784
785    // ===== format_result tests =====
786
787    #[test]
788    fn test_format_result_plain_short() {
789        let result = ToolResult::Text("Success!".to_string());
790        let output = format_result_plain(&result);
791        assert_eq!(output, "Success!");
792    }
793
794    #[test]
795    fn test_format_result_plain_truncated() {
796        // More than MAX_OUTPUT_LINES (12) lines
797        let long_text = (0..20)
798            .map(|i| format!("line {}", i))
799            .collect::<Vec<_>>()
800            .join("\n");
801        let result = ToolResult::Text(long_text);
802        let output = format_result_plain(&result);
803
804        assert!(output.contains("… +"));
805        assert!(output.contains("more lines"));
806    }
807
808    #[test]
809    fn test_format_result_ansi_success_marker() {
810        let result = ToolResult::Text("Done".to_string());
811        let output = format_result_ansi(&result);
812
813        // Should have green checkmark
814        assert!(output.contains("\x1b[32m✓\x1b[0m"));
815    }
816
817    #[test]
818    fn test_format_result_markdown_code_block() {
819        let result = ToolResult::Text("code here".to_string());
820        let output = format_result_markdown(&result);
821
822        assert!(output.starts_with("```\n"));
823        assert!(output.contains("code here"));
824        assert!(output.contains("\n```"));
825    }
826
827    #[test]
828    fn test_format_result_json() {
829        let result = ToolResult::Json(serde_json::json!({"status": "ok"}));
830        let output = format_result_plain(&result);
831
832        // JSON should be pretty-printed
833        assert!(output.contains("status"));
834        assert!(output.contains("ok"));
835    }
836
837    #[test]
838    fn test_format_result_image() {
839        let result = ToolResult::Image {
840            format: ImageFormat::Png,
841            data: vec![0u8; 1000],
842        };
843        let output = format_result_plain(&result);
844
845        assert!(output.contains("Image"));
846        assert!(output.contains("Png"));
847        assert!(output.contains("1000 bytes"));
848    }
849
850    #[test]
851    fn test_format_result_document() {
852        let result = ToolResult::Document {
853            format: DocumentFormat::Pdf,
854            data: vec![0u8; 500],
855            name: Some("report.pdf".to_string()),
856        };
857        let output = format_result_plain(&result);
858
859        assert!(output.contains("Document"));
860        assert!(output.contains("Pdf"));
861        assert!(output.contains("report.pdf"));
862        assert!(output.contains("500 bytes"));
863    }
864
865    #[test]
866    fn test_format_result_document_unnamed() {
867        let result = ToolResult::Document {
868            format: DocumentFormat::Txt,
869            data: vec![0u8; 100],
870            name: None,
871        };
872        let output = format_result_plain(&result);
873
874        assert!(output.contains("unnamed"));
875    }
876
877    // ===== ToolResult factory tests =====
878
879    #[test]
880    fn test_tool_result_image_factory() {
881        let result = ToolResult::image(ImageFormat::Jpeg, vec![1, 2, 3]);
882
883        if let ToolResult::Image { format, data } = result {
884            assert_eq!(format, ImageFormat::Jpeg);
885            assert_eq!(data, vec![1, 2, 3]);
886        } else {
887            panic!("Expected Image variant");
888        }
889    }
890
891    #[test]
892    fn test_tool_result_document_factory() {
893        let result = ToolResult::document(DocumentFormat::Csv, vec![4, 5, 6]);
894
895        if let ToolResult::Document { format, data, name } = result {
896            assert_eq!(format, DocumentFormat::Csv);
897            assert_eq!(data, vec![4, 5, 6]);
898            assert!(name.is_none());
899        } else {
900            panic!("Expected Document variant");
901        }
902    }
903
904    #[test]
905    fn test_tool_result_document_with_name_factory() {
906        let result = ToolResult::document_with_name(DocumentFormat::Html, vec![7, 8], "page.html");
907
908        if let ToolResult::Document { format, data, name } = result {
909            assert_eq!(format, DocumentFormat::Html);
910            assert_eq!(data, vec![7, 8]);
911            assert_eq!(name, Some("page.html".to_string()));
912        } else {
913            panic!("Expected Document variant");
914        }
915    }
916
917    // ===== ToolResult::as_text for binary types =====
918
919    #[test]
920    fn test_tool_result_as_text_image() {
921        let result = ToolResult::Image {
922            format: ImageFormat::Gif,
923            data: vec![0u8; 2000],
924        };
925        let text = result.as_text();
926
927        assert!(text.contains("Image"));
928        assert!(text.contains("Gif"));
929        assert!(text.contains("2000 bytes"));
930    }
931
932    #[test]
933    fn test_tool_result_as_text_document() {
934        let result = ToolResult::Document {
935            format: DocumentFormat::Xlsx,
936            data: vec![0u8; 3000],
937            name: Some("data.xlsx".to_string()),
938        };
939        let text = result.as_text();
940
941        assert!(text.contains("Document"));
942        assert!(text.contains("Xlsx"));
943        assert!(text.contains("data.xlsx"));
944        assert!(text.contains("3000 bytes"));
945    }
946
947    #[test]
948    fn test_tool_result_as_str_binary_types() {
949        let image = ToolResult::Image {
950            format: ImageFormat::Webp,
951            data: vec![],
952        };
953        assert!(image.as_str().is_none());
954
955        let doc = ToolResult::Document {
956            format: DocumentFormat::Doc,
957            data: vec![],
958            name: None,
959        };
960        assert!(doc.as_str().is_none());
961    }
962}