Skip to main content

argentor_builtins/
diff_tool.rs

1//! Text diff generation skill using LCS (Longest Common Subsequence) algorithm.
2//!
3//! Pure Rust implementation inspired by Unix diff. No external dependencies
4//! beyond the standard library.
5//!
6//! # Supported operations
7//!
8//! - `diff` -- Generate a unified diff between original and modified text.
9//! - `patch` -- Apply a unified diff to text, producing the patched result.
10//! - `stats` -- Compute diff statistics (lines added, removed, similarity).
11//! - `word_diff` -- Word-level diff highlighting changed words.
12//! - `char_diff` -- Character-level diff for small text comparisons.
13
14use argentor_core::{ArgentorResult, ToolCall, ToolResult};
15use argentor_skills::skill::{Skill, SkillDescriptor};
16use async_trait::async_trait;
17
18/// Skill for generating and applying text diffs.
19pub struct DiffSkill {
20    descriptor: SkillDescriptor,
21}
22
23impl DiffSkill {
24    /// Create a new `DiffSkill`.
25    pub fn new() -> Self {
26        Self {
27            descriptor: SkillDescriptor {
28                name: "diff".to_string(),
29                description: "Text diff generation and patching. Operations: diff, \
30                              patch, stats, word_diff, char_diff."
31                    .to_string(),
32                parameters_schema: serde_json::json!({
33                    "type": "object",
34                    "properties": {
35                        "operation": {
36                            "type": "string",
37                            "enum": ["diff", "patch", "stats", "word_diff", "char_diff"],
38                            "description": "The diff operation to perform"
39                        },
40                        "original": {
41                            "type": "string",
42                            "description": "The original text"
43                        },
44                        "modified": {
45                            "type": "string",
46                            "description": "The modified text"
47                        },
48                        "diff_text": {
49                            "type": "string",
50                            "description": "Unified diff text (for patch operation)"
51                        },
52                        "context": {
53                            "type": "integer",
54                            "description": "Number of context lines in unified diff (default 3)"
55                        }
56                    },
57                    "required": ["operation"]
58                }),
59                required_capabilities: vec![],
60                requires_approval: false,
61            },
62        }
63    }
64}
65
66impl Default for DiffSkill {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72// ---------------------------------------------------------------------------
73// LCS-based diff engine
74// ---------------------------------------------------------------------------
75
76/// A single edit operation in the diff.
77#[derive(Debug, Clone, PartialEq)]
78enum EditOp {
79    Equal(String),
80    Insert(String),
81    Delete(String),
82}
83
84/// Compute the LCS (Longest Common Subsequence) table for two sequences of lines.
85fn lcs_table(a: &[&str], b: &[&str]) -> Vec<Vec<usize>> {
86    let m = a.len();
87    let n = b.len();
88    let mut table = vec![vec![0usize; n + 1]; m + 1];
89
90    for i in 1..=m {
91        for j in 1..=n {
92            if a[i - 1] == b[j - 1] {
93                table[i][j] = table[i - 1][j - 1] + 1;
94            } else {
95                table[i][j] = table[i - 1][j].max(table[i][j - 1]);
96            }
97        }
98    }
99
100    table
101}
102
103/// Backtrack the LCS table to produce a sequence of edit operations.
104fn backtrack_edits(a: &[&str], b: &[&str], table: &[Vec<usize>]) -> Vec<EditOp> {
105    let mut edits = Vec::new();
106    let mut i = a.len();
107    let mut j = b.len();
108
109    while i > 0 || j > 0 {
110        if i > 0 && j > 0 && a[i - 1] == b[j - 1] {
111            edits.push(EditOp::Equal(a[i - 1].to_string()));
112            i -= 1;
113            j -= 1;
114        } else if j > 0 && (i == 0 || table[i][j - 1] >= table[i - 1][j]) {
115            edits.push(EditOp::Insert(b[j - 1].to_string()));
116            j -= 1;
117        } else if i > 0 {
118            edits.push(EditOp::Delete(a[i - 1].to_string()));
119            i -= 1;
120        }
121    }
122
123    edits.reverse();
124    edits
125}
126
127/// Compute edit operations between two texts (line-level).
128fn compute_line_edits(original: &str, modified: &str) -> Vec<EditOp> {
129    let a: Vec<&str> = original.lines().collect();
130    let b: Vec<&str> = modified.lines().collect();
131    let table = lcs_table(&a, &b);
132    backtrack_edits(&a, &b, &table)
133}
134
135/// Generate unified diff output with context lines.
136fn generate_unified_diff(original: &str, modified: &str, context: usize) -> String {
137    let edits = compute_line_edits(original, modified);
138
139    if edits.iter().all(|e| matches!(e, EditOp::Equal(_))) {
140        return String::new(); // No differences
141    }
142
143    // Convert edits to tagged lines: (' ', line), ('+', line), ('-', line)
144    let mut tagged: Vec<(char, &str)> = Vec::new();
145    for edit in &edits {
146        match edit {
147            EditOp::Equal(s) => tagged.push((' ', s)),
148            EditOp::Insert(s) => tagged.push(('+', s)),
149            EditOp::Delete(s) => tagged.push(('-', s)),
150        }
151    }
152
153    // Group into hunks with context
154    let mut output = String::new();
155    output.push_str("--- original\n");
156    output.push_str("+++ modified\n");
157
158    // Find ranges of changes and include context
159    let mut i = 0;
160    while i < tagged.len() {
161        // Skip equal lines until we find a change
162        if tagged[i].0 == ' ' {
163            i += 1;
164            continue;
165        }
166
167        // Found a change; collect the hunk
168        let hunk_start = i.saturating_sub(context);
169
170        // Find end of this group of changes (including gaps smaller than 2*context)
171        let mut hunk_end = i;
172        while hunk_end < tagged.len() {
173            if tagged[hunk_end].0 != ' ' {
174                hunk_end += 1;
175            } else {
176                // Check if there's another change within context range
177                let lookahead = (hunk_end + 2 * context + 1).min(tagged.len());
178                let has_nearby_change = tagged[hunk_end..lookahead]
179                    .iter()
180                    .any(|(tag, _)| *tag != ' ');
181                if has_nearby_change {
182                    hunk_end += 1;
183                } else {
184                    break;
185                }
186            }
187        }
188
189        // Add trailing context
190        let trailing_end = (hunk_end + context).min(tagged.len());
191
192        // Compute line numbers for the hunk header
193        let mut orig_start = 1usize;
194        let mut orig_count = 0usize;
195        let mut mod_start = 1usize;
196        let mut mod_count = 0usize;
197
198        // Count lines before the hunk to determine starting line numbers
199        for (tag, _) in tagged.iter().take(hunk_start) {
200            match tag {
201                ' ' => {
202                    orig_start += 1;
203                    mod_start += 1;
204                }
205                '-' => orig_start += 1,
206                '+' => mod_start += 1,
207                _ => {}
208            }
209        }
210
211        // Count lines in the hunk
212        for (tag, _) in tagged.iter().take(trailing_end).skip(hunk_start) {
213            match tag {
214                ' ' => {
215                    orig_count += 1;
216                    mod_count += 1;
217                }
218                '-' => orig_count += 1,
219                '+' => mod_count += 1,
220                _ => {}
221            }
222        }
223
224        output.push_str(&format!(
225            "@@ -{orig_start},{orig_count} +{mod_start},{mod_count} @@\n"
226        ));
227
228        for (tag, line) in tagged.iter().take(trailing_end).skip(hunk_start) {
229            output.push(*tag);
230            output.push_str(line);
231            output.push('\n');
232        }
233
234        i = trailing_end;
235    }
236
237    output
238}
239
240/// Apply a unified diff to the original text.
241fn apply_patch(original: &str, diff_text: &str) -> Result<String, String> {
242    let orig_lines: Vec<&str> = original.lines().collect();
243    let mut result: Vec<String> = Vec::new();
244    let mut orig_idx = 0usize;
245
246    let diff_lines: Vec<&str> = diff_text.lines().collect();
247    let mut d = 0;
248
249    // Skip file headers
250    while d < diff_lines.len() {
251        let line = diff_lines[d];
252        if line.starts_with("@@") {
253            break;
254        }
255        d += 1;
256    }
257
258    while d < diff_lines.len() {
259        let line = diff_lines[d];
260
261        if line.starts_with("@@") {
262            // Parse hunk header: @@ -orig_start,orig_count +mod_start,mod_count @@
263            let parts: Vec<&str> = line.split_whitespace().collect();
264            if parts.len() < 3 {
265                return Err(format!("Invalid hunk header: {line}"));
266            }
267            let orig_range = parts[1].trim_start_matches('-');
268            let orig_start: usize = orig_range
269                .split(',')
270                .next()
271                .and_then(|s| s.parse().ok())
272                .unwrap_or(1);
273
274            // Copy lines from original up to the hunk start
275            while orig_idx + 1 < orig_start && orig_idx < orig_lines.len() {
276                result.push(orig_lines[orig_idx].to_string());
277                orig_idx += 1;
278            }
279
280            d += 1;
281            continue;
282        }
283
284        if line.starts_with(' ') {
285            // Context line -- copy from original
286            if orig_idx < orig_lines.len() {
287                result.push(orig_lines[orig_idx].to_string());
288                orig_idx += 1;
289            }
290        } else if let Some(stripped) = line.strip_prefix('+') {
291            // Added line
292            result.push(stripped.to_string());
293        } else if line.starts_with('-') {
294            // Removed line -- skip original
295            orig_idx += 1;
296        }
297
298        d += 1;
299    }
300
301    // Copy remaining original lines
302    while orig_idx < orig_lines.len() {
303        result.push(orig_lines[orig_idx].to_string());
304        orig_idx += 1;
305    }
306
307    Ok(result.join("\n"))
308}
309
310/// Compute diff statistics.
311fn compute_stats(original: &str, modified: &str) -> serde_json::Value {
312    let edits = compute_line_edits(original, modified);
313
314    let mut added = 0usize;
315    let mut removed = 0usize;
316    let mut unchanged = 0usize;
317
318    for edit in &edits {
319        match edit {
320            EditOp::Equal(_) => unchanged += 1,
321            EditOp::Insert(_) => added += 1,
322            EditOp::Delete(_) => removed += 1,
323        }
324    }
325
326    let total = added + removed + unchanged;
327    let similarity = if total == 0 {
328        100.0
329    } else {
330        (unchanged as f64 / (unchanged + removed.max(added)) as f64) * 100.0
331    };
332
333    serde_json::json!({
334        "lines_added": added,
335        "lines_removed": removed,
336        "lines_unchanged": unchanged,
337        "similarity_percentage": (similarity * 100.0).round() / 100.0,
338    })
339}
340
341/// Word-level diff between two texts.
342fn word_diff(original: &str, modified: &str) -> serde_json::Value {
343    let a_words: Vec<&str> = original.split_whitespace().collect();
344    let b_words: Vec<&str> = modified.split_whitespace().collect();
345
346    let table = lcs_table(&a_words, &b_words);
347    let edits = backtrack_edits(&a_words, &b_words, &table);
348
349    let mut changes: Vec<serde_json::Value> = Vec::new();
350    for edit in &edits {
351        match edit {
352            EditOp::Equal(w) => changes.push(serde_json::json!({"type": "equal", "value": w})),
353            EditOp::Insert(w) => changes.push(serde_json::json!({"type": "insert", "value": w})),
354            EditOp::Delete(w) => changes.push(serde_json::json!({"type": "delete", "value": w})),
355        }
356    }
357
358    // Build inline display: [-removed-] {+added+}
359    let mut display = String::new();
360    for edit in &edits {
361        if !display.is_empty() {
362            display.push(' ');
363        }
364        match edit {
365            EditOp::Equal(w) => display.push_str(w),
366            EditOp::Insert(w) => {
367                display.push_str("{+");
368                display.push_str(w);
369                display.push_str("+}");
370            }
371            EditOp::Delete(w) => {
372                display.push_str("[-");
373                display.push_str(w);
374                display.push_str("-]");
375            }
376        }
377    }
378
379    serde_json::json!({
380        "changes": changes,
381        "display": display,
382    })
383}
384
385/// Character-level diff between two texts.
386fn char_diff(original: &str, modified: &str) -> serde_json::Value {
387    let a_chars: Vec<&str> = original.chars().map(|_| "").collect::<Vec<_>>();
388    // We need to work with char slices for LCS
389    let a: Vec<String> = original.chars().map(|c| c.to_string()).collect();
390    let b: Vec<String> = modified.chars().map(|c| c.to_string()).collect();
391    let a_refs: Vec<&str> = a.iter().map(std::string::String::as_str).collect();
392    let b_refs: Vec<&str> = b.iter().map(std::string::String::as_str).collect();
393
394    let _ = a_chars; // suppress unused
395
396    let table = lcs_table(&a_refs, &b_refs);
397    let edits = backtrack_edits(&a_refs, &b_refs, &table);
398
399    let mut changes: Vec<serde_json::Value> = Vec::new();
400    // Coalesce consecutive same-type edits
401    let mut current_type: Option<&str> = None;
402    let mut current_buf = String::new();
403
404    for edit in &edits {
405        let (tag, ch) = match edit {
406            EditOp::Equal(c) => ("equal", c.as_str()),
407            EditOp::Insert(c) => ("insert", c.as_str()),
408            EditOp::Delete(c) => ("delete", c.as_str()),
409        };
410
411        if current_type == Some(tag) {
412            current_buf.push_str(ch);
413        } else {
414            if let Some(t) = current_type {
415                changes.push(serde_json::json!({"type": t, "value": current_buf}));
416            }
417            current_type = Some(tag);
418            current_buf = ch.to_string();
419        }
420    }
421    if let Some(t) = current_type {
422        if !current_buf.is_empty() {
423            changes.push(serde_json::json!({"type": t, "value": current_buf}));
424        }
425    }
426
427    // Build inline display
428    let mut display = String::new();
429    for change in &changes {
430        let t = change["type"].as_str().unwrap_or("");
431        let v = change["value"].as_str().unwrap_or("");
432        match t {
433            "equal" => display.push_str(v),
434            "insert" => {
435                display.push_str("{+");
436                display.push_str(v);
437                display.push_str("+}");
438            }
439            "delete" => {
440                display.push_str("[-");
441                display.push_str(v);
442                display.push_str("-]");
443            }
444            _ => {}
445        }
446    }
447
448    serde_json::json!({
449        "changes": changes,
450        "display": display,
451    })
452}
453
454// ---------------------------------------------------------------------------
455// Skill implementation
456// ---------------------------------------------------------------------------
457
458#[async_trait]
459impl Skill for DiffSkill {
460    fn descriptor(&self) -> &SkillDescriptor {
461        &self.descriptor
462    }
463
464    async fn execute(&self, call: ToolCall) -> ArgentorResult<ToolResult> {
465        let operation = match call.arguments["operation"].as_str() {
466            Some(op) => op,
467            None => {
468                return Ok(ToolResult::error(
469                    &call.id,
470                    "Missing required parameter: 'operation'",
471                ))
472            }
473        };
474
475        match operation {
476            "diff" => {
477                let original = match call.arguments["original"].as_str() {
478                    Some(t) => t,
479                    None => {
480                        return Ok(ToolResult::error(
481                            &call.id,
482                            "Missing required parameter: 'original'",
483                        ))
484                    }
485                };
486                let modified = match call.arguments["modified"].as_str() {
487                    Some(t) => t,
488                    None => {
489                        return Ok(ToolResult::error(
490                            &call.id,
491                            "Missing required parameter: 'modified'",
492                        ))
493                    }
494                };
495                let context = call.arguments["context"]
496                    .as_u64()
497                    .unwrap_or(3) as usize;
498                let diff_output = generate_unified_diff(original, modified, context);
499                let has_changes = !diff_output.is_empty();
500                let result = serde_json::json!({
501                    "has_changes": has_changes,
502                    "diff": diff_output,
503                });
504                Ok(ToolResult::success(&call.id, result.to_string()))
505            }
506            "patch" => {
507                let original = match call.arguments["original"].as_str() {
508                    Some(t) => t,
509                    None => {
510                        return Ok(ToolResult::error(
511                            &call.id,
512                            "Missing required parameter: 'original'",
513                        ))
514                    }
515                };
516                let diff_text = match call.arguments["diff_text"].as_str() {
517                    Some(t) => t,
518                    None => {
519                        return Ok(ToolResult::error(
520                            &call.id,
521                            "Missing required parameter: 'diff_text'",
522                        ))
523                    }
524                };
525                match apply_patch(original, diff_text) {
526                    Ok(patched) => {
527                        let result = serde_json::json!({
528                            "patched_text": patched,
529                            "success": true,
530                        });
531                        Ok(ToolResult::success(&call.id, result.to_string()))
532                    }
533                    Err(e) => Ok(ToolResult::error(&call.id, format!("Patch failed: {e}"))),
534                }
535            }
536            "stats" => {
537                let original = match call.arguments["original"].as_str() {
538                    Some(t) => t,
539                    None => {
540                        return Ok(ToolResult::error(
541                            &call.id,
542                            "Missing required parameter: 'original'",
543                        ))
544                    }
545                };
546                let modified = match call.arguments["modified"].as_str() {
547                    Some(t) => t,
548                    None => {
549                        return Ok(ToolResult::error(
550                            &call.id,
551                            "Missing required parameter: 'modified'",
552                        ))
553                    }
554                };
555                let result = compute_stats(original, modified);
556                Ok(ToolResult::success(&call.id, result.to_string()))
557            }
558            "word_diff" => {
559                let original = match call.arguments["original"].as_str() {
560                    Some(t) => t,
561                    None => {
562                        return Ok(ToolResult::error(
563                            &call.id,
564                            "Missing required parameter: 'original'",
565                        ))
566                    }
567                };
568                let modified = match call.arguments["modified"].as_str() {
569                    Some(t) => t,
570                    None => {
571                        return Ok(ToolResult::error(
572                            &call.id,
573                            "Missing required parameter: 'modified'",
574                        ))
575                    }
576                };
577                let result = word_diff(original, modified);
578                Ok(ToolResult::success(&call.id, result.to_string()))
579            }
580            "char_diff" => {
581                let original = match call.arguments["original"].as_str() {
582                    Some(t) => t,
583                    None => {
584                        return Ok(ToolResult::error(
585                            &call.id,
586                            "Missing required parameter: 'original'",
587                        ))
588                    }
589                };
590                let modified = match call.arguments["modified"].as_str() {
591                    Some(t) => t,
592                    None => {
593                        return Ok(ToolResult::error(
594                            &call.id,
595                            "Missing required parameter: 'modified'",
596                        ))
597                    }
598                };
599                let result = char_diff(original, modified);
600                Ok(ToolResult::success(&call.id, result.to_string()))
601            }
602            _ => Ok(ToolResult::error(
603                &call.id,
604                format!(
605                    "Unknown operation: '{operation}'. Supported: diff, patch, stats, word_diff, char_diff"
606                ),
607            )),
608        }
609    }
610}
611
612// ---------------------------------------------------------------------------
613// Tests
614// ---------------------------------------------------------------------------
615
616#[cfg(test)]
617#[allow(clippy::unwrap_used, clippy::expect_used)]
618mod tests {
619    use super::*;
620
621    fn skill() -> DiffSkill {
622        DiffSkill::new()
623    }
624
625    fn make_call(op: &str, args: serde_json::Value) -> ToolCall {
626        let mut merged = args.clone();
627        merged["operation"] = serde_json::json!(op);
628        ToolCall {
629            id: "test".to_string(),
630            name: "diff".to_string(),
631            arguments: merged,
632        }
633    }
634
635    // -- Descriptor ----------------------------------------------------------
636
637    #[test]
638    fn test_descriptor() {
639        let s = skill();
640        assert_eq!(s.descriptor().name, "diff");
641        assert!(s.descriptor().required_capabilities.is_empty());
642    }
643
644    #[test]
645    fn test_default() {
646        let s = DiffSkill::default();
647        assert_eq!(s.descriptor().name, "diff");
648    }
649
650    // -- diff operation ------------------------------------------------------
651
652    #[tokio::test]
653    async fn test_diff_identical() {
654        let s = skill();
655        let c = make_call(
656            "diff",
657            serde_json::json!({"original": "hello\nworld", "modified": "hello\nworld"}),
658        );
659        let r = s.execute(c).await.unwrap();
660        let v: serde_json::Value = serde_json::from_str(&r.content).unwrap();
661        assert_eq!(v["has_changes"], false);
662        assert_eq!(v["diff"], "");
663    }
664
665    #[tokio::test]
666    async fn test_diff_simple_change() {
667        let s = skill();
668        let c = make_call(
669            "diff",
670            serde_json::json!({
671                "original": "line1\nline2\nline3",
672                "modified": "line1\nchanged\nline3"
673            }),
674        );
675        let r = s.execute(c).await.unwrap();
676        let v: serde_json::Value = serde_json::from_str(&r.content).unwrap();
677        assert_eq!(v["has_changes"], true);
678        let diff = v["diff"].as_str().unwrap();
679        assert!(diff.contains("---"));
680        assert!(diff.contains("+++"));
681        assert!(diff.contains("-line2"));
682        assert!(diff.contains("+changed"));
683    }
684
685    #[tokio::test]
686    async fn test_diff_addition() {
687        let s = skill();
688        let c = make_call(
689            "diff",
690            serde_json::json!({
691                "original": "line1\nline2",
692                "modified": "line1\nline2\nline3"
693            }),
694        );
695        let r = s.execute(c).await.unwrap();
696        let v: serde_json::Value = serde_json::from_str(&r.content).unwrap();
697        assert_eq!(v["has_changes"], true);
698        let diff = v["diff"].as_str().unwrap();
699        assert!(diff.contains("+line3"));
700    }
701
702    #[tokio::test]
703    async fn test_diff_deletion() {
704        let s = skill();
705        let c = make_call(
706            "diff",
707            serde_json::json!({
708                "original": "line1\nline2\nline3",
709                "modified": "line1\nline3"
710            }),
711        );
712        let r = s.execute(c).await.unwrap();
713        let v: serde_json::Value = serde_json::from_str(&r.content).unwrap();
714        assert_eq!(v["has_changes"], true);
715        let diff = v["diff"].as_str().unwrap();
716        assert!(diff.contains("-line2"));
717    }
718
719    #[tokio::test]
720    async fn test_diff_empty_original() {
721        let s = skill();
722        let c = make_call(
723            "diff",
724            serde_json::json!({"original": "", "modified": "new content"}),
725        );
726        let r = s.execute(c).await.unwrap();
727        let v: serde_json::Value = serde_json::from_str(&r.content).unwrap();
728        assert_eq!(v["has_changes"], true);
729    }
730
731    #[tokio::test]
732    async fn test_diff_empty_modified() {
733        let s = skill();
734        let c = make_call(
735            "diff",
736            serde_json::json!({"original": "old content", "modified": ""}),
737        );
738        let r = s.execute(c).await.unwrap();
739        let v: serde_json::Value = serde_json::from_str(&r.content).unwrap();
740        assert_eq!(v["has_changes"], true);
741    }
742
743    // -- stats operation -----------------------------------------------------
744
745    #[tokio::test]
746    async fn test_stats_identical() {
747        let s = skill();
748        let c = make_call(
749            "stats",
750            serde_json::json!({"original": "a\nb\nc", "modified": "a\nb\nc"}),
751        );
752        let r = s.execute(c).await.unwrap();
753        let v: serde_json::Value = serde_json::from_str(&r.content).unwrap();
754        assert_eq!(v["lines_added"], 0);
755        assert_eq!(v["lines_removed"], 0);
756        assert_eq!(v["lines_unchanged"], 3);
757        assert_eq!(v["similarity_percentage"], 100.0);
758    }
759
760    #[tokio::test]
761    async fn test_stats_all_different() {
762        let s = skill();
763        let c = make_call(
764            "stats",
765            serde_json::json!({"original": "a\nb", "modified": "x\ny"}),
766        );
767        let r = s.execute(c).await.unwrap();
768        let v: serde_json::Value = serde_json::from_str(&r.content).unwrap();
769        assert!(v["lines_added"].as_u64().unwrap() > 0);
770        assert!(v["lines_removed"].as_u64().unwrap() > 0);
771        assert_eq!(v["similarity_percentage"], 0.0);
772    }
773
774    #[tokio::test]
775    async fn test_stats_partial_change() {
776        let s = skill();
777        let c = make_call(
778            "stats",
779            serde_json::json!({
780                "original": "a\nb\nc\nd",
781                "modified": "a\nx\nc\nd"
782            }),
783        );
784        let r = s.execute(c).await.unwrap();
785        let v: serde_json::Value = serde_json::from_str(&r.content).unwrap();
786        assert_eq!(v["lines_unchanged"], 3);
787        assert!(v["similarity_percentage"].as_f64().unwrap() > 50.0);
788    }
789
790    // -- word_diff operation -------------------------------------------------
791
792    #[tokio::test]
793    async fn test_word_diff_simple() {
794        let s = skill();
795        let c = make_call(
796            "word_diff",
797            serde_json::json!({
798                "original": "the quick brown fox",
799                "modified": "the slow brown fox"
800            }),
801        );
802        let r = s.execute(c).await.unwrap();
803        let v: serde_json::Value = serde_json::from_str(&r.content).unwrap();
804        let display = v["display"].as_str().unwrap();
805        assert!(display.contains("[-quick-]"));
806        assert!(display.contains("{+slow+}"));
807        assert!(display.contains("the"));
808        assert!(display.contains("fox"));
809    }
810
811    #[tokio::test]
812    async fn test_word_diff_identical() {
813        let s = skill();
814        let c = make_call(
815            "word_diff",
816            serde_json::json!({"original": "same text", "modified": "same text"}),
817        );
818        let r = s.execute(c).await.unwrap();
819        let v: serde_json::Value = serde_json::from_str(&r.content).unwrap();
820        let changes = v["changes"].as_array().unwrap();
821        assert!(changes.iter().all(|c| c["type"] == "equal"));
822    }
823
824    // -- char_diff operation -------------------------------------------------
825
826    #[tokio::test]
827    async fn test_char_diff_simple() {
828        let s = skill();
829        let c = make_call(
830            "char_diff",
831            serde_json::json!({"original": "cat", "modified": "car"}),
832        );
833        let r = s.execute(c).await.unwrap();
834        let v: serde_json::Value = serde_json::from_str(&r.content).unwrap();
835        let display = v["display"].as_str().unwrap();
836        assert!(display.contains("ca"));
837        // t -> r change should be visible
838        assert!(display.contains("[-t-]") || display.contains("{+r+}"));
839    }
840
841    #[tokio::test]
842    async fn test_char_diff_identical() {
843        let s = skill();
844        let c = make_call(
845            "char_diff",
846            serde_json::json!({"original": "abc", "modified": "abc"}),
847        );
848        let r = s.execute(c).await.unwrap();
849        let v: serde_json::Value = serde_json::from_str(&r.content).unwrap();
850        let display = v["display"].as_str().unwrap();
851        assert_eq!(display, "abc");
852    }
853
854    // -- patch operation -----------------------------------------------------
855
856    #[tokio::test]
857    async fn test_patch_simple() {
858        let s = skill();
859        let original = "line1\nline2\nline3";
860        let modified = "line1\nchanged\nline3";
861
862        // First generate the diff
863        let c = make_call(
864            "diff",
865            serde_json::json!({"original": original, "modified": modified}),
866        );
867        let r = s.execute(c).await.unwrap();
868        let v: serde_json::Value = serde_json::from_str(&r.content).unwrap();
869        let diff_text = v["diff"].as_str().unwrap();
870
871        // Then apply it
872        let c2 = make_call(
873            "patch",
874            serde_json::json!({"original": original, "diff_text": diff_text}),
875        );
876        let r2 = s.execute(c2).await.unwrap();
877        assert!(!r2.is_error, "Patch failed: {}", r2.content);
878        let v2: serde_json::Value = serde_json::from_str(&r2.content).unwrap();
879        assert_eq!(v2["success"], true);
880        let patched = v2["patched_text"].as_str().unwrap();
881        assert!(patched.contains("changed"));
882        assert!(!patched.contains("line2") || patched.contains("changed"));
883    }
884
885    // -- Error handling ------------------------------------------------------
886
887    #[tokio::test]
888    async fn test_missing_operation() {
889        let s = skill();
890        let c = ToolCall {
891            id: "test".to_string(),
892            name: "diff".to_string(),
893            arguments: serde_json::json!({"original": "a"}),
894        };
895        let r = s.execute(c).await.unwrap();
896        assert!(r.is_error);
897        assert!(r.content.contains("operation"));
898    }
899
900    #[tokio::test]
901    async fn test_unknown_operation() {
902        let s = skill();
903        let c = make_call(
904            "bogus",
905            serde_json::json!({"original": "a", "modified": "b"}),
906        );
907        let r = s.execute(c).await.unwrap();
908        assert!(r.is_error);
909        assert!(r.content.contains("Unknown operation"));
910    }
911
912    #[tokio::test]
913    async fn test_diff_missing_original() {
914        let s = skill();
915        let c = make_call("diff", serde_json::json!({"modified": "b"}));
916        let r = s.execute(c).await.unwrap();
917        assert!(r.is_error);
918        assert!(r.content.contains("original"));
919    }
920
921    #[tokio::test]
922    async fn test_diff_missing_modified() {
923        let s = skill();
924        let c = make_call("diff", serde_json::json!({"original": "a"}));
925        let r = s.execute(c).await.unwrap();
926        assert!(r.is_error);
927        assert!(r.content.contains("modified"));
928    }
929
930    #[tokio::test]
931    async fn test_patch_missing_diff_text() {
932        let s = skill();
933        let c = make_call("patch", serde_json::json!({"original": "a"}));
934        let r = s.execute(c).await.unwrap();
935        assert!(r.is_error);
936        assert!(r.content.contains("diff_text"));
937    }
938
939    // -- LCS unit tests ------------------------------------------------------
940
941    #[test]
942    fn test_lcs_table_simple() {
943        let a = vec!["a", "b", "c"];
944        let b = vec!["a", "c"];
945        let table = lcs_table(&a, &b);
946        assert_eq!(table[3][2], 2); // LCS length is 2
947    }
948
949    #[test]
950    fn test_lcs_empty() {
951        let a: Vec<&str> = vec![];
952        let b = vec!["a"];
953        let table = lcs_table(&a, &b);
954        assert_eq!(table[0][1], 0);
955    }
956
957    #[test]
958    fn test_compute_line_edits_identical() {
959        let edits = compute_line_edits("a\nb", "a\nb");
960        assert!(edits.iter().all(|e| matches!(e, EditOp::Equal(_))));
961    }
962}