Skip to main content

argentor_builtins/
text_transform.rs

1use argentor_core::{ArgentorResult, ToolCall, ToolResult};
2use argentor_skills::skill::{Skill, SkillDescriptor};
3use async_trait::async_trait;
4
5/// Text transformation skill providing a rich set of string manipulation
6/// operations for AI agents. Inspired by Semantic Kernel's TextPlugin,
7/// AutoGPT's text block, and LangChain text utilities.
8pub struct TextTransformSkill {
9    descriptor: SkillDescriptor,
10}
11
12impl TextTransformSkill {
13    /// Create a new `TextTransformSkill` instance.
14    pub fn new() -> Self {
15        Self {
16            descriptor: SkillDescriptor {
17                name: "text_transform".to_string(),
18                description: "Perform text manipulation operations: case conversion, trimming, \
19                    splitting, joining, replacing, padding, truncation, counting, searching, \
20                    and case-style conversions."
21                    .to_string(),
22                parameters_schema: serde_json::json!({
23                    "type": "object",
24                    "properties": {
25                        "operation": {
26                            "type": "string",
27                            "description": "The text operation to perform",
28                            "enum": [
29                                "uppercase", "lowercase", "title_case", "capitalize",
30                                "trim", "trim_start", "trim_end",
31                                "reverse", "slug",
32                                "split", "join", "replace",
33                                "pad_left", "pad_right",
34                                "truncate",
35                                "word_count", "char_count", "line_count",
36                                "repeat",
37                                "contains", "starts_with", "ends_with",
38                                "extract_between",
39                                "camel_case", "snake_case", "kebab_case"
40                            ]
41                        },
42                        "text": {
43                            "type": "string",
44                            "description": "The input text to transform"
45                        },
46                        "delimiter": {
47                            "type": "string",
48                            "description": "Delimiter for split/join operations"
49                        },
50                        "values": {
51                            "type": "array",
52                            "items": { "type": "string" },
53                            "description": "Array of strings for join operation"
54                        },
55                        "pattern": {
56                            "type": "string",
57                            "description": "Pattern to search for in replace operation"
58                        },
59                        "replacement": {
60                            "type": "string",
61                            "description": "Replacement string for replace operation"
62                        },
63                        "width": {
64                            "type": "integer",
65                            "description": "Target width for pad_left/pad_right"
66                        },
67                        "char": {
68                            "type": "string",
69                            "description": "Single padding character (default: space)"
70                        },
71                        "max_length": {
72                            "type": "integer",
73                            "description": "Maximum length for truncate operation"
74                        },
75                        "suffix": {
76                            "type": "string",
77                            "description": "Suffix appended when truncating (default: \"...\")"
78                        },
79                        "count": {
80                            "type": "integer",
81                            "description": "Repetition count for repeat operation (max 1000)"
82                        },
83                        "substring": {
84                            "type": "string",
85                            "description": "Substring to search for in contains operation"
86                        },
87                        "prefix": {
88                            "type": "string",
89                            "description": "Prefix to check in starts_with operation"
90                        },
91                        "start_marker": {
92                            "type": "string",
93                            "description": "Start marker for extract_between"
94                        },
95                        "end_marker": {
96                            "type": "string",
97                            "description": "End marker for extract_between"
98                        }
99                    },
100                    "required": ["operation"]
101                }),
102                required_capabilities: vec![],
103                requires_approval: false,
104            },
105        }
106    }
107}
108
109impl Default for TextTransformSkill {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115#[async_trait]
116impl Skill for TextTransformSkill {
117    fn descriptor(&self) -> &SkillDescriptor {
118        &self.descriptor
119    }
120
121    async fn execute(&self, call: ToolCall) -> ArgentorResult<ToolResult> {
122        let args = &call.arguments;
123
124        let operation = match args["operation"].as_str() {
125            Some(op) => op,
126            None => {
127                return Ok(ToolResult::error(
128                    &call.id,
129                    r#"{"error":"Missing required parameter: operation"}"#,
130                ));
131            }
132        };
133
134        let result = match operation {
135            "uppercase" => op_uppercase(args),
136            "lowercase" => op_lowercase(args),
137            "title_case" => op_title_case(args),
138            "capitalize" => op_capitalize(args),
139            "trim" => op_trim(args),
140            "trim_start" => op_trim_start(args),
141            "trim_end" => op_trim_end(args),
142            "reverse" => op_reverse(args),
143            "slug" => op_slug(args),
144            "split" => op_split(args),
145            "join" => op_join(args),
146            "replace" => op_replace(args),
147            "pad_left" => op_pad_left(args),
148            "pad_right" => op_pad_right(args),
149            "truncate" => op_truncate(args),
150            "word_count" => op_word_count(args),
151            "char_count" => op_char_count(args),
152            "line_count" => op_line_count(args),
153            "repeat" => op_repeat(args),
154            "contains" => op_contains(args),
155            "starts_with" => op_starts_with(args),
156            "ends_with" => op_ends_with(args),
157            "extract_between" => op_extract_between(args),
158            "camel_case" => op_camel_case(args),
159            "snake_case" => op_snake_case(args),
160            "kebab_case" => op_kebab_case(args),
161            _ => Err(format!("Unknown operation: {operation}")),
162        };
163
164        match result {
165            Ok(value) => Ok(ToolResult::success(
166                &call.id,
167                serde_json::json!({ "result": value }).to_string(),
168            )),
169            Err(e) => Ok(ToolResult::error(
170                &call.id,
171                serde_json::json!({ "error": e }).to_string(),
172            )),
173        }
174    }
175}
176
177// ---------------------------------------------------------------------------
178// Helper: extract a required "text" string from args
179// ---------------------------------------------------------------------------
180fn require_text(args: &serde_json::Value) -> Result<&str, String> {
181    args["text"]
182        .as_str()
183        .ok_or_else(|| "Missing required parameter: text".to_string())
184}
185
186// ---------------------------------------------------------------------------
187// Operations
188// ---------------------------------------------------------------------------
189
190fn op_uppercase(args: &serde_json::Value) -> Result<serde_json::Value, String> {
191    let text = require_text(args)?;
192    Ok(serde_json::Value::String(text.to_uppercase()))
193}
194
195fn op_lowercase(args: &serde_json::Value) -> Result<serde_json::Value, String> {
196    let text = require_text(args)?;
197    Ok(serde_json::Value::String(text.to_lowercase()))
198}
199
200fn op_title_case(args: &serde_json::Value) -> Result<serde_json::Value, String> {
201    let text = require_text(args)?;
202    let result = text
203        .split_whitespace()
204        .map(|word| {
205            let mut chars = word.chars();
206            match chars.next() {
207                Some(c) => {
208                    let upper: String = c.to_uppercase().collect();
209                    let lower: String = chars.as_str().to_lowercase();
210                    format!("{upper}{lower}")
211                }
212                None => String::new(),
213            }
214        })
215        .collect::<Vec<_>>()
216        .join(" ");
217    Ok(serde_json::Value::String(result))
218}
219
220fn op_capitalize(args: &serde_json::Value) -> Result<serde_json::Value, String> {
221    let text = require_text(args)?;
222    if text.is_empty() {
223        return Ok(serde_json::Value::String(String::new()));
224    }
225    let mut chars = text.chars();
226    let first: String = chars
227        .next()
228        .map(|c| c.to_uppercase().collect())
229        .unwrap_or_default();
230    let rest: String = chars.collect();
231    Ok(serde_json::Value::String(format!("{first}{rest}")))
232}
233
234fn op_trim(args: &serde_json::Value) -> Result<serde_json::Value, String> {
235    let text = require_text(args)?;
236    Ok(serde_json::Value::String(text.trim().to_string()))
237}
238
239fn op_trim_start(args: &serde_json::Value) -> Result<serde_json::Value, String> {
240    let text = require_text(args)?;
241    Ok(serde_json::Value::String(text.trim_start().to_string()))
242}
243
244fn op_trim_end(args: &serde_json::Value) -> Result<serde_json::Value, String> {
245    let text = require_text(args)?;
246    Ok(serde_json::Value::String(text.trim_end().to_string()))
247}
248
249fn op_reverse(args: &serde_json::Value) -> Result<serde_json::Value, String> {
250    let text = require_text(args)?;
251    let reversed: String = text.chars().rev().collect();
252    Ok(serde_json::Value::String(reversed))
253}
254
255fn op_slug(args: &serde_json::Value) -> Result<serde_json::Value, String> {
256    let text = require_text(args)?;
257    let slug: String = text
258        .to_lowercase()
259        .chars()
260        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
261        .collect();
262    // Collapse consecutive hyphens and trim leading/trailing hyphens.
263    let collapsed = collapse_hyphens(&slug);
264    Ok(serde_json::Value::String(collapsed))
265}
266
267fn collapse_hyphens(s: &str) -> String {
268    let mut result = String::with_capacity(s.len());
269    let mut prev_hyphen = false;
270    for c in s.chars() {
271        if c == '-' {
272            if !prev_hyphen {
273                result.push('-');
274            }
275            prev_hyphen = true;
276        } else {
277            prev_hyphen = false;
278            result.push(c);
279        }
280    }
281    result.trim_matches('-').to_string()
282}
283
284fn op_split(args: &serde_json::Value) -> Result<serde_json::Value, String> {
285    let text = require_text(args)?;
286    let delimiter = args["delimiter"].as_str().unwrap_or(",");
287    let parts: Vec<serde_json::Value> = text
288        .split(delimiter)
289        .map(|s| serde_json::Value::String(s.to_string()))
290        .collect();
291    Ok(serde_json::Value::Array(parts))
292}
293
294fn op_join(args: &serde_json::Value) -> Result<serde_json::Value, String> {
295    let values = args["values"]
296        .as_array()
297        .ok_or_else(|| "Missing required parameter: values (array)".to_string())?;
298    let delimiter = args["delimiter"].as_str().unwrap_or(",");
299    let strings: Vec<&str> = values.iter().filter_map(|v| v.as_str()).collect();
300    Ok(serde_json::Value::String(strings.join(delimiter)))
301}
302
303fn op_replace(args: &serde_json::Value) -> Result<serde_json::Value, String> {
304    let text = require_text(args)?;
305    let pattern = args["pattern"]
306        .as_str()
307        .ok_or_else(|| "Missing required parameter: pattern".to_string())?;
308    let replacement = args["replacement"].as_str().unwrap_or("");
309    Ok(serde_json::Value::String(
310        text.replace(pattern, replacement),
311    ))
312}
313
314fn op_pad_left(args: &serde_json::Value) -> Result<serde_json::Value, String> {
315    let text = require_text(args)?;
316    let width = args["width"]
317        .as_u64()
318        .ok_or_else(|| "Missing required parameter: width".to_string())? as usize;
319    let pad_char = extract_pad_char(args)?;
320    let current_len = text.chars().count();
321    if current_len >= width {
322        return Ok(serde_json::Value::String(text.to_string()));
323    }
324    let padding: String = std::iter::repeat(pad_char)
325        .take(width - current_len)
326        .collect();
327    Ok(serde_json::Value::String(format!("{padding}{text}")))
328}
329
330fn op_pad_right(args: &serde_json::Value) -> Result<serde_json::Value, String> {
331    let text = require_text(args)?;
332    let width = args["width"]
333        .as_u64()
334        .ok_or_else(|| "Missing required parameter: width".to_string())? as usize;
335    let pad_char = extract_pad_char(args)?;
336    let current_len = text.chars().count();
337    if current_len >= width {
338        return Ok(serde_json::Value::String(text.to_string()));
339    }
340    let padding: String = std::iter::repeat(pad_char)
341        .take(width - current_len)
342        .collect();
343    Ok(serde_json::Value::String(format!("{text}{padding}")))
344}
345
346fn extract_pad_char(args: &serde_json::Value) -> Result<char, String> {
347    match args["char"].as_str() {
348        Some(s) => {
349            let mut chars = s.chars();
350            match (chars.next(), chars.next()) {
351                (Some(c), None) => Ok(c),
352                _ => Err("Parameter 'char' must be a single character".to_string()),
353            }
354        }
355        None => Ok(' '),
356    }
357}
358
359fn op_truncate(args: &serde_json::Value) -> Result<serde_json::Value, String> {
360    let text = require_text(args)?;
361    let max_length = args["max_length"]
362        .as_u64()
363        .ok_or_else(|| "Missing required parameter: max_length".to_string())?
364        as usize;
365    let suffix = args["suffix"].as_str().unwrap_or("...");
366
367    let char_count = text.chars().count();
368    if char_count <= max_length {
369        return Ok(serde_json::Value::String(text.to_string()));
370    }
371
372    let suffix_len = suffix.chars().count();
373    if max_length <= suffix_len {
374        // Not enough room for even the suffix; just truncate hard.
375        let truncated: String = text.chars().take(max_length).collect();
376        return Ok(serde_json::Value::String(truncated));
377    }
378
379    let keep = max_length - suffix_len;
380    let truncated: String = text.chars().take(keep).collect();
381    Ok(serde_json::Value::String(format!("{truncated}{suffix}")))
382}
383
384fn op_word_count(args: &serde_json::Value) -> Result<serde_json::Value, String> {
385    let text = require_text(args)?;
386    let count = text.split_whitespace().count();
387    Ok(serde_json::json!(count))
388}
389
390fn op_char_count(args: &serde_json::Value) -> Result<serde_json::Value, String> {
391    let text = require_text(args)?;
392    let count = text.chars().count();
393    Ok(serde_json::json!(count))
394}
395
396fn op_line_count(args: &serde_json::Value) -> Result<serde_json::Value, String> {
397    let text = require_text(args)?;
398    let count = if text.is_empty() {
399        0
400    } else {
401        text.lines().count()
402    };
403    Ok(serde_json::json!(count))
404}
405
406fn op_repeat(args: &serde_json::Value) -> Result<serde_json::Value, String> {
407    let text = require_text(args)?;
408    let count = args["count"]
409        .as_u64()
410        .ok_or_else(|| "Missing required parameter: count".to_string())?;
411    if count > 1000 {
412        return Err("count must not exceed 1000".to_string());
413    }
414    Ok(serde_json::Value::String(text.repeat(count as usize)))
415}
416
417fn op_contains(args: &serde_json::Value) -> Result<serde_json::Value, String> {
418    let text = require_text(args)?;
419    let substring = args["substring"]
420        .as_str()
421        .ok_or_else(|| "Missing required parameter: substring".to_string())?;
422    Ok(serde_json::Value::Bool(text.contains(substring)))
423}
424
425fn op_starts_with(args: &serde_json::Value) -> Result<serde_json::Value, String> {
426    let text = require_text(args)?;
427    let prefix = args["prefix"]
428        .as_str()
429        .ok_or_else(|| "Missing required parameter: prefix".to_string())?;
430    Ok(serde_json::Value::Bool(text.starts_with(prefix)))
431}
432
433fn op_ends_with(args: &serde_json::Value) -> Result<serde_json::Value, String> {
434    let text = require_text(args)?;
435    let suffix = args["suffix"]
436        .as_str()
437        .ok_or_else(|| "Missing required parameter: suffix".to_string())?;
438    Ok(serde_json::Value::Bool(text.ends_with(suffix)))
439}
440
441fn op_extract_between(args: &serde_json::Value) -> Result<serde_json::Value, String> {
442    let text = require_text(args)?;
443    let start_marker = args["start_marker"]
444        .as_str()
445        .ok_or_else(|| "Missing required parameter: start_marker".to_string())?;
446    let end_marker = args["end_marker"]
447        .as_str()
448        .ok_or_else(|| "Missing required parameter: end_marker".to_string())?;
449
450    let start_pos = text
451        .find(start_marker)
452        .ok_or_else(|| format!("Start marker '{start_marker}' not found in text"))?;
453    let after_start = start_pos + start_marker.len();
454    let end_pos = text[after_start..]
455        .find(end_marker)
456        .ok_or_else(|| format!("End marker '{end_marker}' not found after start marker"))?;
457
458    let extracted = &text[after_start..after_start + end_pos];
459    Ok(serde_json::Value::String(extracted.to_string()))
460}
461
462/// Split text into words by whitespace and non-alphanumeric boundaries.
463fn split_into_words(text: &str) -> Vec<String> {
464    let mut words = Vec::new();
465    let mut current = String::new();
466
467    for c in text.chars() {
468        if c.is_alphanumeric() {
469            current.push(c);
470        } else if !current.is_empty() {
471            words.push(current.clone());
472            current.clear();
473        }
474    }
475    if !current.is_empty() {
476        words.push(current);
477    }
478
479    // Further split on camelCase boundaries within each word.
480    let mut result = Vec::new();
481    for word in words {
482        let mut sub = String::new();
483        let chars: Vec<char> = word.chars().collect();
484        for i in 0..chars.len() {
485            if i > 0 && chars[i].is_uppercase() && chars[i - 1].is_lowercase() {
486                result.push(sub.clone());
487                sub.clear();
488            }
489            sub.push(chars[i]);
490        }
491        if !sub.is_empty() {
492            result.push(sub);
493        }
494    }
495
496    result
497}
498
499fn op_camel_case(args: &serde_json::Value) -> Result<serde_json::Value, String> {
500    let text = require_text(args)?;
501    let words = split_into_words(text);
502    let mut result = String::new();
503    for (i, word) in words.iter().enumerate() {
504        if i == 0 {
505            result.push_str(&word.to_lowercase());
506        } else {
507            let mut chars = word.chars();
508            if let Some(c) = chars.next() {
509                let upper: String = c.to_uppercase().collect();
510                result.push_str(&upper);
511                result.push_str(&chars.as_str().to_lowercase());
512            }
513        }
514    }
515    Ok(serde_json::Value::String(result))
516}
517
518fn op_snake_case(args: &serde_json::Value) -> Result<serde_json::Value, String> {
519    let text = require_text(args)?;
520    let words = split_into_words(text);
521    let result: String = words
522        .iter()
523        .map(|w| w.to_lowercase())
524        .collect::<Vec<_>>()
525        .join("_");
526    Ok(serde_json::Value::String(result))
527}
528
529fn op_kebab_case(args: &serde_json::Value) -> Result<serde_json::Value, String> {
530    let text = require_text(args)?;
531    let words = split_into_words(text);
532    let result: String = words
533        .iter()
534        .map(|w| w.to_lowercase())
535        .collect::<Vec<_>>()
536        .join("-");
537    Ok(serde_json::Value::String(result))
538}
539
540// ===========================================================================
541// Tests
542// ===========================================================================
543
544#[cfg(test)]
545#[allow(clippy::unwrap_used, clippy::expect_used)]
546mod tests {
547    use super::*;
548    use serde_json::json;
549
550    fn make_call(args: serde_json::Value) -> ToolCall {
551        ToolCall {
552            id: "test".to_string(),
553            name: "text_transform".to_string(),
554            arguments: args,
555        }
556    }
557
558    async fn exec(args: serde_json::Value) -> ToolResult {
559        let skill = TextTransformSkill::new();
560        skill.execute(make_call(args)).await.unwrap()
561    }
562
563    fn parse_result(result: &ToolResult) -> serde_json::Value {
564        serde_json::from_str(&result.content).unwrap()
565    }
566
567    // -- descriptor --------------------------------------------------------
568
569    #[test]
570    fn test_descriptor() {
571        let skill = TextTransformSkill::new();
572        let desc = skill.descriptor();
573        assert_eq!(desc.name, "text_transform");
574        assert!(desc.required_capabilities.is_empty());
575        assert!(desc.parameters_schema["properties"]["operation"].is_object());
576    }
577
578    #[test]
579    fn test_default_trait() {
580        let skill = TextTransformSkill::default();
581        assert_eq!(skill.descriptor().name, "text_transform");
582    }
583
584    // -- missing / unknown operation ---------------------------------------
585
586    #[tokio::test]
587    async fn test_missing_operation() {
588        let r = exec(json!({"text": "hello"})).await;
589        assert!(r.is_error);
590        assert!(r.content.contains("Missing required parameter: operation"));
591    }
592
593    #[tokio::test]
594    async fn test_unknown_operation() {
595        let r = exec(json!({"operation": "foobar", "text": "hello"})).await;
596        assert!(r.is_error);
597        assert!(r.content.contains("Unknown operation: foobar"));
598    }
599
600    // -- uppercase / lowercase ---------------------------------------------
601
602    #[tokio::test]
603    async fn test_uppercase() {
604        let r = exec(json!({"operation": "uppercase", "text": "hello World"})).await;
605        assert!(!r.is_error);
606        let v = parse_result(&r);
607        assert_eq!(v["result"], "HELLO WORLD");
608    }
609
610    #[tokio::test]
611    async fn test_lowercase() {
612        let r = exec(json!({"operation": "lowercase", "text": "Hello WORLD"})).await;
613        let v = parse_result(&r);
614        assert_eq!(v["result"], "hello world");
615    }
616
617    #[tokio::test]
618    async fn test_uppercase_missing_text() {
619        let r = exec(json!({"operation": "uppercase"})).await;
620        assert!(r.is_error);
621        assert!(r.content.contains("Missing required parameter: text"));
622    }
623
624    // -- title_case --------------------------------------------------------
625
626    #[tokio::test]
627    async fn test_title_case() {
628        let r = exec(json!({"operation": "title_case", "text": "hello world foo"})).await;
629        let v = parse_result(&r);
630        assert_eq!(v["result"], "Hello World Foo");
631    }
632
633    #[tokio::test]
634    async fn test_title_case_mixed() {
635        let r = exec(json!({"operation": "title_case", "text": "hELLO wORLD"})).await;
636        let v = parse_result(&r);
637        assert_eq!(v["result"], "Hello World");
638    }
639
640    // -- capitalize --------------------------------------------------------
641
642    #[tokio::test]
643    async fn test_capitalize() {
644        let r = exec(json!({"operation": "capitalize", "text": "hello world"})).await;
645        let v = parse_result(&r);
646        assert_eq!(v["result"], "Hello world");
647    }
648
649    #[tokio::test]
650    async fn test_capitalize_empty() {
651        let r = exec(json!({"operation": "capitalize", "text": ""})).await;
652        let v = parse_result(&r);
653        assert_eq!(v["result"], "");
654    }
655
656    // -- trim / trim_start / trim_end --------------------------------------
657
658    #[tokio::test]
659    async fn test_trim() {
660        let r = exec(json!({"operation": "trim", "text": "  hello  "})).await;
661        let v = parse_result(&r);
662        assert_eq!(v["result"], "hello");
663    }
664
665    #[tokio::test]
666    async fn test_trim_start() {
667        let r = exec(json!({"operation": "trim_start", "text": "  hello  "})).await;
668        let v = parse_result(&r);
669        assert_eq!(v["result"], "hello  ");
670    }
671
672    #[tokio::test]
673    async fn test_trim_end() {
674        let r = exec(json!({"operation": "trim_end", "text": "  hello  "})).await;
675        let v = parse_result(&r);
676        assert_eq!(v["result"], "  hello");
677    }
678
679    // -- reverse -----------------------------------------------------------
680
681    #[tokio::test]
682    async fn test_reverse() {
683        let r = exec(json!({"operation": "reverse", "text": "abcde"})).await;
684        let v = parse_result(&r);
685        assert_eq!(v["result"], "edcba");
686    }
687
688    #[tokio::test]
689    async fn test_reverse_unicode() {
690        let r = exec(json!({"operation": "reverse", "text": "hola"})).await;
691        let v = parse_result(&r);
692        assert_eq!(v["result"], "aloh");
693    }
694
695    // -- slug --------------------------------------------------------------
696
697    #[tokio::test]
698    async fn test_slug_basic() {
699        let r = exec(json!({"operation": "slug", "text": "Hello World!"})).await;
700        let v = parse_result(&r);
701        assert_eq!(v["result"], "hello-world");
702    }
703
704    #[tokio::test]
705    async fn test_slug_special_chars() {
706        let r = exec(json!({"operation": "slug", "text": "  Foo  Bar & Baz!! "})).await;
707        let v = parse_result(&r);
708        assert_eq!(v["result"], "foo-bar-baz");
709    }
710
711    // -- split -------------------------------------------------------------
712
713    #[tokio::test]
714    async fn test_split_default_delimiter() {
715        let r = exec(json!({"operation": "split", "text": "a,b,c"})).await;
716        let v = parse_result(&r);
717        assert_eq!(v["result"], json!(["a", "b", "c"]));
718    }
719
720    #[tokio::test]
721    async fn test_split_custom_delimiter() {
722        let r = exec(json!({"operation": "split", "text": "a|b|c", "delimiter": "|"})).await;
723        let v = parse_result(&r);
724        assert_eq!(v["result"], json!(["a", "b", "c"]));
725    }
726
727    // -- join --------------------------------------------------------------
728
729    #[tokio::test]
730    async fn test_join_default_delimiter() {
731        let r = exec(json!({"operation": "join", "values": ["a", "b", "c"]})).await;
732        let v = parse_result(&r);
733        assert_eq!(v["result"], "a,b,c");
734    }
735
736    #[tokio::test]
737    async fn test_join_custom_delimiter() {
738        let r = exec(json!({"operation": "join", "values": ["x", "y"], "delimiter": " - "})).await;
739        let v = parse_result(&r);
740        assert_eq!(v["result"], "x - y");
741    }
742
743    #[tokio::test]
744    async fn test_join_missing_values() {
745        let r = exec(json!({"operation": "join"})).await;
746        assert!(r.is_error);
747        assert!(r.content.contains("values"));
748    }
749
750    // -- replace -----------------------------------------------------------
751
752    #[tokio::test]
753    async fn test_replace() {
754        let r = exec(json!({
755            "operation": "replace",
756            "text": "hello world",
757            "pattern": "world",
758            "replacement": "rust"
759        }))
760        .await;
761        let v = parse_result(&r);
762        assert_eq!(v["result"], "hello rust");
763    }
764
765    #[tokio::test]
766    async fn test_replace_no_replacement() {
767        let r = exec(json!({
768            "operation": "replace",
769            "text": "hello world",
770            "pattern": " world"
771        }))
772        .await;
773        let v = parse_result(&r);
774        assert_eq!(v["result"], "hello");
775    }
776
777    #[tokio::test]
778    async fn test_replace_missing_pattern() {
779        let r = exec(json!({"operation": "replace", "text": "hello"})).await;
780        assert!(r.is_error);
781        assert!(r.content.contains("pattern"));
782    }
783
784    // -- pad_left / pad_right ----------------------------------------------
785
786    #[tokio::test]
787    async fn test_pad_left_default_char() {
788        let r = exec(json!({"operation": "pad_left", "text": "hi", "width": 5})).await;
789        let v = parse_result(&r);
790        assert_eq!(v["result"], "   hi");
791    }
792
793    #[tokio::test]
794    async fn test_pad_left_custom_char() {
795        let r = exec(json!({"operation": "pad_left", "text": "42", "width": 5, "char": "0"})).await;
796        let v = parse_result(&r);
797        assert_eq!(v["result"], "00042");
798    }
799
800    #[tokio::test]
801    async fn test_pad_right_default_char() {
802        let r = exec(json!({"operation": "pad_right", "text": "hi", "width": 5})).await;
803        let v = parse_result(&r);
804        assert_eq!(v["result"], "hi   ");
805    }
806
807    #[tokio::test]
808    async fn test_pad_no_change_when_longer() {
809        let r = exec(json!({"operation": "pad_left", "text": "hello", "width": 3})).await;
810        let v = parse_result(&r);
811        assert_eq!(v["result"], "hello");
812    }
813
814    #[tokio::test]
815    async fn test_pad_invalid_char() {
816        let r = exec(json!({"operation": "pad_left", "text": "x", "width": 5, "char": "ab"})).await;
817        assert!(r.is_error);
818        assert!(r.content.contains("single character"));
819    }
820
821    #[tokio::test]
822    async fn test_pad_missing_width() {
823        let r = exec(json!({"operation": "pad_left", "text": "x"})).await;
824        assert!(r.is_error);
825        assert!(r.content.contains("width"));
826    }
827
828    // -- truncate ----------------------------------------------------------
829
830    #[tokio::test]
831    async fn test_truncate_with_default_suffix() {
832        let r =
833            exec(json!({"operation": "truncate", "text": "hello world", "max_length": 8})).await;
834        let v = parse_result(&r);
835        assert_eq!(v["result"], "hello...");
836    }
837
838    #[tokio::test]
839    async fn test_truncate_custom_suffix() {
840        let r = exec(json!({
841            "operation": "truncate",
842            "text": "hello world",
843            "max_length": 8,
844            "suffix": ".."
845        }))
846        .await;
847        let v = parse_result(&r);
848        assert_eq!(v["result"], "hello ..");
849    }
850
851    #[tokio::test]
852    async fn test_truncate_no_truncation_needed() {
853        let r = exec(json!({"operation": "truncate", "text": "hi", "max_length": 10})).await;
854        let v = parse_result(&r);
855        assert_eq!(v["result"], "hi");
856    }
857
858    #[tokio::test]
859    async fn test_truncate_very_short_max() {
860        let r = exec(json!({"operation": "truncate", "text": "hello", "max_length": 2})).await;
861        let v = parse_result(&r);
862        assert_eq!(v["result"], "he");
863    }
864
865    #[tokio::test]
866    async fn test_truncate_missing_max_length() {
867        let r = exec(json!({"operation": "truncate", "text": "hello"})).await;
868        assert!(r.is_error);
869        assert!(r.content.contains("max_length"));
870    }
871
872    // -- word_count / char_count / line_count ------------------------------
873
874    #[tokio::test]
875    async fn test_word_count() {
876        let r = exec(json!({"operation": "word_count", "text": "hello world foo"})).await;
877        let v = parse_result(&r);
878        assert_eq!(v["result"], 3);
879    }
880
881    #[tokio::test]
882    async fn test_word_count_empty() {
883        let r = exec(json!({"operation": "word_count", "text": ""})).await;
884        let v = parse_result(&r);
885        assert_eq!(v["result"], 0);
886    }
887
888    #[tokio::test]
889    async fn test_char_count() {
890        let r = exec(json!({"operation": "char_count", "text": "hello"})).await;
891        let v = parse_result(&r);
892        assert_eq!(v["result"], 5);
893    }
894
895    #[tokio::test]
896    async fn test_char_count_unicode() {
897        let r = exec(json!({"operation": "char_count", "text": "cafe\u{0301}"})).await;
898        let v = parse_result(&r);
899        // 'c', 'a', 'f', 'e', combining acute accent = 5 chars
900        assert_eq!(v["result"], 5);
901    }
902
903    #[tokio::test]
904    async fn test_line_count() {
905        let r = exec(json!({"operation": "line_count", "text": "a\nb\nc"})).await;
906        let v = parse_result(&r);
907        assert_eq!(v["result"], 3);
908    }
909
910    #[tokio::test]
911    async fn test_line_count_empty() {
912        let r = exec(json!({"operation": "line_count", "text": ""})).await;
913        let v = parse_result(&r);
914        assert_eq!(v["result"], 0);
915    }
916
917    #[tokio::test]
918    async fn test_line_count_single_line() {
919        let r = exec(json!({"operation": "line_count", "text": "no newline"})).await;
920        let v = parse_result(&r);
921        assert_eq!(v["result"], 1);
922    }
923
924    // -- repeat ------------------------------------------------------------
925
926    #[tokio::test]
927    async fn test_repeat() {
928        let r = exec(json!({"operation": "repeat", "text": "ab", "count": 3})).await;
929        let v = parse_result(&r);
930        assert_eq!(v["result"], "ababab");
931    }
932
933    #[tokio::test]
934    async fn test_repeat_zero() {
935        let r = exec(json!({"operation": "repeat", "text": "ab", "count": 0})).await;
936        let v = parse_result(&r);
937        assert_eq!(v["result"], "");
938    }
939
940    #[tokio::test]
941    async fn test_repeat_exceeds_max() {
942        let r = exec(json!({"operation": "repeat", "text": "x", "count": 1001})).await;
943        assert!(r.is_error);
944        assert!(r.content.contains("1000"));
945    }
946
947    #[tokio::test]
948    async fn test_repeat_missing_count() {
949        let r = exec(json!({"operation": "repeat", "text": "x"})).await;
950        assert!(r.is_error);
951        assert!(r.content.contains("count"));
952    }
953
954    // -- contains / starts_with / ends_with --------------------------------
955
956    #[tokio::test]
957    async fn test_contains_true() {
958        let r = exec(json!({"operation": "contains", "text": "hello world", "substring": "world"}))
959            .await;
960        let v = parse_result(&r);
961        assert_eq!(v["result"], true);
962    }
963
964    #[tokio::test]
965    async fn test_contains_false() {
966        let r =
967            exec(json!({"operation": "contains", "text": "hello world", "substring": "xyz"})).await;
968        let v = parse_result(&r);
969        assert_eq!(v["result"], false);
970    }
971
972    #[tokio::test]
973    async fn test_contains_missing_substring() {
974        let r = exec(json!({"operation": "contains", "text": "hello"})).await;
975        assert!(r.is_error);
976        assert!(r.content.contains("substring"));
977    }
978
979    #[tokio::test]
980    async fn test_starts_with_true() {
981        let r = exec(json!({"operation": "starts_with", "text": "hello world", "prefix": "hello"}))
982            .await;
983        let v = parse_result(&r);
984        assert_eq!(v["result"], true);
985    }
986
987    #[tokio::test]
988    async fn test_starts_with_false() {
989        let r = exec(json!({"operation": "starts_with", "text": "hello world", "prefix": "world"}))
990            .await;
991        let v = parse_result(&r);
992        assert_eq!(v["result"], false);
993    }
994
995    #[tokio::test]
996    async fn test_ends_with_true() {
997        let r =
998            exec(json!({"operation": "ends_with", "text": "hello world", "suffix": "world"})).await;
999        let v = parse_result(&r);
1000        assert_eq!(v["result"], true);
1001    }
1002
1003    #[tokio::test]
1004    async fn test_ends_with_false() {
1005        let r =
1006            exec(json!({"operation": "ends_with", "text": "hello world", "suffix": "hello"})).await;
1007        let v = parse_result(&r);
1008        assert_eq!(v["result"], false);
1009    }
1010
1011    // -- extract_between ---------------------------------------------------
1012
1013    #[tokio::test]
1014    async fn test_extract_between() {
1015        let r = exec(json!({
1016            "operation": "extract_between",
1017            "text": "foo [bar] baz",
1018            "start_marker": "[",
1019            "end_marker": "]"
1020        }))
1021        .await;
1022        let v = parse_result(&r);
1023        assert_eq!(v["result"], "bar");
1024    }
1025
1026    #[tokio::test]
1027    async fn test_extract_between_html() {
1028        let r = exec(json!({
1029            "operation": "extract_between",
1030            "text": "<title>My Page</title>",
1031            "start_marker": "<title>",
1032            "end_marker": "</title>"
1033        }))
1034        .await;
1035        let v = parse_result(&r);
1036        assert_eq!(v["result"], "My Page");
1037    }
1038
1039    #[tokio::test]
1040    async fn test_extract_between_start_not_found() {
1041        let r = exec(json!({
1042            "operation": "extract_between",
1043            "text": "hello world",
1044            "start_marker": "<<",
1045            "end_marker": ">>"
1046        }))
1047        .await;
1048        assert!(r.is_error);
1049        assert!(r.content.contains("Start marker"));
1050    }
1051
1052    #[tokio::test]
1053    async fn test_extract_between_end_not_found() {
1054        let r = exec(json!({
1055            "operation": "extract_between",
1056            "text": "hello << world",
1057            "start_marker": "<<",
1058            "end_marker": ">>"
1059        }))
1060        .await;
1061        assert!(r.is_error);
1062        assert!(r.content.contains("End marker"));
1063    }
1064
1065    // -- camel_case / snake_case / kebab_case ------------------------------
1066
1067    #[tokio::test]
1068    async fn test_camel_case_from_spaces() {
1069        let r = exec(json!({"operation": "camel_case", "text": "hello world foo"})).await;
1070        let v = parse_result(&r);
1071        assert_eq!(v["result"], "helloWorldFoo");
1072    }
1073
1074    #[tokio::test]
1075    async fn test_camel_case_from_snake() {
1076        let r = exec(json!({"operation": "camel_case", "text": "my_variable_name"})).await;
1077        let v = parse_result(&r);
1078        assert_eq!(v["result"], "myVariableName");
1079    }
1080
1081    #[tokio::test]
1082    async fn test_camel_case_from_kebab() {
1083        let r = exec(json!({"operation": "camel_case", "text": "my-component-name"})).await;
1084        let v = parse_result(&r);
1085        assert_eq!(v["result"], "myComponentName");
1086    }
1087
1088    #[tokio::test]
1089    async fn test_snake_case_from_camel() {
1090        let r = exec(json!({"operation": "snake_case", "text": "myVariableName"})).await;
1091        let v = parse_result(&r);
1092        assert_eq!(v["result"], "my_variable_name");
1093    }
1094
1095    #[tokio::test]
1096    async fn test_snake_case_from_spaces() {
1097        let r = exec(json!({"operation": "snake_case", "text": "Hello World Foo"})).await;
1098        let v = parse_result(&r);
1099        assert_eq!(v["result"], "hello_world_foo");
1100    }
1101
1102    #[tokio::test]
1103    async fn test_kebab_case_from_camel() {
1104        let r = exec(json!({"operation": "kebab_case", "text": "myComponentName"})).await;
1105        let v = parse_result(&r);
1106        assert_eq!(v["result"], "my-component-name");
1107    }
1108
1109    #[tokio::test]
1110    async fn test_kebab_case_from_snake() {
1111        let r = exec(json!({"operation": "kebab_case", "text": "my_variable_name"})).await;
1112        let v = parse_result(&r);
1113        assert_eq!(v["result"], "my-variable-name");
1114    }
1115
1116    // -- edge cases --------------------------------------------------------
1117
1118    #[tokio::test]
1119    async fn test_empty_text_uppercase() {
1120        let r = exec(json!({"operation": "uppercase", "text": ""})).await;
1121        let v = parse_result(&r);
1122        assert_eq!(v["result"], "");
1123    }
1124
1125    #[tokio::test]
1126    async fn test_split_empty_text() {
1127        let r = exec(json!({"operation": "split", "text": ""})).await;
1128        let v = parse_result(&r);
1129        assert_eq!(v["result"], json!([""]));
1130    }
1131
1132    #[tokio::test]
1133    async fn test_repeat_at_boundary() {
1134        let r = exec(json!({"operation": "repeat", "text": "x", "count": 1000})).await;
1135        assert!(!r.is_error);
1136        let v = parse_result(&r);
1137        assert_eq!(v["result"].as_str().unwrap().len(), 1000);
1138    }
1139
1140    #[tokio::test]
1141    async fn test_slug_already_clean() {
1142        let r = exec(json!({"operation": "slug", "text": "already-clean"})).await;
1143        let v = parse_result(&r);
1144        assert_eq!(v["result"], "already-clean");
1145    }
1146
1147    #[tokio::test]
1148    async fn test_replace_multiple_occurrences() {
1149        let r = exec(json!({
1150            "operation": "replace",
1151            "text": "aaa bbb aaa",
1152            "pattern": "aaa",
1153            "replacement": "xxx"
1154        }))
1155        .await;
1156        let v = parse_result(&r);
1157        assert_eq!(v["result"], "xxx bbb xxx");
1158    }
1159
1160    #[tokio::test]
1161    async fn test_join_empty_array() {
1162        let r = exec(json!({"operation": "join", "values": []})).await;
1163        let v = parse_result(&r);
1164        assert_eq!(v["result"], "");
1165    }
1166
1167    #[tokio::test]
1168    async fn test_pad_right_custom_char() {
1169        let r =
1170            exec(json!({"operation": "pad_right", "text": "hi", "width": 6, "char": "."})).await;
1171        let v = parse_result(&r);
1172        assert_eq!(v["result"], "hi....");
1173    }
1174
1175    #[tokio::test]
1176    async fn test_truncate_exact_length() {
1177        let r = exec(json!({"operation": "truncate", "text": "hello", "max_length": 5})).await;
1178        let v = parse_result(&r);
1179        assert_eq!(v["result"], "hello");
1180    }
1181
1182    #[tokio::test]
1183    async fn test_word_count_extra_whitespace() {
1184        let r = exec(json!({"operation": "word_count", "text": "  hello   world  "})).await;
1185        let v = parse_result(&r);
1186        assert_eq!(v["result"], 2);
1187    }
1188
1189    #[tokio::test]
1190    async fn test_camel_case_single_word() {
1191        let r = exec(json!({"operation": "camel_case", "text": "hello"})).await;
1192        let v = parse_result(&r);
1193        assert_eq!(v["result"], "hello");
1194    }
1195
1196    #[tokio::test]
1197    async fn test_snake_case_single_word() {
1198        let r = exec(json!({"operation": "snake_case", "text": "Hello"})).await;
1199        let v = parse_result(&r);
1200        assert_eq!(v["result"], "hello");
1201    }
1202
1203    #[tokio::test]
1204    async fn test_kebab_case_with_numbers() {
1205        let r = exec(json!({"operation": "kebab_case", "text": "version 2 release"})).await;
1206        let v = parse_result(&r);
1207        assert_eq!(v["result"], "version-2-release");
1208    }
1209
1210    #[tokio::test]
1211    async fn test_extract_between_empty_content() {
1212        let r = exec(json!({
1213            "operation": "extract_between",
1214            "text": "[]",
1215            "start_marker": "[",
1216            "end_marker": "]"
1217        }))
1218        .await;
1219        let v = parse_result(&r);
1220        assert_eq!(v["result"], "");
1221    }
1222
1223    #[tokio::test]
1224    async fn test_contains_empty_substring() {
1225        let r = exec(json!({"operation": "contains", "text": "hello", "substring": ""})).await;
1226        let v = parse_result(&r);
1227        assert_eq!(v["result"], true);
1228    }
1229
1230    #[tokio::test]
1231    async fn test_reverse_empty() {
1232        let r = exec(json!({"operation": "reverse", "text": ""})).await;
1233        let v = parse_result(&r);
1234        assert_eq!(v["result"], "");
1235    }
1236}