use super::{normalize_display_path, normalize_path_key, truncate_for_prompt, FileChangeSummary};
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
const AI_DIFF_CHAR_LIMIT: usize = 6_000;
const MAX_LOCKFILE_SUMMARY_ITEMS: usize = 8;
const LONG_DIFF_LINE_LIMIT: usize = 220;
#[derive(Debug, Clone, Default)]
pub(super) struct PromptDiffExcerpt {
pub(super) excerpt: String,
pub(super) omitted_files: Vec<String>,
pub(super) summaries: Vec<String>,
}
pub(super) fn build_prompt_diff_excerpt(analysis: &super::WorktreeAnalysis) -> PromptDiffExcerpt {
let mut retained_records = Vec::new();
let mut omitted_files = Vec::new();
let mut summaries = Vec::new();
for record in split_diff_records(&analysis.diff_text) {
let Some(path) = extract_diff_record_path(&record) else {
retained_records.push(compact_retained_diff_record(&record));
continue;
};
if let Some(reason) = prompt_diff_omit_reason(&path) {
omitted_files.push(format_prompt_omitted_diff(&path, reason, &analysis.files));
summaries.extend(summarize_omitted_diff_record(
&path,
reason,
&record,
&analysis.files,
));
} else {
retained_records.push(compact_retained_diff_record(&record));
}
}
PromptDiffExcerpt {
excerpt: truncate_for_prompt(&retained_records.join(""), AI_DIFF_CHAR_LIMIT),
omitted_files,
summaries: dedupe_preserve_order(summaries),
}
}
pub(super) fn prompt_diff_omit_reason(path: &str) -> Option<&'static str> {
let normalized = normalize_display_path(path).to_ascii_lowercase();
let filename = Path::new(&normalized)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(normalized.as_str());
if matches!(
filename,
"cargo.lock"
| "pnpm-lock.yaml"
| "package-lock.json"
| "npm-shrinkwrap.json"
| "yarn.lock"
| "bun.lock"
| "bun.lockb"
| "deno.lock"
| "composer.lock"
| "poetry.lock"
| "pipfile.lock"
| "gemfile.lock"
| "uv.lock"
| "go.sum"
) {
return Some("lockfile");
}
if normalized.ends_with(".generated.ts")
|| normalized.ends_with(".generated.tsx")
|| normalized.ends_with(".generated.js")
|| normalized.ends_with(".gen.ts")
|| normalized.ends_with(".gen.tsx")
|| normalized.ends_with(".gen.js")
|| normalized.ends_with("routetree.gen.ts")
|| normalized.ends_with("worker-configuration.d.ts")
{
return Some("generated file");
}
None
}
fn split_diff_records(diff_text: &str) -> Vec<String> {
let mut records = Vec::new();
let mut current = String::new();
for line in diff_text.lines() {
if line.starts_with("diff --git ") && !current.is_empty() {
records.push(current);
current = String::new();
}
if line.starts_with("diff --git ") || !current.is_empty() {
current.push_str(line);
current.push('\n');
}
}
if !current.is_empty() {
records.push(current);
}
records
}
fn extract_diff_record_path(record: &str) -> Option<String> {
for line in record.lines() {
if let Some(path) = line.strip_prefix("+++ b/") {
let normalized = normalize_display_path(path);
if normalized != "/dev/null" {
return Some(normalized);
}
}
}
for line in record.lines() {
if let Some(path) = line.strip_prefix("--- a/") {
let normalized = normalize_display_path(path);
if normalized != "/dev/null" {
return Some(normalized);
}
}
}
record.lines().find_map(|line| {
let rest = line.strip_prefix("diff --git ")?;
let path = rest.split(" b/").nth(1)?;
Some(normalize_display_path(path))
})
}
fn compact_retained_diff_record(record: &str) -> String {
let mut lines = Vec::new();
let mut duplicate_counts = BTreeMap::<String, usize>::new();
let mut omitted_duplicates = 0usize;
for line in record.lines() {
let original_is_long = line.chars().count() >= LONG_DIFF_LINE_LIMIT;
let compacted = compact_prompt_diff_line(line);
let key = canonical_prompt_diff_line(&compacted);
if original_is_long {
let count = duplicate_counts.entry(key).or_default();
*count += 1;
if *count > 1 {
omitted_duplicates += 1;
continue;
}
}
lines.push(compacted);
}
if omitted_duplicates > 0 {
lines.push(format!(
"[{} duplicate long diff line(s) omitted]",
omitted_duplicates
));
}
if lines.is_empty() {
String::new()
} else {
format!("{}\n", lines.join("\n"))
}
}
fn compact_prompt_diff_line(line: &str) -> String {
let mut compacted = replace_integrity_value(line);
if compacted.chars().count() > LONG_DIFF_LINE_LIMIT {
compacted = compact_parenthesized_dependency_suffix(&compacted);
}
if compacted.chars().count() > LONG_DIFF_LINE_LIMIT {
compacted = truncate_diff_line_middle(&compacted, LONG_DIFF_LINE_LIMIT);
}
compacted
}
fn replace_integrity_value(line: &str) -> String {
let Some(start) = line.find("integrity:") else {
return line.to_string();
};
let Some(sha_start) = line[start..].find("sha") else {
return line.to_string();
};
let value_start = start + sha_start;
let mut value_end = value_start;
for (offset, ch) in line[value_start..].char_indices() {
if ch == ',' || ch == '}' || ch.is_whitespace() {
break;
}
value_end = value_start + offset + ch.len_utf8();
}
format!("{}<integrity>{}", &line[..value_start], &line[value_end..])
}
fn compact_parenthesized_dependency_suffix(line: &str) -> String {
let Some(paren_start) = line.find('(') else {
return line.to_string();
};
let Some(paren_end) = line.rfind(')') else {
return line.to_string();
};
if paren_end <= paren_start {
return line.to_string();
}
format!(
"{}(...peer deps omitted){}",
&line[..paren_start],
&line[paren_end + 1..]
)
}
fn truncate_diff_line_middle(line: &str, limit: usize) -> String {
if line.chars().count() <= limit {
return line.to_string();
}
let head_len = limit.saturating_sub(24) / 2;
let tail_len = limit.saturating_sub(24 + head_len);
let head = line.chars().take(head_len).collect::<String>();
let tail = line
.chars()
.rev()
.take(tail_len)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<String>();
format!("{}...[line compacted]...{}", head, tail)
}
fn canonical_prompt_diff_line(line: &str) -> String {
line.trim().split_whitespace().collect::<Vec<_>>().join(" ")
}
fn format_prompt_omitted_diff(path: &str, reason: &str, files: &[FileChangeSummary]) -> String {
let normalized_path = normalize_display_path(path);
let key = normalize_path_key(&normalized_path);
let summary = files
.iter()
.find(|file| {
normalize_path_key(&file.path) == key
|| normalize_display_path(&file.path) == normalized_path
})
.map(|file| format!("+{} -{}", file.additions, file.deletions))
.unwrap_or_else(|| "+0 -0".to_string());
format!("{} ({}; {})", normalized_path, summary, reason)
}
fn summarize_omitted_diff_record(
path: &str,
reason: &str,
record: &str,
files: &[FileChangeSummary],
) -> Vec<String> {
let mut summaries = vec![format_prompt_omitted_diff(path, reason, files)];
if reason == "lockfile" {
summaries.extend(summarize_lockfile_diff(path, record));
}
summaries
}
fn summarize_lockfile_diff(path: &str, record: &str) -> Vec<String> {
let normalized = normalize_display_path(path).to_ascii_lowercase();
if normalized.ends_with("cargo.lock") {
summarize_cargo_lock_diff(record)
} else {
summarize_package_lock_diff(record)
}
}
fn summarize_package_lock_diff(record: &str) -> Vec<String> {
let mut added_entries = BTreeSet::new();
let mut removed_entries = BTreeSet::new();
let mut added_versions = Vec::new();
let mut removed_versions = Vec::new();
for line in record.lines() {
let Some((sign, body)) = diff_line_body(line) else {
continue;
};
let trimmed = body.trim();
if let Some(entry) = parse_package_lock_entry(trimmed) {
match sign {
'+' => {
added_entries.insert(entry);
}
'-' => {
removed_entries.insert(entry);
}
_ => {}
}
continue;
}
if let Some(version) = parse_lock_value_line(trimmed) {
match sign {
'+' => added_versions.push(version),
'-' => removed_versions.push(version),
_ => {}
}
}
}
let mut summaries = Vec::new();
summaries.extend(format_version_shift_summary(
"version shifts",
&removed_versions,
&added_versions,
));
summaries.extend(format_added_removed_summary(
"package entries",
&removed_entries,
&added_entries,
));
summaries
}
fn summarize_cargo_lock_diff(record: &str) -> Vec<String> {
let mut added_names = Vec::new();
let mut removed_names = Vec::new();
let mut added_versions = Vec::new();
let mut removed_versions = Vec::new();
for line in record.lines() {
let Some((sign, body)) = diff_line_body(line) else {
continue;
};
let trimmed = body.trim();
if let Some(name) = parse_quoted_assignment(trimmed, "name") {
match sign {
'+' => added_names.push(name),
'-' => removed_names.push(name),
_ => {}
}
} else if let Some(version) = parse_quoted_assignment(trimmed, "version") {
match sign {
'+' => added_versions.push(version),
'-' => removed_versions.push(version),
_ => {}
}
}
}
let added_entries = added_names
.into_iter()
.zip(
added_versions
.iter()
.cloned()
.chain(std::iter::repeat(String::new())),
)
.map(|(name, version)| {
if version.is_empty() {
name
} else {
format!("{}@{}", name, version)
}
})
.collect::<BTreeSet<_>>();
let removed_entries = removed_names
.into_iter()
.zip(
removed_versions
.iter()
.cloned()
.chain(std::iter::repeat(String::new())),
)
.map(|(name, version)| {
if version.is_empty() {
name
} else {
format!("{}@{}", name, version)
}
})
.collect::<BTreeSet<_>>();
let mut summaries = Vec::new();
summaries.extend(format_version_shift_summary(
"version shifts",
&removed_versions,
&added_versions,
));
summaries.extend(format_added_removed_summary(
"cargo packages",
&removed_entries,
&added_entries,
));
summaries
}
fn diff_line_body(line: &str) -> Option<(char, &str)> {
let sign = line.chars().next()?;
if !matches!(sign, '+' | '-') || line.starts_with("+++") || line.starts_with("---") {
return None;
}
Some((sign, &line[1..]))
}
fn parse_package_lock_entry(trimmed: &str) -> Option<String> {
let entry = trimmed
.strip_suffix(':')?
.trim()
.trim_matches('"')
.trim_matches('\'');
if entry.is_empty()
|| matches!(
entry,
"importers" | "packages" | "snapshots" | "dependencies" | "devDependencies"
)
|| !entry.contains('@')
{
return None;
}
Some(compact_lock_entry_label(entry))
}
fn compact_lock_entry_label(entry: &str) -> String {
let compacted = compact_parenthesized_dependency_suffix(entry);
truncate_diff_line_middle(&compacted, 120)
}
fn parse_lock_value_line(trimmed: &str) -> Option<String> {
let value = trimmed
.strip_prefix("specifier:")
.or_else(|| trimmed.strip_prefix("version:"))?
.trim()
.trim_matches('"')
.trim_matches('\'');
if value.is_empty() || value.contains("sha512-") {
return None;
}
Some(truncate_diff_line_middle(
&compact_parenthesized_dependency_suffix(value),
120,
))
}
fn parse_quoted_assignment(trimmed: &str, key: &str) -> Option<String> {
let prefix = format!("{} = ", key);
let value = trimmed.strip_prefix(&prefix)?.trim();
Some(value.trim_matches('"').to_string())
}
fn format_version_shift_summary(
label: &str,
removed_values: &[String],
added_values: &[String],
) -> Vec<String> {
let mut shifts = BTreeSet::new();
for (removed, added) in removed_values.iter().zip(added_values.iter()) {
if removed != added {
shifts.insert(format!("{} -> {}", removed, added));
}
}
if shifts.is_empty() {
Vec::new()
} else {
vec![format!(
"{}: {}",
label,
summarize_set_items(&shifts, MAX_LOCKFILE_SUMMARY_ITEMS)
)]
}
}
fn format_added_removed_summary(
label: &str,
removed_values: &BTreeSet<String>,
added_values: &BTreeSet<String>,
) -> Vec<String> {
let mut summaries = Vec::new();
let added_only = added_values
.difference(removed_values)
.cloned()
.collect::<BTreeSet<_>>();
let removed_only = removed_values
.difference(added_values)
.cloned()
.collect::<BTreeSet<_>>();
if !added_only.is_empty() {
summaries.push(format!(
"{} added: {}",
label,
summarize_set_items(&added_only, MAX_LOCKFILE_SUMMARY_ITEMS)
));
}
if !removed_only.is_empty() {
summaries.push(format!(
"{} removed: {}",
label,
summarize_set_items(&removed_only, MAX_LOCKFILE_SUMMARY_ITEMS)
));
}
summaries
}
fn summarize_set_items(items: &BTreeSet<String>, limit: usize) -> String {
let mut values = items.iter().take(limit).cloned().collect::<Vec<_>>();
if items.len() > limit {
values.push(format!("... {} more", items.len() - limit));
}
values.join(", ")
}
fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> {
let mut seen = BTreeSet::new();
let mut deduped = Vec::new();
for value in values {
if seen.insert(value.clone()) {
deduped.push(value);
}
}
deduped
}