1use crate::tools::safe_resolve_path;
12use similar::{ChangeTag, TextDiff};
13use std::path::Path;
14
15const CONTEXT_LINES: usize = 3;
17
18const MAX_DIFF_LINES: usize = 120;
20
21const MAX_WRITE_NEW_LINES: usize = 60;
23
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30#[serde(tag = "kind")]
31pub enum DiffPreview {
32 UnifiedDiff(UnifiedDiffPreview),
35 WriteNew(WriteNewPreview),
37 DeleteFile(DeleteFilePreview),
39 DeleteDir(DeleteDirPreview),
41 FileNotYetExists,
43 PathNotFound,
45}
46
47#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
49pub struct UnifiedDiffPreview {
50 pub path: String,
52 pub old_content: String,
54 pub new_content: String,
56 pub hunks: Vec<DiffHunk>,
58 pub truncated: bool,
60}
61
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
64pub struct DiffHunk {
65 pub old_start: usize,
67 pub old_count: usize,
69 pub new_start: usize,
71 pub new_count: usize,
73 pub lines: Vec<DiffLine>,
75}
76
77#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
79pub struct DiffLine {
80 pub tag: DiffTag,
82 pub content: String,
84 pub old_line: Option<usize>,
86 pub new_line: Option<usize>,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
92pub enum DiffTag {
93 Context,
95 Insert,
97 Delete,
99}
100
101#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
103pub struct WriteNewPreview {
104 pub path: String,
106 pub line_count: usize,
108 pub byte_count: usize,
110 pub first_lines: Vec<String>,
112 pub truncated: bool,
114}
115
116#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
118pub struct DeleteFilePreview {
119 pub line_count: usize,
121 pub byte_count: u64,
123}
124
125#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
127pub struct DeleteDirPreview {
128 pub recursive: bool,
130}
131
132pub async fn compute(
138 tool_name: &str,
139 args: &serde_json::Value,
140 project_root: &Path,
141) -> Option<DiffPreview> {
142 match tool_name {
143 "Edit" => preview_edit(args, project_root).await,
144 "Write" => preview_write(args, project_root).await,
145 "Delete" => preview_delete(args, project_root).await,
146 _ => None,
147 }
148}
149
150fn build_unified_diff(path: &str, old_content: &str, new_content: &str) -> UnifiedDiffPreview {
154 let diff = TextDiff::from_lines(old_content, new_content);
155 let mut hunks = Vec::new();
156 let mut total_lines = 0usize;
157 let mut truncated = false;
158
159 for group in diff.grouped_ops(CONTEXT_LINES) {
160 let mut hunk_lines = Vec::new();
161 let mut old_start = 0;
162 let mut new_start = 0;
163 let mut old_count = 0;
164 let mut new_count = 0;
165 let mut first = true;
166
167 for op in &group {
168 if first {
169 old_start = op.old_range().start + 1; new_start = op.new_range().start + 1;
171 first = false;
172 }
173
174 for change in diff.iter_changes(op) {
175 let content = change.value().trim_end_matches('\n').to_string();
176 let (tag, old_line, new_line) = match change.tag() {
177 ChangeTag::Equal => {
178 old_count += 1;
179 new_count += 1;
180 (
181 DiffTag::Context,
182 change.old_index().map(|i| i + 1),
183 change.new_index().map(|i| i + 1),
184 )
185 }
186 ChangeTag::Delete => {
187 old_count += 1;
188 (DiffTag::Delete, change.old_index().map(|i| i + 1), None)
189 }
190 ChangeTag::Insert => {
191 new_count += 1;
192 (DiffTag::Insert, None, change.new_index().map(|i| i + 1))
193 }
194 };
195
196 hunk_lines.push(DiffLine {
197 tag,
198 content,
199 old_line,
200 new_line,
201 });
202 }
203 }
204
205 total_lines += hunk_lines.len();
206 hunks.push(DiffHunk {
207 old_start,
208 old_count,
209 new_start,
210 new_count,
211 lines: hunk_lines,
212 });
213
214 if total_lines > MAX_DIFF_LINES {
215 truncated = true;
216 break;
217 }
218 }
219
220 UnifiedDiffPreview {
221 path: path.to_string(),
222 old_content: old_content.to_string(),
223 new_content: new_content.to_string(),
224 hunks,
225 truncated,
226 }
227}
228
229async fn preview_edit(args: &serde_json::Value, project_root: &Path) -> Option<DiffPreview> {
230 let inner = args.get("payload").unwrap_or(args);
231 let path_str = inner
232 .get("path")
233 .or(inner.get("file_path"))
234 .and_then(|v| v.as_str())?;
235 let replacements = inner.get("replacements")?.as_array()?;
236
237 let resolved = safe_resolve_path(project_root, path_str).ok()?;
238 if !resolved.exists() {
239 return Some(DiffPreview::FileNotYetExists);
240 }
241 let old_content = tokio::fs::read_to_string(&resolved).await.ok()?;
242
243 let mut new_content = old_content.clone();
245 for replacement in replacements {
246 let old_str = replacement.get("old_str")?.as_str()?;
247 let new_str = replacement
248 .get("new_str")
249 .and_then(|v| v.as_str())
250 .unwrap_or("");
251 if let Some(pos) = new_content.find(old_str) {
253 new_content.replace_range(pos..pos + old_str.len(), new_str);
254 }
255 }
256
257 let preview = build_unified_diff(path_str, &old_content, &new_content);
258 Some(DiffPreview::UnifiedDiff(preview))
259}
260
261async fn preview_write(args: &serde_json::Value, project_root: &Path) -> Option<DiffPreview> {
262 let inner = args.get("payload").unwrap_or(args);
263 let path_str = inner
264 .get("path")
265 .or(inner.get("file_path"))
266 .and_then(|v| v.as_str())?;
267 let content = inner.get("content").and_then(|v| v.as_str())?;
268 let resolved = safe_resolve_path(project_root, path_str).ok()?;
269
270 if resolved.exists() {
271 let old_content = tokio::fs::read_to_string(&resolved).await.ok()?;
273 let preview = build_unified_diff(path_str, &old_content, content);
274 Some(DiffPreview::UnifiedDiff(preview))
275 } else {
276 let content_lines: Vec<&str> = content.lines().collect();
278 let line_count = content_lines.len();
279 let preview_count = line_count.min(MAX_WRITE_NEW_LINES);
280 let first_lines: Vec<String> = content_lines[..preview_count]
281 .iter()
282 .map(|s| s.to_string())
283 .collect();
284 let truncated = line_count > MAX_WRITE_NEW_LINES;
285
286 Some(DiffPreview::WriteNew(WriteNewPreview {
287 path: path_str.to_string(),
288 line_count,
289 byte_count: content.len(),
290 first_lines,
291 truncated,
292 }))
293 }
294}
295
296async fn preview_delete(args: &serde_json::Value, project_root: &Path) -> Option<DiffPreview> {
297 let inner = args.get("payload").unwrap_or(args);
298 let path_str = inner
299 .get("path")
300 .or(inner.get("file_path"))
301 .and_then(|v| v.as_str())?;
302 let resolved = safe_resolve_path(project_root, path_str).ok()?;
303
304 if !resolved.exists() {
305 return Some(DiffPreview::PathNotFound);
306 }
307
308 let meta = tokio::fs::metadata(&resolved).await.ok()?;
309 if meta.is_file() {
310 let line_count = tokio::fs::read_to_string(&resolved)
311 .await
312 .map(|c| c.lines().count())
313 .unwrap_or(0);
314 Some(DiffPreview::DeleteFile(DeleteFilePreview {
315 line_count,
316 byte_count: meta.len(),
317 }))
318 } else if meta.is_dir() {
319 let recursive = args
320 .get("recursive")
321 .and_then(|v| v.as_bool())
322 .unwrap_or(false);
323 Some(DiffPreview::DeleteDir(DeleteDirPreview { recursive }))
324 } else {
325 None
326 }
327}
328
329#[cfg(test)]
332mod tests {
333 use super::*;
334 use serde_json::json;
335 use tempfile::TempDir;
336
337 #[tokio::test]
338 async fn test_edit_produces_unified_diff() {
339 let tmp = TempDir::new().unwrap();
340 let file = tmp.path().join("test.rs");
341 std::fs::write(&file, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
342
343 let args = json!({
344 "path": file.to_str().unwrap(),
345 "replacements": [{
346 "old_str": "println!(\"hello\");",
347 "new_str": "println!(\"world\");"
348 }]
349 });
350
351 let preview = compute("Edit", &args, tmp.path()).await.unwrap();
352 match preview {
353 DiffPreview::UnifiedDiff(diff) => {
354 assert_eq!(diff.hunks.len(), 1);
355 let hunk = &diff.hunks[0];
356 let tags: Vec<_> = hunk.lines.iter().map(|l| l.tag).collect();
358 assert!(tags.contains(&DiffTag::Delete));
359 assert!(tags.contains(&DiffTag::Insert));
360 assert!(tags.contains(&DiffTag::Context));
361 let del = hunk
363 .lines
364 .iter()
365 .find(|l| l.tag == DiffTag::Delete)
366 .unwrap();
367 assert!(del.content.contains("hello"));
368 let ins = hunk
370 .lines
371 .iter()
372 .find(|l| l.tag == DiffTag::Insert)
373 .unwrap();
374 assert!(ins.content.contains("world"));
375 }
376 other => panic!("expected UnifiedDiff, got {other:?}"),
377 }
378 }
379
380 #[tokio::test]
381 async fn test_edit_multiple_replacements() {
382 let tmp = TempDir::new().unwrap();
383 let file = tmp.path().join("test.rs");
384 let content: String = (1..=20).map(|i| format!("line {i}\n")).collect();
387 std::fs::write(&file, &content).unwrap();
388
389 let args = json!({
390 "path": file.to_str().unwrap(),
391 "replacements": [
392 { "old_str": "line 2", "new_str": "LINE TWO" },
393 { "old_str": "line 19", "new_str": "LINE NINETEEN" }
394 ]
395 });
396
397 let preview = compute("Edit", &args, tmp.path()).await.unwrap();
398 match preview {
399 DiffPreview::UnifiedDiff(diff) => {
400 assert_eq!(
401 diff.hunks.len(),
402 2,
403 "expected 2 hunks, got {:?}",
404 diff.hunks
405 );
406 }
407 other => panic!("expected UnifiedDiff, got {other:?}"),
408 }
409 }
410
411 #[tokio::test]
412 async fn test_write_new_file() {
413 let tmp = TempDir::new().unwrap();
414 let args = json!({
415 "path": "new_file.rs",
416 "content": "fn main() {}\n"
417 });
418
419 let preview = compute("Write", &args, tmp.path()).await.unwrap();
420 assert!(matches!(preview, DiffPreview::WriteNew(_)));
421 }
422
423 #[tokio::test]
424 async fn test_write_overwrite_produces_unified_diff() {
425 let tmp = TempDir::new().unwrap();
426 let file = tmp.path().join("existing.rs");
427 std::fs::write(&file, "old content\n").unwrap();
428
429 let args = json!({
430 "path": file.to_str().unwrap(),
431 "content": "new content\nline 2\n"
432 });
433
434 let preview = compute("Write", &args, tmp.path()).await.unwrap();
435 match preview {
436 DiffPreview::UnifiedDiff(diff) => {
437 assert!(!diff.hunks.is_empty());
438 }
439 other => panic!("expected UnifiedDiff for overwrite, got {other:?}"),
440 }
441 }
442
443 #[tokio::test]
444 async fn test_delete_file() {
445 let tmp = TempDir::new().unwrap();
446 let file = tmp.path().join("doomed.rs");
447 std::fs::write(&file, "goodbye\n").unwrap();
448
449 let args = json!({ "path": file.to_str().unwrap() });
450 let preview = compute("Delete", &args, tmp.path()).await.unwrap();
451 assert!(matches!(preview, DiffPreview::DeleteFile(_)));
452 }
453
454 #[tokio::test]
455 async fn test_unknown_tool_returns_none() {
456 let tmp = TempDir::new().unwrap();
457 let args = json!({"path": "anything.rs"});
458 let preview = compute("Read", &args, tmp.path()).await;
459 assert!(preview.is_none());
460 }
461
462 #[tokio::test]
463 async fn test_edit_missing_file() {
464 let tmp = TempDir::new().unwrap();
465 let args = json!({
466 "path": "nonexistent.rs",
467 "replacements": [{ "old_str": "x", "new_str": "y" }]
468 });
469 let preview = compute("Edit", &args, tmp.path()).await.unwrap();
470 assert!(matches!(preview, DiffPreview::FileNotYetExists));
471 }
472
473 #[tokio::test]
474 async fn test_unified_diff_has_line_numbers() {
475 let tmp = TempDir::new().unwrap();
476 let file = tmp.path().join("test.txt");
477 std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
478
479 let args = json!({
480 "path": file.to_str().unwrap(),
481 "replacements": [{ "old_str": "c", "new_str": "C" }]
482 });
483
484 let preview = compute("Edit", &args, tmp.path()).await.unwrap();
485 match preview {
486 DiffPreview::UnifiedDiff(diff) => {
487 let hunk = &diff.hunks[0];
488 for line in &hunk.lines {
490 assert!(
491 line.old_line.is_some() || line.new_line.is_some(),
492 "line should have a line number: {line:?}"
493 );
494 }
495 }
496 other => panic!("expected UnifiedDiff, got {other:?}"),
497 }
498 }
499
500 #[tokio::test]
501 async fn test_delete_dir() {
502 let tmp = TempDir::new().unwrap();
503 let dir = tmp.path().join("subdir");
504 std::fs::create_dir(&dir).unwrap();
505 std::fs::write(dir.join("file.txt"), "content").unwrap();
506
507 let args = json!({ "path": dir.to_str().unwrap(), "recursive": true });
508 let preview = compute("Delete", &args, tmp.path()).await.unwrap();
509 match preview {
510 DiffPreview::DeleteDir(d) => assert!(d.recursive),
511 other => panic!("expected DeleteDir, got {other:?}"),
512 }
513 }
514
515 #[tokio::test]
516 async fn test_delete_dir_non_recursive() {
517 let tmp = TempDir::new().unwrap();
518 let dir = tmp.path().join("emptydir");
519 std::fs::create_dir(&dir).unwrap();
520
521 let args = json!({ "path": dir.to_str().unwrap() });
522 let preview = compute("Delete", &args, tmp.path()).await.unwrap();
523 match preview {
524 DiffPreview::DeleteDir(d) => assert!(!d.recursive),
525 other => panic!("expected DeleteDir, got {other:?}"),
526 }
527 }
528
529 #[tokio::test]
530 async fn test_delete_nonexistent_path() {
531 let tmp = TempDir::new().unwrap();
532 let args = json!({ "path": "nonexistent_file.rs" });
533 let preview = compute("Delete", &args, tmp.path()).await.unwrap();
534 assert!(matches!(preview, DiffPreview::PathNotFound));
535 }
536
537 #[tokio::test]
538 async fn test_write_new_file_truncates_long_content() {
539 let tmp = TempDir::new().unwrap();
540 let content: String = (1..=100).map(|i| format!("line {i}\n")).collect();
542 let args = json!({ "path": "big_new_file.rs", "content": content });
543
544 let preview = compute("Write", &args, tmp.path()).await.unwrap();
545 match preview {
546 DiffPreview::WriteNew(w) => {
547 assert_eq!(w.line_count, 100);
548 assert_eq!(w.first_lines.len(), 60);
549 assert!(w.truncated);
550 }
551 other => panic!("expected WriteNew, got {other:?}"),
552 }
553 }
554
555 #[tokio::test]
556 async fn test_build_unified_diff_truncates_large_diffs() {
557 let old: String = (1..=200).map(|i| format!("old line {i}\n")).collect();
559 let new: String = (1..=200).map(|i| format!("new line {i}\n")).collect();
560
561 let diff = build_unified_diff("test.txt", &old, &new);
562 assert!(diff.truncated, "large diff should be truncated");
563 }
564}