Skip to main content

binocular/preview/
diff.rs

1use crate::preview::{DiffPreview, PreviewContent};
2use ratatui::style::{Color, Style};
3use ratatui::text::{Line, Span, Text};
4use similar::{ChangeTag, DiffOp, TextDiff};
5use std::fs;
6use std::path::Path;
7
8pub fn build_diff_preview(left_path: &str, right_path: &str) -> PreviewContent {
9    let left = match read_text_file(left_path) {
10        Ok(content) => content,
11        Err(message) => return PreviewContent::PlainText(Text::from(message)),
12    };
13    let right = match read_text_file(right_path) {
14        Ok(content) => content,
15        Err(message) => return PreviewContent::PlainText(Text::from(message)),
16    };
17
18    let diff = TextDiff::from_lines(&left, &right);
19    let mut lines = Vec::new();
20    lines.push(Line::from(vec![
21        Span::styled("--- ", Style::default().fg(Color::Red)),
22        Span::raw(left_path.to_string()),
23    ]));
24    lines.push(Line::from(vec![
25        Span::styled("+++ ", Style::default().fg(Color::Green)),
26        Span::raw(right_path.to_string()),
27    ]));
28    lines.push(Line::default());
29
30    for group in diff.grouped_ops(3) {
31        lines.push(build_hunk_header(&group));
32        for op in group {
33            render_op_lines(&diff, &op, &mut lines);
34        }
35    }
36
37    PreviewContent::Diff(DiffPreview {
38        text: Text::from(lines),
39    })
40}
41
42/// Maximum file size to load for diff preview (50 MiB).
43const MAX_DIFF_FILE_SIZE: u64 = 50 * 1024 * 1024;
44
45fn read_text_file(path: &str) -> Result<String, String> {
46    let path_obj = Path::new(path);
47
48    // SECURITY: reject files that are too large to prevent OOM.
49    let metadata = fs::metadata(path_obj)
50        .map_err(|err| format!("Failed to read {}: {}", path_obj.display(), err))?;
51    if metadata.len() > MAX_DIFF_FILE_SIZE {
52        return Err(format!(
53            "{} is too large to diff ({} > {} MiB)",
54            path_obj.display(),
55            metadata.len() / (1024 * 1024),
56            MAX_DIFF_FILE_SIZE / (1024 * 1024)
57        ));
58    }
59
60    let bytes = fs::read(path_obj)
61        .map_err(|err| format!("Failed to read {}: {}", path_obj.display(), err))?;
62
63    if bytes.contains(&0)
64        || crate::text::proportion_of_printable_ascii_characters(&bytes)
65            < crate::text::PRINTABLE_ASCII_THRESHOLD
66    {
67        return Err(format!("{} is not a text file", path_obj.display()));
68    }
69
70    if let Ok(content) = String::from_utf8(bytes.clone()) {
71        return Ok(content);
72    }
73
74    if let Some(decoded) = crate::preview::encoding::try_decode_utf16(&bytes) {
75        return Ok(decoded);
76    }
77
78    Ok(String::from_utf8_lossy(&bytes).into_owned())
79}
80
81fn build_hunk_header(group: &[DiffOp]) -> Line<'static> {
82    let old_start = group
83        .iter()
84        .map(|op| op.old_range().start)
85        .min()
86        .unwrap_or(0)
87        + 1;
88    let old_end = group.iter().map(|op| op.old_range().end).max().unwrap_or(0);
89    let new_start = group
90        .iter()
91        .map(|op| op.new_range().start)
92        .min()
93        .unwrap_or(0)
94        + 1;
95    let new_end = group.iter().map(|op| op.new_range().end).max().unwrap_or(0);
96    let old_len = old_end.saturating_sub(old_start.saturating_sub(1));
97    let new_len = new_end.saturating_sub(new_start.saturating_sub(1));
98
99    Line::from(vec![Span::styled(
100        format!(
101            "@@ -{},{} +{},{} @@",
102            old_start, old_len, new_start, new_len
103        ),
104        Style::default().fg(Color::Cyan),
105    )])
106}
107
108fn render_op_lines<'a>(
109    diff: &'a TextDiff<'a, 'a, str>,
110    op: &DiffOp,
111    lines: &mut Vec<Line<'static>>,
112) {
113    for change in diff.iter_inline_changes(op) {
114        let (sign, base_style, emphasize_style) = match change.tag() {
115            ChangeTag::Delete => (
116                "-",
117                Style::default().fg(Color::Red),
118                Style::default().fg(Color::Black).bg(Color::Red),
119            ),
120            ChangeTag::Insert => (
121                "+",
122                Style::default().fg(Color::Green),
123                Style::default().fg(Color::Black).bg(Color::Green),
124            ),
125            ChangeTag::Equal => (
126                " ",
127                Style::default().fg(Color::DarkGray),
128                Style::default().fg(Color::DarkGray),
129            ),
130        };
131
132        let old_line = format_line_number(change.old_index());
133        let new_line = format_line_number(change.new_index());
134        let gutter_style = Style::default().fg(Color::DarkGray);
135        let mut spans = vec![
136            Span::styled(format!("{} {} | ", old_line, new_line), gutter_style),
137            Span::styled(sign, base_style),
138        ];
139        for (emphasized, value) in change.iter_strings_lossy() {
140            let style = if emphasized {
141                emphasize_style
142            } else {
143                base_style
144            };
145            for segment in value.split_inclusive('\n') {
146                let segment = segment.trim_end_matches('\n');
147                if !segment.is_empty() {
148                    spans.push(Span::styled(segment.to_string(), style));
149                }
150                if value.ends_with('\n') {
151                    lines.push(Line::from(std::mem::take(&mut spans)));
152                    spans.push(Span::styled(
153                        format!("{} {} | ", old_line, new_line),
154                        gutter_style,
155                    ));
156                    spans.push(Span::styled(sign, base_style));
157                }
158            }
159        }
160
161        if spans.len() > 1 {
162            lines.push(Line::from(spans));
163        }
164    }
165}
166
167fn format_line_number(index: Option<usize>) -> String {
168    index
169        .map(|value| format!("{:>4}", value + 1))
170        .unwrap_or_else(|| "    ".to_string())
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use std::path::PathBuf;
177    use std::time::{SystemTime, UNIX_EPOCH};
178
179    fn unique_temp_path(name: &str) -> PathBuf {
180        let nanos = SystemTime::now()
181            .duration_since(UNIX_EPOCH)
182            .unwrap_or_default()
183            .as_nanos();
184        std::env::temp_dir().join(format!("binocular-diff-{name}-{nanos}.txt"))
185    }
186
187    #[test]
188    fn diff_preview_renders_changed_lines() {
189        let left = unique_temp_path("left");
190        let right = unique_temp_path("right");
191        std::fs::write(&left, "alpha\nbeta\n").unwrap();
192        std::fs::write(&right, "alpha\ngamma\n").unwrap();
193
194        let preview = build_diff_preview(&left.display().to_string(), &right.display().to_string());
195        let PreviewContent::Diff(diff) = preview else {
196            panic!("expected diff preview");
197        };
198
199        let rendered = diff
200            .text
201            .lines
202            .iter()
203            .map(|line| line.to_string())
204            .collect::<Vec<_>>()
205            .join("\n");
206        assert!(rendered.contains("--- "));
207        assert!(rendered.contains("+++ "));
208        assert!(rendered.contains("@@ -1,2 +1,2 @@"));
209        assert!(rendered.contains("   2      | -beta") || rendered.contains("   2    2 | -beta"));
210        assert!(rendered.contains("      2 | +gamma") || rendered.contains("   2    2 | +gamma"));
211
212        let _ = std::fs::remove_file(left);
213        let _ = std::fs::remove_file(right);
214    }
215}