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            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            // Suppress unused-variable warnings — we use iter_changes instead.
208            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    // Apply all replacements sequentially to produce new_content
250    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        // Replace first occurrence only (matches Edit tool behavior)
258        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        // Overwrite → produce a real unified diff
278        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        // New file → show content preview
283        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// ── Tests ─────────────────────────────────────────────────────
336
337#[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                // Should have context + delete + insert
363                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                // Deleted line should contain "hello"
368                let del = hunk
369                    .lines
370                    .iter()
371                    .find(|l| l.tag == DiffTag::Delete)
372                    .unwrap();
373                assert!(del.content.contains("hello"));
374                // Inserted line should contain "world"
375                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        // 20 lines — changes at line 2 and 19 are >6 lines apart,
391        // so with 3 context lines they produce separate hunks.
392        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                // Every line should have at least one line number
495                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}