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(pad_char)
324                .take(width - char_count)
325                .collect();
326            format!("{}{}", padding, text)
327        };
328
329        let output = StringOutput { result };
330        self.to_result(&output)
331    }
332
333    fn handle_pad_right(&self, input: &TextInput) -> ToolResult {
334        let text = input.text.as_deref().unwrap_or("");
335        let width = input.width.unwrap_or(0);
336        let pad_char = input
337            .pad_char
338            .as_deref()
339            .and_then(|s| s.chars().next())
340            .unwrap_or(' ');
341
342        let char_count = text.chars().count();
343        let result = if char_count >= width {
344            text.to_string()
345        } else {
346            let padding: String = std::iter::repeat(pad_char)
347                .take(width - char_count)
348                .collect();
349            format!("{}{}", text, padding)
350        };
351
352        let output = StringOutput { result };
353        self.to_result(&output)
354    }
355
356    fn handle_truncate(&self, input: &TextInput) -> ToolResult {
357        let text = input.text.as_deref().unwrap_or("");
358        let width = input.width.unwrap_or(text.chars().count());
359        let suffix = input.suffix.as_deref().unwrap_or("...");
360
361        let chars: Vec<char> = text.chars().collect();
362        let result = if chars.len() <= width {
363            text.to_string()
364        } else {
365            let suffix_len = suffix.chars().count();
366            if width <= suffix_len {
367                chars[..width].iter().collect()
368            } else {
369                let truncated: String = chars[..(width - suffix_len)].iter().collect();
370                format!("{}{}", truncated, suffix)
371            }
372        };
373
374        let output = StringOutput { result };
375        self.to_result(&output)
376    }
377
378    fn handle_lines(&self, input: &TextInput) -> ToolResult {
379        let text = input.text.as_deref().unwrap_or("");
380        let lines: Vec<String> = text.lines().map(|s| s.to_string()).collect();
381
382        let output = LinesOutput {
383            count: lines.len(),
384            lines,
385        };
386        self.to_result(&output)
387    }
388
389    fn handle_words(&self, input: &TextInput) -> ToolResult {
390        let text = input.text.as_deref().unwrap_or("");
391        let words: Vec<String> = text.split_whitespace().map(|s| s.to_string()).collect();
392
393        let output = SplitOutput {
394            count: words.len(),
395            parts: words,
396        };
397        self.to_result(&output)
398    }
399
400    fn handle_char_at(&self, input: &TextInput) -> ToolResult {
401        let text = input.text.as_deref().unwrap_or("");
402        let index = input.index.unwrap_or(0);
403
404        let chars: Vec<char> = text.chars().collect();
405        let output = if index < chars.len() {
406            CharAtOutput {
407                char: Some(chars[index].to_string()),
408                found: true,
409            }
410        } else {
411            CharAtOutput {
412                char: None,
413                found: false,
414            }
415        };
416        self.to_result(&output)
417    }
418
419    fn handle_index_of(&self, input: &TextInput) -> ToolResult {
420        let text = input.text.as_deref().unwrap_or("");
421        let find = input.find.as_deref().unwrap_or("");
422
423        let output = match text.find(find) {
424            Some(byte_index) => {
425                let char_index = text[..byte_index].chars().count();
426                IndexOfOutput {
427                    index: Some(char_index),
428                    found: true,
429                }
430            }
431            None => IndexOfOutput {
432                index: None,
433                found: false,
434            },
435        };
436        self.to_result(&output)
437    }
438
439    fn to_result<T: Serialize>(&self, output: &T) -> ToolResult {
440        match serde_json::to_string(output) {
441            Ok(json) => ToolResult::ok(json),
442            Err(e) => ToolResult::error(format!("Serialization error: {}", e)),
443        }
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[tokio::test]
452    async fn test_length_unicode() {
453        let tool = TextTool::new();
454        let result = tool
455            .execute(
456                serde_json::json!({
457                    "operation": "length",
458                    "text": "안녕하세요"
459                }),
460                ai_agents_core::ToolExecutionContext::test("test"),
461            )
462            .await;
463        assert!(result.success);
464        let output: LengthOutput = serde_json::from_str(&result.output).unwrap();
465        assert_eq!(output.length, 5);
466    }
467
468    #[tokio::test]
469    async fn test_substring() {
470        let tool = TextTool::new();
471        let result = tool
472            .execute(
473                serde_json::json!({
474                    "operation": "substring",
475                    "text": "hello world",
476                    "start": 0,
477                    "end": 5
478                }),
479                ai_agents_core::ToolExecutionContext::test("test"),
480            )
481            .await;
482        assert!(result.success);
483        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
484        assert_eq!(output.result, "hello");
485    }
486
487    #[tokio::test]
488    async fn test_uppercase_lowercase() {
489        let tool = TextTool::new();
490
491        let result = tool
492            .execute(
493                serde_json::json!({
494                    "operation": "uppercase",
495                    "text": "hello"
496                }),
497                ai_agents_core::ToolExecutionContext::test("test"),
498            )
499            .await;
500        assert!(result.success);
501        assert!(result.output.contains("HELLO"));
502
503        let result = tool
504            .execute(
505                serde_json::json!({
506                    "operation": "lowercase",
507                    "text": "HELLO"
508                }),
509                ai_agents_core::ToolExecutionContext::test("test"),
510            )
511            .await;
512        assert!(result.success);
513        assert!(result.output.contains("hello"));
514    }
515
516    #[tokio::test]
517    async fn test_trim() {
518        let tool = TextTool::new();
519        let result = tool
520            .execute(
521                serde_json::json!({
522                    "operation": "trim",
523                    "text": "  hello  "
524                }),
525                ai_agents_core::ToolExecutionContext::test("test"),
526            )
527            .await;
528        assert!(result.success);
529        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
530        assert_eq!(output.result, "hello");
531    }
532
533    #[tokio::test]
534    async fn test_split_join() {
535        let tool = TextTool::new();
536
537        let result = tool
538            .execute(
539                serde_json::json!({
540                    "operation": "split",
541                    "text": "a,b,c",
542                    "delimiter": ","
543                }),
544                ai_agents_core::ToolExecutionContext::test("test"),
545            )
546            .await;
547        assert!(result.success);
548        let output: SplitOutput = serde_json::from_str(&result.output).unwrap();
549        assert_eq!(output.parts, vec!["a", "b", "c"]);
550        assert_eq!(output.count, 3);
551
552        let result = tool
553            .execute(
554                serde_json::json!({
555                    "operation": "join",
556                    "items": ["a", "b", "c"],
557                    "delimiter": "-"
558                }),
559                ai_agents_core::ToolExecutionContext::test("test"),
560            )
561            .await;
562        assert!(result.success);
563        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
564        assert_eq!(output.result, "a-b-c");
565    }
566
567    #[tokio::test]
568    async fn test_replace() {
569        let tool = TextTool::new();
570        let result = tool
571            .execute(
572                serde_json::json!({
573                    "operation": "replace",
574                    "text": "hello world",
575                    "find": "world",
576                    "replace_with": "rust"
577                }),
578                ai_agents_core::ToolExecutionContext::test("test"),
579            )
580            .await;
581        assert!(result.success);
582        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
583        assert_eq!(output.result, "hello rust");
584    }
585
586    #[tokio::test]
587    async fn test_contains() {
588        let tool = TextTool::new();
589        let result = tool
590            .execute(
591                serde_json::json!({
592                    "operation": "contains",
593                    "text": "hello world",
594                    "find": "world"
595                }),
596                ai_agents_core::ToolExecutionContext::test("test"),
597            )
598            .await;
599        assert!(result.success);
600        let output: BoolOutput = serde_json::from_str(&result.output).unwrap();
601        assert!(output.result);
602    }
603
604    #[tokio::test]
605    async fn test_repeat() {
606        let tool = TextTool::new();
607        let result = tool
608            .execute(
609                serde_json::json!({
610                    "operation": "repeat",
611                    "text": "ab",
612                    "count": 3
613                }),
614                ai_agents_core::ToolExecutionContext::test("test"),
615            )
616            .await;
617        assert!(result.success);
618        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
619        assert_eq!(output.result, "ababab");
620    }
621
622    #[tokio::test]
623    async fn test_reverse() {
624        let tool = TextTool::new();
625        let result = tool
626            .execute(
627                serde_json::json!({
628                    "operation": "reverse",
629                    "text": "hello"
630                }),
631                ai_agents_core::ToolExecutionContext::test("test"),
632            )
633            .await;
634        assert!(result.success);
635        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
636        assert_eq!(output.result, "olleh");
637    }
638
639    #[tokio::test]
640    async fn test_pad() {
641        let tool = TextTool::new();
642
643        let result = tool
644            .execute(
645                serde_json::json!({
646                    "operation": "pad_left",
647                    "text": "5",
648                    "width": 3,
649                    "pad_char": "0"
650                }),
651                ai_agents_core::ToolExecutionContext::test("test"),
652            )
653            .await;
654        assert!(result.success);
655        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
656        assert_eq!(output.result, "005");
657
658        let result = tool
659            .execute(
660                serde_json::json!({
661                    "operation": "pad_right",
662                    "text": "hi",
663                    "width": 5
664                }),
665                ai_agents_core::ToolExecutionContext::test("test"),
666            )
667            .await;
668        assert!(result.success);
669        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
670        assert_eq!(output.result, "hi   ");
671    }
672
673    #[tokio::test]
674    async fn test_truncate() {
675        let tool = TextTool::new();
676        let result = tool
677            .execute(
678                serde_json::json!({
679                    "operation": "truncate",
680                    "text": "hello world",
681                    "width": 8
682                }),
683                ai_agents_core::ToolExecutionContext::test("test"),
684            )
685            .await;
686        assert!(result.success);
687        let output: StringOutput = serde_json::from_str(&result.output).unwrap();
688        assert_eq!(output.result, "hello...");
689    }
690
691    #[tokio::test]
692    async fn test_lines() {
693        let tool = TextTool::new();
694        let result = tool
695            .execute(
696                serde_json::json!({
697                    "operation": "lines",
698                    "text": "line1\nline2\nline3"
699                }),
700                ai_agents_core::ToolExecutionContext::test("test"),
701            )
702            .await;
703        assert!(result.success);
704        let output: LinesOutput = serde_json::from_str(&result.output).unwrap();
705        assert_eq!(output.count, 3);
706    }
707
708    #[tokio::test]
709    async fn test_words() {
710        let tool = TextTool::new();
711        let result = tool
712            .execute(
713                serde_json::json!({
714                    "operation": "words",
715                    "text": "hello  world   test"
716                }),
717                ai_agents_core::ToolExecutionContext::test("test"),
718            )
719            .await;
720        assert!(result.success);
721        let output: SplitOutput = serde_json::from_str(&result.output).unwrap();
722        assert_eq!(output.count, 3);
723        assert_eq!(output.parts, vec!["hello", "world", "test"]);
724    }
725
726    #[tokio::test]
727    async fn test_invalid_operation() {
728        let tool = TextTool::new();
729        let result = tool
730            .execute(
731                serde_json::json!({
732                    "operation": "invalid"
733                }),
734                ai_agents_core::ToolExecutionContext::test("test"),
735            )
736            .await;
737        assert!(!result.success);
738    }
739}