Skip to main content

koda_core/
preview.rs

1//! Pre-confirmation diff previews for destructive tool operations.
2//!
3//! Computes **structured** preview data before the user confirms an Edit,
4//! Write, or Delete.  The actual rendering (colors, syntax highlighting)
5//! is the client's responsibility — koda-core never emits ANSI codes.
6//!
7//! Edit and Write-overwrite produce a proper unified diff (via `similar`)
8//! with context lines and hunk headers — the same information you'd see
9//! in `git diff` output.
10
11use crate::tools::safe_resolve_path;
12use similar::{ChangeTag, TextDiff};
13use std::path::Path;
14
15/// Number of context lines around each change (like `diff -U3`).
16const CONTEXT_LINES: usize = 3;
17
18/// Maximum total diff lines before we truncate.
19const MAX_DIFF_LINES: usize = 120;
20
21/// Maximum lines shown for a new-file preview.
22const MAX_WRITE_NEW_LINES: usize = 60;
23
24// ── Data types ────────────────────────────────────────────────
25
26/// Structured diff preview produced by the engine.
27///
28/// Clients render this however they want (ratatui, HTML, plain text, …).
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30#[serde(tag = "kind")]
31pub enum DiffPreview {
32    /// Unified diff with context lines and hunk headers.
33    /// Used for both Edit and Write-overwrite.
34    UnifiedDiff(UnifiedDiffPreview),
35    /// New file creation.
36    WriteNew(WriteNewPreview),
37    /// Single file deletion.
38    DeleteFile(DeleteFilePreview),
39    /// Directory deletion.
40    DeleteDir(DeleteDirPreview),
41    /// Target file doesn't exist yet (for Edit on missing file).
42    FileNotYetExists,
43    /// Target path not found.
44    PathNotFound,
45}
46
47/// A proper unified diff between old and new file content.
48#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
49pub struct UnifiedDiffPreview {
50    /// File path (as given in the tool args).
51    pub path: String,
52    /// Full old file content (for syntax-context highlighting).
53    pub old_content: String,
54    /// Full new file content (for syntax-context highlighting).
55    pub new_content: String,
56    /// Diff hunks with context.
57    pub hunks: Vec<DiffHunk>,
58    /// Whether hunks were truncated due to size limits.
59    pub truncated: bool,
60}
61
62/// A single hunk in a unified diff.
63#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
64pub struct DiffHunk {
65    /// 1-based starting line in the old file.
66    pub old_start: usize,
67    /// Number of lines from the old file in this hunk.
68    pub old_count: usize,
69    /// 1-based starting line in the new file.
70    pub new_start: usize,
71    /// Number of lines from the new file in this hunk.
72    pub new_count: usize,
73    /// The lines in this hunk (context + insertions + deletions).
74    pub lines: Vec<DiffLine>,
75}
76
77/// A single line within a diff hunk.
78#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
79pub struct DiffLine {
80    /// Whether this line is context, inserted, or deleted.
81    pub tag: DiffTag,
82    /// The line content (without trailing newline).
83    pub content: String,
84    /// Line number in the old file (for Context/Delete lines).
85    pub old_line: Option<usize>,
86    /// Line number in the new file (for Context/Insert lines).
87    pub new_line: Option<usize>,
88}
89
90/// The type of a diff line.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
92pub enum DiffTag {
93    /// Unchanged context line.
94    Context,
95    /// Line was added.
96    Insert,
97    /// Line was removed.
98    Delete,
99}
100
101/// Preview of a Write (new file) operation.
102#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
103pub struct WriteNewPreview {
104    /// File path.
105    pub path: String,
106    /// Total line count of the new file.
107    pub line_count: usize,
108    /// Total byte count.
109    pub byte_count: usize,
110    /// First lines (for preview display).
111    pub first_lines: Vec<String>,
112    /// Whether `first_lines` was truncated.
113    pub truncated: bool,
114}
115
116/// Preview of a single file deletion.
117#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
118pub struct DeleteFilePreview {
119    /// Line count of the file being deleted.
120    pub line_count: usize,
121    /// Byte count of the file being deleted.
122    pub byte_count: u64,
123}
124
125/// Preview of a directory deletion.
126#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
127pub struct DeleteDirPreview {
128    /// Whether the deletion is recursive.
129    pub recursive: bool,
130}
131
132// ── Compute ───────────────────────────────────────────────────
133
134/// Compute a structured diff preview for a tool action.
135///
136/// Returns `None` for tools that don't need a preview.
137pub 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
150/// Build a unified diff from old and new content.
151///
152/// Shared by Edit and Write-overwrite paths.
153fn 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; // 1-based
170                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    // Apply all replacements sequentially to produce new_content
244    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        // Replace first occurrence only (matches Edit tool behavior)
252        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        // Overwrite → produce a real unified diff
272        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        // New file → show content preview
277        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// ── Tests ─────────────────────────────────────────────────────
330
331#[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                // Should have context + delete + insert
357                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                // Deleted line should contain "hello"
362                let del = hunk
363                    .lines
364                    .iter()
365                    .find(|l| l.tag == DiffTag::Delete)
366                    .unwrap();
367                assert!(del.content.contains("hello"));
368                // Inserted line should contain "world"
369                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        // 20 lines — changes at line 2 and 19 are >6 lines apart,
385        // so with 3 context lines they produce separate hunks.
386        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                // Every line should have at least one line number
489                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        // Create content with more lines than MAX_WRITE_NEW_LINES (60)
541        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        // Produce a diff with more than MAX_DIFF_LINES (120) changed lines
558        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}