Skip to main content

ai_agents_tools/builtin/
text.rs

1use async_trait::async_trait;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::generate_schema;
7use ai_agents_core::{Tool, ToolResult, ToolSafetyMetadata};
8
9pub struct TextTool;
10
11impl TextTool {
12    pub fn new() -> Self {
13        Self
14    }
15}
16
17impl Default for TextTool {
18    fn default() -> Self {
19        Self::new()
20    }
21}
22
23#[derive(Debug, Deserialize, JsonSchema)]
24struct TextInput {
25    /// Operation: length, substring, uppercase, lowercase, trim, trim_start, trim_end, replace, split, join, contains, starts_with, ends_with, repeat, reverse, pad_left, pad_right, truncate, lines, words, char_at, index_of
26    operation: String,
27    /// Input text
28    #[serde(default)]
29    text: Option<String>,
30    /// Start index (for substring)
31    #[serde(default)]
32    start: Option<usize>,
33    /// End index (for substring)
34    #[serde(default)]
35    end: Option<usize>,
36    /// Text to find (for replace/contains/index_of)
37    #[serde(default)]
38    find: Option<String>,
39    /// Replacement text
40    #[serde(default)]
41    replace_with: Option<String>,
42    /// Delimiter (for split/join)
43    #[serde(default)]
44    delimiter: Option<String>,
45    /// Items to join
46    #[serde(default)]
47    items: Option<Vec<String>>,
48    /// Repeat count
49    #[serde(default)]
50    count: Option<usize>,
51    /// Target width (for padding/truncate)
52    #[serde(default)]
53    width: Option<usize>,
54    /// Padding character
55    #[serde(default)]
56    pad_char: Option<String>,
57    /// Index position
58    #[serde(default)]
59    index: Option<usize>,
60    /// Suffix for truncation
61    #[serde(default)]
62    suffix: Option<String>,
63}
64
65#[derive(Debug, Serialize, Deserialize)]
66struct LengthOutput {
67    length: usize,
68    bytes: usize,
69}
70
71#[derive(Debug, Serialize, Deserialize)]
72struct StringOutput {
73    result: String,
74}
75
76#[derive(Debug, Serialize, Deserialize)]
77struct BoolOutput {
78    result: bool,
79}
80
81#[derive(Debug, Serialize, Deserialize)]
82struct SplitOutput {
83    parts: Vec<String>,
84    count: usize,
85}
86
87#[derive(Debug, Serialize, Deserialize)]
88struct CharAtOutput {
89    char: Option<String>,
90    found: bool,
91}
92
93#[derive(Debug, Serialize, Deserialize)]
94struct IndexOfOutput {
95    index: Option<usize>,
96    found: bool,
97}
98
99#[derive(Debug, Serialize, Deserialize)]
100struct LinesOutput {
101    lines: Vec<String>,
102    count: usize,
103}
104
105#[async_trait]
106impl Tool for TextTool {
107    fn id(&self) -> &str {
108        "text"
109    }
110
111    fn name(&self) -> &str {
112        "Text Manipulation"
113    }
114
115    fn description(&self) -> &str {
116        "String operations: length (character count), substring, uppercase, lowercase, trim, trim_start, trim_end, replace, split, join, contains, starts_with, ends_with, repeat, reverse, pad_left, pad_right, truncate, lines, words, char_at, index_of. Works with all Unicode text."
117    }
118
119    fn input_schema(&self) -> Value {
120        generate_schema::<TextInput>()
121    }
122
123    fn safety_metadata(&self) -> ToolSafetyMetadata {
124        ToolSafetyMetadata::compute()
125    }
126
127    async fn execute(&self, args: Value, _ctx: ai_agents_core::ToolExecutionContext) -> ToolResult {
128        let input: TextInput = match serde_json::from_value(args) {
129            Ok(input) => input,
130            Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
131        };
132
133        match input.operation.to_lowercase().as_str() {
134            "length" | "len" => self.handle_length(&input),
135            "substring" | "substr" | "slice" => self.handle_substring(&input),
136            "uppercase" | "upper" => self.handle_uppercase(&input),
137            "lowercase" | "lower" => self.handle_lowercase(&input),
138            "trim" => self.handle_trim(&input),
139            "trim_start" | "ltrim" => self.handle_trim_start(&input),
140            "trim_end" | "rtrim" => self.handle_trim_end(&input),
141            "replace" => self.handle_replace(&input),
142            "split" => self.handle_split(&input),
143            "join" => self.handle_join(&input),
144            "contains" | "includes" => self.handle_contains(&input),
145            "starts_with" => self.handle_starts_with(&input),
146            "ends_with" => self.handle_ends_with(&input),
147            "repeat" => self.handle_repeat(&input),
148            "reverse" => self.handle_reverse(&input),
149            "pad_left" | "lpad" => self.handle_pad_left(&input),
150            "pad_right" | "rpad" => self.handle_pad_right(&input),
151            "truncate" => self.handle_truncate(&input),
152            "lines" => self.handle_lines(&input),
153            "words" => self.handle_words(&input),
154            "char_at" => self.handle_char_at(&input),
155            "index_of" | "find" => self.handle_index_of(&input),
156            _ => ToolResult::error(format!(
157                "Unknown operation: {}. Valid: length, substring, uppercase, lowercase, trim, replace, split, join, contains, starts_with, ends_with, repeat, reverse, pad_left, pad_right, truncate, lines, words, char_at, index_of",
158                input.operation
159            )),
160        }
161    }
162}
163
164impl TextTool {
165    fn handle_length(&self, input: &TextInput) -> ToolResult {
166        let text = input.text.as_deref().unwrap_or("");
167        let output = LengthOutput {
168            length: text.chars().count(),
169            bytes: text.len(),
170        };
171        self.to_result(&output)
172    }
173
174    fn handle_substring(&self, input: &TextInput) -> ToolResult {
175        let text = input.text.as_deref().unwrap_or("");
176        let chars: Vec<char> = text.chars().collect();
177        let start = input.start.unwrap_or(0);
178        let end = input.end.unwrap_or(chars.len());
179
180        let start = start.min(chars.len());
181        let end = end.min(chars.len());
182
183        let result: String = chars[start..end].iter().collect();
184        let output = StringOutput { result };
185        self.to_result(&output)
186    }
187
188    fn handle_uppercase(&self, input: &TextInput) -> ToolResult {
189        let text = input.text.as_deref().unwrap_or("");
190        let output = StringOutput {
191            result: text.to_uppercase(),
192        };
193        self.to_result(&output)
194    }
195
196    fn handle_lowercase(&self, input: &TextInput) -> ToolResult {
197        let text = input.text.as_deref().unwrap_or("");
198        let output = StringOutput {
199            result: text.to_lowercase(),
200        };
201        self.to_result(&output)
202    }
203
204    fn handle_trim(&self, input: &TextInput) -> ToolResult {
205        let text = input.text.as_deref().unwrap_or("");
206        let output = StringOutput {
207            result: text.trim().to_string(),
208        };
209        self.to_result(&output)
210    }
211
212    fn handle_trim_start(&self, input: &TextInput) -> ToolResult {
213        let text = input.text.as_deref().unwrap_or("");
214        let output = StringOutput {
215            result: text.trim_start().to_string(),
216        };
217        self.to_result(&output)
218    }
219
220    fn handle_trim_end(&self, input: &TextInput) -> ToolResult {
221        let text = input.text.as_deref().unwrap_or("");
222        let output = StringOutput {
223            result: text.trim_end().to_string(),
224        };
225        self.to_result(&output)
226    }
227
228    fn handle_replace(&self, input: &TextInput) -> ToolResult {
229        let text = input.text.as_deref().unwrap_or("");
230        let find = input.find.as_deref().unwrap_or("");
231        let replace_with = input.replace_with.as_deref().unwrap_or("");
232
233        let output = StringOutput {
234            result: text.replace(find, replace_with),
235        };
236        self.to_result(&output)
237    }
238
239    fn handle_split(&self, input: &TextInput) -> ToolResult {
240        let text = input.text.as_deref().unwrap_or("");
241        let delimiter = input.delimiter.as_deref().unwrap_or(" ");
242
243        let parts: Vec<String> = text.split(delimiter).map(|s| s.to_string()).collect();
244        let output = SplitOutput {
245            count: parts.len(),
246            parts,
247        };
248        self.to_result(&output)
249    }
250
251    fn handle_join(&self, input: &TextInput) -> ToolResult {
252        let items = input.items.as_deref().unwrap_or(&[]);
253        let delimiter = input.delimiter.as_deref().unwrap_or("");
254
255        let output = StringOutput {
256            result: items.join(delimiter),
257        };
258        self.to_result(&output)
259    }
260
261    fn handle_contains(&self, input: &TextInput) -> ToolResult {
262        let text = input.text.as_deref().unwrap_or("");
263        let find = input.find.as_deref().unwrap_or("");
264
265        let output = BoolOutput {
266            result: text.contains(find),
267        };
268        self.to_result(&output)
269    }
270
271    fn handle_starts_with(&self, input: &TextInput) -> ToolResult {
272        let text = input.text.as_deref().unwrap_or("");
273        let find = input.find.as_deref().unwrap_or("");
274
275        let output = BoolOutput {
276            result: text.starts_with(find),
277        };
278        self.to_result(&output)
279    }
280
281    fn handle_ends_with(&self, input: &TextInput) -> ToolResult {
282        let text = input.text.as_deref().unwrap_or("");
283        let find = input.find.as_deref().unwrap_or("");
284
285        let output = BoolOutput {
286            result: text.ends_with(find),
287        };
288        self.to_result(&output)
289    }
290
291    fn handle_repeat(&self, input: &TextInput) -> ToolResult {
292        let text = input.text.as_deref().unwrap_or("");
293        let count = input.count.unwrap_or(1);
294
295        let output = StringOutput {
296            result: text.repeat(count),
297        };
298        self.to_result(&output)
299    }
300
301    fn handle_reverse(&self, input: &TextInput) -> ToolResult {
302        let text = input.text.as_deref().unwrap_or("");
303
304        let output = StringOutput {
305            result: text.chars().rev().collect(),
306        };
307        self.to_result(&output)
308    }
309
310    fn handle_pad_left(&self, input: &TextInput) -> ToolResult {
311        let text = input.text.as_deref().unwrap_or("");
312        let width = input.width.unwrap_or(0);
313        let pad_char = input
314            .pad_char
315            .as_deref()
316            .and_then(|s| s.chars().next())
317            .unwrap_or(' ');
318
319        let char_count = text.chars().count();
320        let result = if char_count >= width {
321            text.to_string()
322        } else {
323            let padding: String = std::iter::repeat_n(pad_char, width - char_count).collect();
324            format!("{}{}", padding, text)
325        };
326
327        let output = StringOutput { result };
328        self.to_result(&output)
329    }
330
331    fn handle_pad_right(&self, input: &TextInput) -> ToolResult {
332        let text = input.text.as_deref().unwrap_or("");
333        let width = input.width.unwrap_or(0);
334        let pad_char = input
335            .pad_char
336            .as_deref()
337            .and_then(|s| s.chars().next())
338            .unwrap_or(' ');
339
340        let char_count = text.chars().count();
341        let result = if char_count >= width {
342            text.to_string()
343        } else {
344            let padding: String = std::iter::repeat_n(pad_char, width - char_count).collect();
345            format!("{}{}", text, padding)
346        };
347
348        let output = StringOutput { result };
349        self.to_result(&output)
350    }
351
352    fn handle_truncate(&self, input: &TextInput) -> ToolResult {
353        let text = input.text.as_deref().unwrap_or("");
354        let width = input.width.unwrap_or(text.chars().count());
355        let suffix = input.suffix.as_deref().unwrap_or("...");
356
357        let chars: Vec<char> = text.chars().collect();
358        let result = if chars.len() <= width {
359            text.to_string()
360        } else {
361            let suffix_len = suffix.chars().count();
362            if width <= suffix_len {
363                chars[..width].iter().collect()
364            } else {
365                let truncated: String = chars[..(width - suffix_len)].iter().collect();
366                format!("{}{}", truncated, suffix)
367            }
368        };
369
370        let output = StringOutput { result };
371        self.to_result(&output)
372    }
373
374    fn handle_lines(&self, input: &TextInput) -> ToolResult {
375        let text = input.text.as_deref().unwrap_or("");
376        let lines: Vec<String> = text.lines().map(|s| s.to_string()).collect();
377
378        let output = LinesOutput {
379            count: lines.len(),
380            lines,
381        };
382        self.to_result(&output)
383    }
384
385    fn handle_words(&self, input: &TextInput) -> ToolResult {
386        let text = input.text.as_deref().unwrap_or("");
387        let words: Vec<String> = text.split_whitespace().map(|s| s.to_string()).collect();
388
389        let output = SplitOutput {
390            count: words.len(),
391            parts: words,
392        };
393        self.to_result(&output)
394    }
395
396    fn handle_char_at(&self, input: &TextInput) -> ToolResult {
397        let text = input.text.as_deref().unwrap_or("");
398        let index = input.index.unwrap_or(0);
399
400        let chars: Vec<char> = text.chars().collect();
401        let output = if index < chars.len() {
402            CharAtOutput {
403                char: Some(chars[index].to_string()),
404                found: true,
405            }
406        } else {
407            CharAtOutput {
408                char: None,
409                found: false,
410            }
411        };
412        self.to_result(&output)
413    }
414
415    fn handle_index_of(&self, input: &TextInput) -> ToolResult {
416        let text = input.text.as_deref().unwrap_or("");
417        let find = input.find.as_deref().unwrap_or("");
418
419        let output = match text.find(find) {
420            Some(byte_index) => {
421                let char_index = text[..byte_index].chars().count();
422                IndexOfOutput {
423                    index: Some(char_index),
424                    found: true,
425                }
426            }
427            None => IndexOfOutput {
428                index: None,
429                found: false,
430            },
431        };
432        self.to_result(&output)
433    }
434
435    fn to_result<T: Serialize>(&self, output: &T) -> ToolResult {
436        match serde_json::to_string(output) {
437            Ok(json) => ToolResult::ok(json),
438            Err(e) => ToolResult::error(format!("Serialization error: {}", e)),
439        }
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[tokio::test]
448    async fn test_length_unicode() {
449        let tool = TextTool::new();
450        let result = tool
451            .execute(
452                serde_json::json!({
453                    "operation": "length",
454                    "text": "안녕하세요"
455                }),
456                ai_agents_core::ToolExecutionContext::test("test"),
457            )
458            .await;
459        assert!(result.success);
460        let output: LengthOutput = serde_json::from_str(&result.output).unwrap();
461        assert_eq!(output.length, 5);
462    }
463
464    #[tokio::test]
465    async fn test_substring() {
466        let tool = TextTool::new();
467        let result = tool
468            .execute(
469                serde_json::json!({
470                    "operation": "substring",
471                    "text": "hello world",
472                    "start": 0,
473                    "end": 5
474                }),
475                ai_agents_core::ToolExecutionContext::test("test"),
476            )
477            .await;
478        assert!(result.success);
479        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
480        assert_eq!(output.result, "hello");
481    }
482
483    #[tokio::test]
484    async fn test_uppercase_lowercase() {
485        let tool = TextTool::new();
486
487        let result = tool
488            .execute(
489                serde_json::json!({
490                    "operation": "uppercase",
491                    "text": "hello"
492                }),
493                ai_agents_core::ToolExecutionContext::test("test"),
494            )
495            .await;
496        assert!(result.success);
497        assert!(result.output.contains("HELLO"));
498
499        let result = tool
500            .execute(
501                serde_json::json!({
502                    "operation": "lowercase",
503                    "text": "HELLO"
504                }),
505                ai_agents_core::ToolExecutionContext::test("test"),
506            )
507            .await;
508        assert!(result.success);
509        assert!(result.output.contains("hello"));
510    }
511
512    #[tokio::test]
513    async fn test_trim() {
514        let tool = TextTool::new();
515        let result = tool
516            .execute(
517                serde_json::json!({
518                    "operation": "trim",
519                    "text": "  hello  "
520                }),
521                ai_agents_core::ToolExecutionContext::test("test"),
522            )
523            .await;
524        assert!(result.success);
525        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
526        assert_eq!(output.result, "hello");
527    }
528
529    #[tokio::test]
530    async fn test_split_join() {
531        let tool = TextTool::new();
532
533        let result = tool
534            .execute(
535                serde_json::json!({
536                    "operation": "split",
537                    "text": "a,b,c",
538                    "delimiter": ","
539                }),
540                ai_agents_core::ToolExecutionContext::test("test"),
541            )
542            .await;
543        assert!(result.success);
544        let output: SplitOutput = serde_json::from_str(&result.output).unwrap();
545        assert_eq!(output.parts, vec!["a", "b", "c"]);
546        assert_eq!(output.count, 3);
547
548        let result = tool
549            .execute(
550                serde_json::json!({
551                    "operation": "join",
552                    "items": ["a", "b", "c"],
553                    "delimiter": "-"
554                }),
555                ai_agents_core::ToolExecutionContext::test("test"),
556            )
557            .await;
558        assert!(result.success);
559        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
560        assert_eq!(output.result, "a-b-c");
561    }
562
563    #[tokio::test]
564    async fn test_replace() {
565        let tool = TextTool::new();
566        let result = tool
567            .execute(
568                serde_json::json!({
569                    "operation": "replace",
570                    "text": "hello world",
571                    "find": "world",
572                    "replace_with": "rust"
573                }),
574                ai_agents_core::ToolExecutionContext::test("test"),
575            )
576            .await;
577        assert!(result.success);
578        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
579        assert_eq!(output.result, "hello rust");
580    }
581
582    #[tokio::test]
583    async fn test_contains() {
584        let tool = TextTool::new();
585        let result = tool
586            .execute(
587                serde_json::json!({
588                    "operation": "contains",
589                    "text": "hello world",
590                    "find": "world"
591                }),
592                ai_agents_core::ToolExecutionContext::test("test"),
593            )
594            .await;
595        assert!(result.success);
596        let output: BoolOutput = serde_json::from_str(&result.output).unwrap();
597        assert!(output.result);
598    }
599
600    #[tokio::test]
601    async fn test_repeat() {
602        let tool = TextTool::new();
603        let result = tool
604            .execute(
605                serde_json::json!({
606                    "operation": "repeat",
607                    "text": "ab",
608                    "count": 3
609                }),
610                ai_agents_core::ToolExecutionContext::test("test"),
611            )
612            .await;
613        assert!(result.success);
614        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
615        assert_eq!(output.result, "ababab");
616    }
617
618    #[tokio::test]
619    async fn test_reverse() {
620        let tool = TextTool::new();
621        let result = tool
622            .execute(
623                serde_json::json!({
624                    "operation": "reverse",
625                    "text": "hello"
626                }),
627                ai_agents_core::ToolExecutionContext::test("test"),
628            )
629            .await;
630        assert!(result.success);
631        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
632        assert_eq!(output.result, "olleh");
633    }
634
635    #[tokio::test]
636    async fn test_pad() {
637        let tool = TextTool::new();
638
639        let result = tool
640            .execute(
641                serde_json::json!({
642                    "operation": "pad_left",
643                    "text": "5",
644                    "width": 3,
645                    "pad_char": "0"
646                }),
647                ai_agents_core::ToolExecutionContext::test("test"),
648            )
649            .await;
650        assert!(result.success);
651        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
652        assert_eq!(output.result, "005");
653
654        let result = tool
655            .execute(
656                serde_json::json!({
657                    "operation": "pad_right",
658                    "text": "hi",
659                    "width": 5
660                }),
661                ai_agents_core::ToolExecutionContext::test("test"),
662            )
663            .await;
664        assert!(result.success);
665        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
666        assert_eq!(output.result, "hi   ");
667    }
668
669    #[tokio::test]
670    async fn test_truncate() {
671        let tool = TextTool::new();
672        let result = tool
673            .execute(
674                serde_json::json!({
675                    "operation": "truncate",
676                    "text": "hello world",
677                    "width": 8
678                }),
679                ai_agents_core::ToolExecutionContext::test("test"),
680            )
681            .await;
682        assert!(result.success);
683        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
684        assert_eq!(output.result, "hello...");
685    }
686
687    #[tokio::test]
688    async fn test_lines() {
689        let tool = TextTool::new();
690        let result = tool
691            .execute(
692                serde_json::json!({
693                    "operation": "lines",
694                    "text": "line1\nline2\nline3"
695                }),
696                ai_agents_core::ToolExecutionContext::test("test"),
697            )
698            .await;
699        assert!(result.success);
700        let output: LinesOutput = serde_json::from_str(&result.output).unwrap();
701        assert_eq!(output.count, 3);
702    }
703
704    #[tokio::test]
705    async fn test_words() {
706        let tool = TextTool::new();
707        let result = tool
708            .execute(
709                serde_json::json!({
710                    "operation": "words",
711                    "text": "hello  world   test"
712                }),
713                ai_agents_core::ToolExecutionContext::test("test"),
714            )
715            .await;
716        assert!(result.success);
717        let output: SplitOutput = serde_json::from_str(&result.output).unwrap();
718        assert_eq!(output.count, 3);
719        assert_eq!(output.parts, vec!["hello", "world", "test"]);
720    }
721
722    #[tokio::test]
723    async fn test_invalid_operation() {
724        let tool = TextTool::new();
725        let result = tool
726            .execute(
727                serde_json::json!({
728                    "operation": "invalid"
729                }),
730                ai_agents_core::ToolExecutionContext::test("test"),
731            )
732            .await;
733        assert!(!result.success);
734    }
735}