Files
vibe-kanban/crates/utils/src/diff.rs
2025-09-29 16:16:34 +01:00

263 lines
7.6 KiB
Rust

use std::borrow::Cow;
use serde::{Deserialize, Serialize};
use similar::{ChangeTag, TextDiff};
use ts_rs::TS;
// Structs compatable with props: https://github.com/MrWangJustToDo/git-diff-view
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct FileDiffDetails {
pub file_name: Option<String>,
pub content: Option<String>,
}
// Worktree diffs for the diffs tab: minimal, no hunks, optional full contents
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct Diff {
pub change: DiffChangeKind,
pub old_path: Option<String>,
pub new_path: Option<String>,
pub old_content: Option<String>,
pub new_content: Option<String>,
/// True when file contents are intentionally omitted (e.g., too large)
pub content_omitted: bool,
/// Optional precomputed stats for omitted content
pub additions: Option<usize>,
pub deletions: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub enum DiffChangeKind {
Added,
Deleted,
Modified,
Renamed,
Copied,
PermissionChange,
}
// ==============================
// Unified diff utility functions
// ==============================
/// Converts a replace diff to a unified diff hunk without the hunk header.
/// The hunk returned will have valid hunk, and diff lines.
pub fn create_unified_diff_hunk(old: &str, new: &str) -> String {
// normalize ending line feed to optimize diff output
let mut old = old.to_string();
let mut new = new.to_string();
if !old.ends_with('\n') {
old.push('\n');
}
if !new.ends_with('\n') {
new.push('\n');
}
let diff = TextDiff::from_lines(&old, &new);
let mut out = String::new();
// We need a valud hunk header. assume lines are 0. but - + count will be correct.
let old_count = diff.old_slices().len();
let new_count = diff.new_slices().len();
out.push_str(&format!("@@ -1,{old_count} +1,{new_count} @@\n"));
for change in diff.iter_all_changes() {
let sign = match change.tag() {
ChangeTag::Equal => ' ',
ChangeTag::Delete => '-',
ChangeTag::Insert => '+',
};
let val = change.value();
out.push(sign);
out.push_str(val);
}
out
}
/// Creates a full unified diff with the file path in the header.
pub fn create_unified_diff(file_path: &str, old: &str, new: &str) -> String {
let mut out = String::new();
out.push_str(format!("--- a/{file_path}\n+++ b/{file_path}\n").as_str());
out.push_str(&create_unified_diff_hunk(old, new));
out
}
/// Compute addition/deletion counts between two text snapshots.
pub fn compute_line_change_counts(old: &str, new: &str) -> (usize, usize) {
let old = ensure_newline(old);
let new = ensure_newline(new);
let diff = TextDiff::from_lines(&old, &new);
let mut additions = 0usize;
let mut deletions = 0usize;
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Insert => additions += 1,
ChangeTag::Delete => deletions += 1,
ChangeTag::Equal => {}
}
}
(additions, deletions)
}
// ensure a line ends with a newline character
fn ensure_newline(line: &str) -> Cow<'_, str> {
if line.ends_with('\n') {
Cow::Borrowed(line)
} else {
let mut owned = line.to_owned();
owned.push('\n');
Cow::Owned(owned)
}
}
/// Extracts unified diff hunks from a string containing a full unified diff.
/// Tolerates non-diff lines and missing `@@`` hunk headers.
pub fn extract_unified_diff_hunks(unified_diff: &str) -> Vec<String> {
let lines = unified_diff.split_inclusive('\n').collect::<Vec<_>>();
if !lines.iter().any(|l| l.starts_with("@@")) {
// No @@ hunk headers: treat as a single hunk
let hunk = lines
.iter()
.copied()
.filter(|line| line.starts_with([' ', '+', '-']))
.collect::<String>();
let old_count = lines
.iter()
.filter(|line| line.starts_with(['-', ' ']))
.count();
let new_count = lines
.iter()
.filter(|line| line.starts_with(['+', ' ']))
.count();
return if hunk.is_empty() {
vec![]
} else {
vec![format!("@@ -1,{old_count} +1,{new_count} @@\n{hunk}")]
};
}
let mut hunks = vec![];
let mut current_hunk: Option<String> = None;
// Collect hunks starting with @@ headers
for line in lines {
if line.starts_with("@@") {
// new hunk starts
if let Some(hunk) = current_hunk.take() {
// flush current hunk
if !hunk.is_empty() {
hunks.push(hunk);
}
}
current_hunk = Some(line.to_string());
} else if let Some(ref mut hunk) = current_hunk {
if line.starts_with([' ', '+', '-']) {
// hunk content
hunk.push_str(line);
} else {
// unkown line, flush current hunk
if !hunk.is_empty() {
hunks.push(hunk.clone());
}
current_hunk = None;
}
}
}
// we have reached the end. flush the last hunk if it exists
if let Some(hunk) = current_hunk
&& !hunk.is_empty()
{
hunks.push(hunk);
}
// Fix hunk headers if they are empty @@\n
hunks = fix_hunk_headers(hunks);
hunks
}
// Helper function to ensure valid hunk headers
fn fix_hunk_headers(hunks: Vec<String>) -> Vec<String> {
if hunks.is_empty() {
return hunks;
}
let mut new_hunks = Vec::new();
// if hunk header is empty @@\n, ten we need to replace it with a valid header
for hunk in hunks {
let mut lines = hunk
.split_inclusive('\n')
.map(str::to_string)
.collect::<Vec<_>>();
if lines.len() < 2 {
// empty hunk, skip
continue;
}
let header = &lines[0];
if !header.starts_with("@@") {
// no header, skip
continue;
}
if header.trim() == "@@" {
// empty header, replace with a valid one
lines.remove(0);
let old_count = lines
.iter()
.filter(|line| line.starts_with(['-', ' ']))
.count();
let new_count = lines
.iter()
.filter(|line| line.starts_with(['+', ' ']))
.count();
let new_header = format!("@@ -1,{old_count} +1,{new_count} @@");
lines.insert(0, new_header);
new_hunks.push(lines.join(""));
} else {
// valid header, keep as is
new_hunks.push(hunk);
}
}
new_hunks
}
/// Creates a full unified diff with the file path in the header,
pub fn concatenate_diff_hunks(file_path: &str, hunks: &[String]) -> String {
let mut unified_diff = String::new();
let header = format!("--- a/{file_path}\n+++ b/{file_path}\n");
unified_diff.push_str(&header);
if !hunks.is_empty() {
let lines = hunks
.iter()
.flat_map(|hunk| hunk.lines())
.filter(|line| line.starts_with("@@ ") || line.starts_with([' ', '+', '-']))
.collect::<Vec<_>>();
unified_diff.push_str(lines.join("\n").as_str());
if !unified_diff.ends_with('\n') {
unified_diff.push('\n');
}
}
unified_diff
}