1use argentor_core::{ArgentorResult, ToolCall, ToolResult};
15use argentor_skills::skill::{Skill, SkillDescriptor};
16use async_trait::async_trait;
17
18pub struct DiffSkill {
20 descriptor: SkillDescriptor,
21}
22
23impl DiffSkill {
24 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#[derive(Debug, Clone, PartialEq)]
78enum EditOp {
79 Equal(String),
80 Insert(String),
81 Delete(String),
82}
83
84fn 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
103fn 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
127fn 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
135fn 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(); }
142
143 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 let mut output = String::new();
155 output.push_str("--- original\n");
156 output.push_str("+++ modified\n");
157
158 let mut i = 0;
160 while i < tagged.len() {
161 if tagged[i].0 == ' ' {
163 i += 1;
164 continue;
165 }
166
167 let hunk_start = i.saturating_sub(context);
169
170 let mut hunk_end = i;
172 while hunk_end < tagged.len() {
173 if tagged[hunk_end].0 != ' ' {
174 hunk_end += 1;
175 } else {
176 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 let trailing_end = (hunk_end + context).min(tagged.len());
191
192 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 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 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
240fn 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 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 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 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 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 result.push(stripped.to_string());
293 } else if line.starts_with('-') {
294 orig_idx += 1;
296 }
297
298 d += 1;
299 }
300
301 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
310fn 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
341fn 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 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
385fn char_diff(original: &str, modified: &str) -> serde_json::Value {
387 let a_chars: Vec<&str> = original.chars().map(|_| "").collect::<Vec<_>>();
388 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; 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 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 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#[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#[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 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 #[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 #[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); }
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}