use std::time::Duration;
use agent_client_protocol::schema::v1::{Diff, ToolCallContent};
use serde::{Deserialize, Serialize};
use similar::TextDiff;
pub const DIFF_PATCH_META_KEY: &str = "dev.mj.diffPatch";
pub const DIFF_PATCH_BYTES: usize = 128 * 1024;
const CONTEXT_RADIUS: usize = 3;
const DIFF_DEADLINE: Duration = Duration::from_millis(250);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiffPatch {
pub text: String,
pub insertions: usize,
pub deletions: usize,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub created: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub truncated: bool,
}
pub fn compact_diff(diff: &mut Diff) -> bool {
if diff.old_text.is_none() && diff.new_text.is_empty() {
return false;
}
let patch = build_patch(
&diff.path.display().to_string(),
diff.old_text.as_deref(),
&diff.new_text,
);
let Ok(value) = serde_json::to_value(&patch) else {
return false;
};
diff.meta
.get_or_insert_with(Default::default)
.insert(DIFF_PATCH_META_KEY.to_owned(), value);
diff.old_text = None;
diff.new_text = String::new();
true
}
pub fn compact_tool_call_content(content: &mut [ToolCallContent]) -> bool {
content
.iter_mut()
.fold(false, |compacted, item| match item {
ToolCallContent::Diff(diff) => compact_diff(diff) || compacted,
_ => compacted,
})
}
pub fn drop_patch_text(diff: &mut Diff) -> bool {
let mut changed = compact_diff(diff);
let Some(mut patch) = stored_patch(diff) else {
return changed;
};
if patch.text.is_empty() && patch.truncated {
return changed;
}
patch.text = String::new();
patch.truncated = true;
let Ok(value) = serde_json::to_value(&patch) else {
return changed;
};
diff.meta
.get_or_insert_with(Default::default)
.insert(DIFF_PATCH_META_KEY.to_owned(), value);
changed = true;
changed
}
#[must_use]
pub fn stored_patch(diff: &Diff) -> Option<DiffPatch> {
let value = diff.meta.as_ref()?.get(DIFF_PATCH_META_KEY)?;
match serde_json::from_value(value.clone()) {
Ok(patch) => Some(patch),
Err(error) => {
tracing::warn!(%error, "could not read a stored diff patch");
None
}
}
}
#[must_use]
pub fn patch_of(diff: &Diff) -> DiffPatch {
stored_patch(diff).unwrap_or_else(|| {
build_patch(
&diff.path.display().to_string(),
diff.old_text.as_deref(),
&diff.new_text,
)
})
}
fn build_patch(path: &str, old_text: Option<&str>, new_text: &str) -> DiffPatch {
let created = old_text.is_none();
let relative = path.trim_start_matches('/');
let old_header = if created {
"/dev/null".to_owned()
} else {
format!("a/{relative}")
};
let old_text = old_text.unwrap_or_default();
let changes = TextDiff::configure()
.timeout(DIFF_DEADLINE)
.diff_lines(old_text, new_text);
let (insertions, deletions) =
changes
.iter_all_changes()
.fold((0, 0), |(insertions, deletions), change| {
match change.tag() {
similar::ChangeTag::Insert => (insertions + 1, deletions),
similar::ChangeTag::Delete => (insertions, deletions + 1),
similar::ChangeTag::Equal => (insertions, deletions),
}
});
let mut unified = changes.unified_diff();
unified.context_radius(CONTEXT_RADIUS);
let mut text = format!("--- {old_header}\n+++ b/{relative}\n");
let mut truncated = false;
for hunk in unified.iter_hunks() {
let rendered = hunk.to_string();
if text.len() + rendered.len() > DIFF_PATCH_BYTES {
truncated = true;
break;
}
text.push_str(&rendered);
}
if truncated {
text.push_str("[mj dropped the remaining hunks]\n");
}
DiffPatch {
text,
insertions,
deletions,
created,
truncated,
}
}
#[cfg(test)]
mod tests;