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 let old_lines = diff.old_slices();
175 let new_lines = diff.new_slices();
176
177 for change in diff.iter_changes(op) {
178 let content = change.value().trim_end_matches('\n').to_string();
179 let (tag, old_line, new_line) = match change.tag() {
180 ChangeTag::Equal => {
181 old_count += 1;
182 new_count += 1;
183 (
184 DiffTag::Context,
185 change.old_index().map(|i| i + 1),
186 change.new_index().map(|i| i + 1),
187 )
188 }
189 ChangeTag::Delete => {
190 old_count += 1;
191 (DiffTag::Delete, change.old_index().map(|i| i + 1), None)
192 }
193 ChangeTag::Insert => {
194 new_count += 1;
195 (DiffTag::Insert, None, change.new_index().map(|i| i + 1))
196 }
197 };
198
199 hunk_lines.push(DiffLine {
200 tag,
201 content,
202 old_line,
203 new_line,
204 });
205 }
206
207 let _ = (old_lines, new_lines);
209 }
210
211 total_lines += hunk_lines.len();
212 hunks.push(DiffHunk {
213 old_start,
214 old_count,
215 new_start,
216 new_count,
217 lines: hunk_lines,
218 });
219
220 if total_lines > MAX_DIFF_LINES {
221 truncated = true;
222 break;
223 }
224 }
225
226 UnifiedDiffPreview {
227 path: path.to_string(),
228 old_content: old_content.to_string(),
229 new_content: new_content.to_string(),
230 hunks,
231 truncated,
232 }
233}
234
235async fn preview_edit(args: &serde_json::Value, project_root: &Path) -> Option<DiffPreview> {
236 let inner = args.get("payload").unwrap_or(args);
237 let path_str = inner
238 .get("path")
239 .or(inner.get("file_path"))
240 .and_then(|v| v.as_str())?;
241 let replacements = inner.get("replacements")?.as_array()?;
242
243 let resolved = safe_resolve_path(project_root, path_str).ok()?;
244 if !resolved.exists() {
245 return Some(DiffPreview::FileNotYetExists);
246 }
247 let old_content = tokio::fs::read_to_string(&resolved).await.ok()?;
248
249 let mut new_content = old_content.clone();
251 for replacement in replacements {
252 let old_str = replacement.get("old_str")?.as_str()?;
253 let new_str = replacement
254 .get("new_str")
255 .and_then(|v| v.as_str())
256 .unwrap_or("");
257 if let Some(pos) = new_content.find(old_str) {
259 new_content.replace_range(pos..pos + old_str.len(), new_str);
260 }
261 }
262
263 let preview = build_unified_diff(path_str, &old_content, &new_content);
264 Some(DiffPreview::UnifiedDiff(preview))
265}
266
267async fn preview_write(args: &serde_json::Value, project_root: &Path) -> Option<DiffPreview> {
268 let inner = args.get("payload").unwrap_or(args);
269 let path_str = inner
270 .get("path")
271 .or(inner.get("file_path"))
272 .and_then(|v| v.as_str())?;
273 let content = inner.get("content").and_then(|v| v.as_str())?;
274 let resolved = safe_resolve_path(project_root, path_str).ok()?;
275
276 if resolved.exists() {
277 let old_content = tokio::fs::read_to_string(&resolved).await.ok()?;
279 let preview = build_unified_diff(path_str, &old_content, content);
280 Some(DiffPreview::UnifiedDiff(preview))
281 } else {
282 let content_lines: Vec<&str> = content.lines().collect();
284 let line_count = content_lines.len();
285 let preview_count = line_count.min(MAX_WRITE_NEW_LINES);
286 let first_lines: Vec<String> = content_lines[..preview_count]
287 .iter()
288 .map(|s| s.to_string())
289 .collect();
290 let truncated = line_count > MAX_WRITE_NEW_LINES;
291
292 Some(DiffPreview::WriteNew(WriteNewPreview {
293 path: path_str.to_string(),
294 line_count,
295 byte_count: content.len(),
296 first_lines,
297 truncated,
298 }))
299 }
300}
301
302async fn preview_delete(args: &serde_json::Value, project_root: &Path) -> Option<DiffPreview> {
303 let inner = args.get("payload").unwrap_or(args);
304 let path_str = inner
305 .get("path")
306 .or(inner.get("file_path"))
307 .and_then(|v| v.as_str())?;
308 let resolved = safe_resolve_path(project_root, path_str).ok()?;
309
310 if !resolved.exists() {
311 return Some(DiffPreview::PathNotFound);
312 }
313
314 let meta = tokio::fs::metadata(&resolved).await.ok()?;
315 if meta.is_file() {
316 let line_count = tokio::fs::read_to_string(&resolved)
317 .await
318 .map(|c| c.lines().count())
319 .unwrap_or(0);
320 Some(DiffPreview::DeleteFile(DeleteFilePreview {
321 line_count,
322 byte_count: meta.len(),
323 }))
324 } else if meta.is_dir() {
325 let recursive = args
326 .get("recursive")
327 .and_then(|v| v.as_bool())
328 .unwrap_or(false);
329 Some(DiffPreview::DeleteDir(DeleteDirPreview { recursive }))
330 } else {
331 None
332 }
333}
334
335#[cfg(test)]
338mod tests {
339 use super::*;
340 use serde_json::json;
341 use tempfile::TempDir;
342
343 #[tokio::test]
344 async fn test_edit_produces_unified_diff() {
345 let tmp = TempDir::new().unwrap();
346 let file = tmp.path().join("test.rs");
347 std::fs::write(&file, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
348
349 let args = json!({
350 "path": file.to_str().unwrap(),
351 "replacements": [{
352 "old_str": "println!(\"hello\");",
353 "new_str": "println!(\"world\");"
354 }]
355 });
356
357 let preview = compute("Edit", &args, tmp.path()).await.unwrap();
358 match preview {
359 DiffPreview::UnifiedDiff(diff) => {
360 assert_eq!(diff.hunks.len(), 1);
361 let hunk = &diff.hunks[0];
362 let tags: Vec<_> = hunk.lines.iter().map(|l| l.tag).collect();
364 assert!(tags.contains(&DiffTag::Delete));
365 assert!(tags.contains(&DiffTag::Insert));
366 assert!(tags.contains(&DiffTag::Context));
367 let del = hunk
369 .lines
370 .iter()
371 .find(|l| l.tag == DiffTag::Delete)
372 .unwrap();
373 assert!(del.content.contains("hello"));
374 let ins = hunk
376 .lines
377 .iter()
378 .find(|l| l.tag == DiffTag::Insert)
379 .unwrap();
380 assert!(ins.content.contains("world"));
381 }
382 other => panic!("expected UnifiedDiff, got {other:?}"),
383 }
384 }
385
386 #[tokio::test]
387 async fn test_edit_multiple_replacements() {
388 let tmp = TempDir::new().unwrap();
389 let file = tmp.path().join("test.rs");
390 let content: String = (1..=20).map(|i| format!("line {i}\n")).collect();
393 std::fs::write(&file, &content).unwrap();
394
395 let args = json!({
396 "path": file.to_str().unwrap(),
397 "replacements": [
398 { "old_str": "line 2", "new_str": "LINE TWO" },
399 { "old_str": "line 19", "new_str": "LINE NINETEEN" }
400 ]
401 });
402
403 let preview = compute("Edit", &args, tmp.path()).await.unwrap();
404 match preview {
405 DiffPreview::UnifiedDiff(diff) => {
406 assert_eq!(
407 diff.hunks.len(),
408 2,
409 "expected 2 hunks, got {:?}",
410 diff.hunks
411 );
412 }
413 other => panic!("expected UnifiedDiff, got {other:?}"),
414 }
415 }
416
417 #[tokio::test]
418 async fn test_write_new_file() {
419 let tmp = TempDir::new().unwrap();
420 let args = json!({
421 "path": "new_file.rs",
422 "content": "fn main() {}\n"
423 });
424
425 let preview = compute("Write", &args, tmp.path()).await.unwrap();
426 assert!(matches!(preview, DiffPreview::WriteNew(_)));
427 }
428
429 #[tokio::test]
430 async fn test_write_overwrite_produces_unified_diff() {
431 let tmp = TempDir::new().unwrap();
432 let file = tmp.path().join("existing.rs");
433 std::fs::write(&file, "old content\n").unwrap();
434
435 let args = json!({
436 "path": file.to_str().unwrap(),
437 "content": "new content\nline 2\n"
438 });
439
440 let preview = compute("Write", &args, tmp.path()).await.unwrap();
441 match preview {
442 DiffPreview::UnifiedDiff(diff) => {
443 assert!(!diff.hunks.is_empty());
444 }
445 other => panic!("expected UnifiedDiff for overwrite, got {other:?}"),
446 }
447 }
448
449 #[tokio::test]
450 async fn test_delete_file() {
451 let tmp = TempDir::new().unwrap();
452 let file = tmp.path().join("doomed.rs");
453 std::fs::write(&file, "goodbye\n").unwrap();
454
455 let args = json!({ "path": file.to_str().unwrap() });
456 let preview = compute("Delete", &args, tmp.path()).await.unwrap();
457 assert!(matches!(preview, DiffPreview::DeleteFile(_)));
458 }
459
460 #[tokio::test]
461 async fn test_unknown_tool_returns_none() {
462 let tmp = TempDir::new().unwrap();
463 let args = json!({"path": "anything.rs"});
464 let preview = compute("Read", &args, tmp.path()).await;
465 assert!(preview.is_none());
466 }
467
468 #[tokio::test]
469 async fn test_edit_missing_file() {
470 let tmp = TempDir::new().unwrap();
471 let args = json!({
472 "path": "nonexistent.rs",
473 "replacements": [{ "old_str": "x", "new_str": "y" }]
474 });
475 let preview = compute("Edit", &args, tmp.path()).await.unwrap();
476 assert!(matches!(preview, DiffPreview::FileNotYetExists));
477 }
478
479 #[tokio::test]
480 async fn test_unified_diff_has_line_numbers() {
481 let tmp = TempDir::new().unwrap();
482 let file = tmp.path().join("test.txt");
483 std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
484
485 let args = json!({
486 "path": file.to_str().unwrap(),
487 "replacements": [{ "old_str": "c", "new_str": "C" }]
488 });
489
490 let preview = compute("Edit", &args, tmp.path()).await.unwrap();
491 match preview {
492 DiffPreview::UnifiedDiff(diff) => {
493 let hunk = &diff.hunks[0];
494 for line in &hunk.lines {
496 assert!(
497 line.old_line.is_some() || line.new_line.is_some(),
498 "line should have a line number: {line:?}"
499 );
500 }
501 }
502 other => panic!("expected UnifiedDiff, got {other:?}"),
503 }
504 }
505}