use std::io::{self, IsTerminal, Write};
use std::path::Path;
use difflore_core::context::retrieval::detect_language_from_path;
use difflore_core::contract::{RecordAcceptedEditRequest, RecordAcceptedEditResponse};
use difflore_core::observability::fix_outcomes::FixOutcomeInput;
use difflore_core::review_engine::ReviewIssueRecord;
use crate::style::{self, sym};
use super::errors::GIT_SPAWN_FAILURE as GIT_SPAWN_PREFIX;
use super::{file_loc, fix_debug};
#[derive(Debug, Default)]
pub(super) struct ApplyOutcome {
pub(super) applied: Vec<OutcomeIssue>,
pub(super) failed: Vec<(OutcomeIssue, String)>,
pub(super) accepted_edits: Vec<AcceptedEditProof>,
}
#[derive(Debug, Clone)]
pub(super) struct AcceptedEditProof {
pub(super) file_path: String,
pub(super) before_code: String,
pub(super) after_code: String,
pub(super) language: Option<String>,
pub(super) diff_signature: String,
pub(super) rule_ids: Vec<String>,
}
#[derive(Debug, Clone)]
pub(super) struct OutcomeIssue {
pub(super) rule_id: Option<String>,
pub(super) rule_name: String,
pub(super) file_path: Option<String>,
pub(super) file_loc: String,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct AcceptedEditUploadSummary {
queued: usize,
uploaded: usize,
linked_observations: usize,
missing_rule_ids: usize,
missing_target_pr: usize,
missing_team: usize,
missing_rule_observation: usize,
}
const fn record_accepted_edit_upload_queued(
summary: &mut AcceptedEditUploadSummary,
expected_rule_ids: usize,
has_target_pr: bool,
) {
summary.queued += 1;
if expected_rule_ids == 0 {
summary.missing_rule_ids += 1;
}
if !has_target_pr {
summary.missing_target_pr += 1;
}
}
const fn record_accepted_edit_upload_response(
summary: &mut AcceptedEditUploadSummary,
expected_rule_ids: usize,
response: &RecordAcceptedEditResponse,
) {
if !response.acceptance_recorded {
return;
}
summary.uploaded += 1;
summary.linked_observations += response.observations_inserted as usize;
if expected_rule_ids == 0 {
return;
}
if response.team_id.is_none() {
summary.missing_team += 1;
} else if response.observations_inserted == 0 {
summary.missing_rule_observation += 1;
}
}
fn record_accepted_edit_enqueue_result<E: std::fmt::Display>(
summary: &mut AcceptedEditUploadSummary,
result: Result<i64, E>,
expected_rule_ids: usize,
has_target_pr: bool,
) -> Option<i64> {
match result {
Ok(row_id) => {
record_accepted_edit_upload_queued(summary, expected_rule_ids, has_target_pr);
Some(row_id)
}
Err(e) => {
eprintln!(
"{} could not queue accepted edit upload: {e}",
style::warn(sym::WARN)
);
None
}
}
}
fn print_accepted_edit_upload_warnings(summary: &AcceptedEditUploadSummary) {
if summary.uploaded == 0 && summary.queued == 0 {
return;
}
let pending_attribution = summary.queued.saturating_sub(summary.uploaded);
if pending_attribution > 0 {
eprintln!(
"{} {count} accepted edit(s) queued for later cloud upload; run cloud sync to confirm team workspace and linked memory activity.",
style::warn(sym::WARN),
count = pending_attribution
);
}
if summary.missing_rule_ids > 0 {
eprintln!(
"{} {count} accepted edit(s) have no recalled memory id; review recalled memories before applying fixes.",
style::warn(sym::WARN),
count = summary.missing_rule_ids
);
}
if summary.missing_target_pr > 0 {
eprintln!(
"{} {count} accepted edit(s) have no target PR number; run `difflore fix --pr <PR>` on a real PR before uploading team review history.",
style::warn(sym::WARN),
count = summary.missing_target_pr
);
}
if summary.missing_team > 0 {
eprintln!(
"{} {count} accepted edit(s) uploaded, but no cloud team workspace was found; create or join a team.",
style::warn(sym::WARN),
count = summary.missing_team
);
}
if summary.missing_rule_observation > 0 {
eprintln!(
"{} {count} accepted edit(s) uploaded without linked memory activity; review recalled memories before applying fixes.",
style::warn(sym::WARN),
count = summary.missing_rule_observation
);
}
}
impl OutcomeIssue {
pub(super) fn rule_label(&self) -> String {
match &self.rule_id {
Some(id) if !id.trim().is_empty() => format!("{} ({id})", self.rule_name),
_ => self.rule_name.clone(),
}
}
}
impl From<&ReviewIssueRecord> for OutcomeIssue {
fn from(issue: &ReviewIssueRecord) -> Self {
Self {
rule_id: issue.rule_id.clone(),
rule_name: issue.rule.clone(),
file_path: issue.file.clone(),
file_loc: file_loc(issue),
}
}
}
pub(super) const fn yes_mode_should_fail(outcome: &ApplyOutcome) -> bool {
!outcome.failed.is_empty()
}
fn failure_reason_is_provider_misconfig(reason: &str) -> bool {
let lower = reason.trim_start().to_ascii_lowercase();
lower.starts_with("no active ai provider configured")
|| lower.starts_with("no llm provider configured")
|| lower.starts_with("no supported agent cli found")
|| lower.starts_with("failed to spawn `claude` cli")
|| lower.starts_with("failed to spawn claude cli")
|| (lower.starts_with("claude cli")
&& (lower.contains("not logged in") || lower.contains("please run /login")))
}
pub(super) async fn record_fix_outcomes(
db: &difflore_core::SqlitePool,
outcome: &mut ApplyOutcome,
rejected: &[&ReviewIssueRecord],
repo_full_name: Option<&str>,
pr_number: Option<u64>,
upload_acceptance: bool,
) {
let target_pr_number = pr_number.and_then(|number| i64::try_from(number).ok());
let accepted_signatures_by_file: std::collections::BTreeMap<&str, &str> = outcome
.accepted_edits
.iter()
.map(|proof| (proof.file_path.as_str(), proof.diff_signature.as_str()))
.collect();
let mut rows: Vec<FixOutcomeInput<'_>> = Vec::new();
rows.extend(outcome.applied.iter().map(|issue| {
FixOutcomeInput {
rule_id: issue.rule_id.as_deref(),
rule_name: &issue.rule_name,
file_path: issue.file_path.as_deref(),
repo_full_name,
pr_number: target_pr_number,
diff_signature: issue
.file_path
.as_deref()
.and_then(|path| accepted_signatures_by_file.get(path).copied()),
accepted: true,
applied_ok: true,
failed_reason: None,
}
}));
rows.extend(
outcome
.failed
.iter()
.filter(|(_, reason)| !failure_reason_is_provider_misconfig(reason))
.map(|(issue, reason)| FixOutcomeInput {
rule_id: issue.rule_id.as_deref(),
rule_name: &issue.rule_name,
file_path: issue.file_path.as_deref(),
repo_full_name,
pr_number: target_pr_number,
diff_signature: None,
accepted: true,
applied_ok: false,
failed_reason: Some(reason.as_str()),
}),
);
rows.extend(rejected.iter().map(|issue| FixOutcomeInput {
rule_id: issue.rule_id.as_deref(),
rule_name: &issue.rule,
file_path: issue.file.as_deref(),
repo_full_name,
pr_number: target_pr_number,
diff_signature: None,
accepted: false,
applied_ok: false,
failed_reason: None,
}));
if let Err(e) = difflore_core::observability::fix_outcomes::record_many(db, &rows).await {
eprintln!(
"{} could not record local fix outcomes: {e}",
style::warn(sym::WARN)
);
}
for issue in &outcome.applied {
let Some(rule_id) = issue.rule_id.as_deref().filter(|s| !s.trim().is_empty()) else {
fix_debug!(
"skipping rule reinforcement for applied issue with empty rule_id (rule={})",
issue.rule_name
);
continue;
};
reinforce_rule(db, rule_id, &issue.rule_name, "accept", "fix_accepted").await;
}
for issue in rejected {
let Some(rule_id) = issue.rule_id.as_deref().filter(|s| !s.trim().is_empty()) else {
fix_debug!(
"skipping rule reinforcement for rejected issue with empty rule_id (rule={})",
issue.rule
);
continue;
};
reinforce_rule(db, rule_id, &issue.rule, "reject", "fix_rejected").await;
}
fix_debug!("accepted edit proofs: {}", outcome.accepted_edits.len());
if !upload_acceptance {
outcome.accepted_edits.clear();
return;
}
let acceptances = std::mem::take(&mut outcome.accepted_edits);
record_accepted_edit_proofs(db, acceptances, repo_full_name, pr_number).await;
}
#[allow(clippy::cast_possible_truncation)]
async fn reinforce_rule(
db: &difflore_core::SqlitePool,
rule_id: &str,
rule_title: &str,
signal: &str,
reason: &str,
) {
let input = difflore_core::domain::models::UpdateConfidenceInput {
skill_id: rule_id.to_owned(),
signal: signal.to_owned(),
};
match difflore_core::skills::update_confidence(db, input).await {
Ok(change) => {
difflore_core::observability::activity_stream::record(
difflore_core::observability::activity_stream::ActivityPayload::RuleReinforced {
rule_id: rule_id.to_owned(),
rule_title: rule_title.to_owned(),
prev_strength: change.before as f32,
new_strength: change.after as f32,
reason: reason.to_owned(),
},
);
}
Err(e) => {
fix_debug!(
"skipping rule reinforcement for rule_id `{rule_id}` ({reason}); update_confidence failed: {e}"
);
}
}
}
async fn record_accepted_edit_proofs(
db: &difflore_core::SqlitePool,
acceptances: Vec<AcceptedEditProof>,
repo_full_name: Option<&str>,
pr_number: Option<u64>,
) {
if acceptances.is_empty() {
return;
}
let queue = difflore_core::cloud::outbox::OutboxQueue::new(db.clone());
let client = difflore_core::cloud::client::CloudClient::create().await;
let mut upload_summary = AcceptedEditUploadSummary::default();
let target_pr_number = pr_number.and_then(|number| i64::try_from(number).ok());
let mut rule_id_cache: std::collections::BTreeMap<String, Option<String>> =
std::collections::BTreeMap::new();
for mut acceptance in acceptances {
acceptance.rule_ids =
resolve_acceptance_cloud_rule_ids(db, acceptance.rule_ids, &mut rule_id_cache).await;
let req = accepted_edit_upload_request(acceptance, repo_full_name, target_pr_number);
let expected_rule_ids = req.rule_ids.len();
let payload = match serde_json::to_string(&req) {
Ok(payload) => payload,
Err(e) => {
eprintln!(
"{} could not prepare accepted edit upload: {e}",
style::warn(sym::WARN)
);
continue;
}
};
let Some(row_id) = record_accepted_edit_enqueue_result(
&mut upload_summary,
queue
.enqueue(difflore_core::cloud::outbox::kind::ACCEPTED_EDIT, &payload)
.await,
expected_rule_ids,
target_pr_number.is_some(),
) else {
continue;
};
if client.is_logged_in() {
match client.record_accepted_edit_response(req).await {
Ok(response) if response.acceptance_recorded => {
record_accepted_edit_upload_response(
&mut upload_summary,
expected_rule_ids,
&response,
);
if let Err(e) = queue.confirm(row_id).await {
eprintln!(
"{} accepted edit uploaded but local outbox cleanup failed: {e}",
style::warn(sym::WARN)
);
}
}
Ok(response) => {
fix_debug!(
"accepted edit evidence upload returned ok={} error={:?}",
response.ok,
response.error
);
}
Err(e) => {
fix_debug!("accepted edit evidence upload failed: {e}");
}
}
}
}
print_accepted_edit_upload_warnings(&upload_summary);
}
fn accepted_edit_upload_request(
acceptance: AcceptedEditProof,
repo_full_name: Option<&str>,
target_pr_number: Option<i64>,
) -> RecordAcceptedEditRequest {
RecordAcceptedEditRequest {
before_code: acceptance.before_code,
after_code: acceptance.after_code,
file_path: Some(acceptance.file_path),
repo_full_name: repo_full_name.map(str::to_owned),
target_pr_number,
language: acceptance.language,
acceptance_source: Some("difflore_fix".to_owned()),
client: Some("difflore_cli".to_owned()),
diff_signature: Some(acceptance.diff_signature),
rule_ids: acceptance.rule_ids,
}
}
async fn resolve_acceptance_cloud_rule_ids(
db: &difflore_core::SqlitePool,
rule_ids: Vec<String>,
cache: &mut std::collections::BTreeMap<String, Option<String>>,
) -> Vec<String> {
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
for rule_id in rule_ids {
let rule_id = rule_id.trim();
if rule_id.is_empty() {
continue;
}
let cloud_rule_id = if let Some(cached) = cache.get(rule_id).cloned() {
cached
} else {
let resolved = match difflore_core::team::resolve_known_cloud_rule_id(db, rule_id).await
{
Ok(resolved) => resolved,
Err(e) => {
fix_debug!(
"accepted edit evidence skipped rule_id `{rule_id}`; cloud id lookup failed: {e}"
);
None
}
};
cache.insert(rule_id.to_owned(), resolved.clone());
resolved
};
let Some(cloud_rule_id) = cloud_rule_id else {
fix_debug!("accepted edit evidence omitted unmapped non-cloud rule_id `{rule_id}`");
continue;
};
if seen.insert(cloud_rule_id.clone()) {
out.push(cloud_rule_id);
}
}
out
}
fn unique_rule_ids(issues: &[&ReviewIssueRecord]) -> Vec<String> {
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
for issue in issues {
let Some(rule_id) = issue
.rule_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
else {
continue;
};
if seen.insert(rule_id.to_owned()) {
out.push(rule_id.to_owned());
}
}
out
}
pub(super) fn print_apply_summary(outcome: &ApplyOutcome, skipped: u32, total: usize) {
println!();
println!(
"{} {} applied, {} failed, {} skipped (of {}).",
style::ok(sym::OK),
outcome.applied.len(),
outcome.failed.len(),
skipped,
total,
);
if !outcome.failed.is_empty() {
println!();
println!(
" {} patch generation didn't pan out for:",
style::warn(sym::WARN),
);
use std::collections::BTreeMap;
let mut by_reason: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (issue, reason) in &outcome.failed {
by_reason
.entry(reason.as_str())
.or_default()
.push(issue.file_loc.as_str());
}
for (reason, locs) in &by_reason {
if locs.len() == 1 {
println!(" {} ({reason})", locs[0]);
} else {
println!(" {} suggestions | {reason}", locs.len());
for loc in locs {
println!(" {loc}");
}
}
}
}
if !outcome.applied.is_empty() {
println!(
" +{} accepted edit{} recorded for local value tracking.",
outcome.applied.len(),
if outcome.applied.len() == 1 { "" } else { "s" },
);
let mut seen = std::collections::BTreeSet::new();
for issue in &outcome.applied {
seen.insert(issue.rule_label());
}
let rule_word = if seen.len() == 1 { "rule" } else { "rules" };
println!(" {} applied {rule_word}:", style::pewter(sym::BULLET));
for label in &seen {
println!(" {label}");
}
println!(" > review what changed: {}", style::cmd("git diff"));
println!(" > undo: {}", style::cmd("git checkout -p"));
}
println!();
println!("next: {}", style::cmd("difflore status"));
}
pub(super) async fn apply_accepted_patches(
db: &difflore_core::SqlitePool,
repo_root: &Path,
accepted: &[&ReviewIssueRecord],
sync_staged_index: bool,
quiet: bool,
) -> ApplyOutcome {
let mut outcome = ApplyOutcome::default();
if accepted.is_empty() {
return outcome;
}
use std::collections::BTreeMap;
let mut by_file: BTreeMap<String, Vec<&ReviewIssueRecord>> = BTreeMap::new();
let mut orphans: Vec<&ReviewIssueRecord> = Vec::new();
for issue in accepted {
match issue.file.as_deref() {
Some(p) => by_file.entry(p.to_owned()).or_default().push(issue),
None => orphans.push(issue),
}
}
for issue in &orphans {
outcome
.failed
.push((OutcomeIssue::from(*issue), "issue has no file path".into()));
}
let total_files = by_file.len();
if total_files > 0 && !quiet {
println!();
}
let mut pending_applied: Vec<PendingAppliedPatch> = Vec::new();
let mut aborted = false;
for (idx, (file_path, issues)) in by_file.iter().enumerate() {
let loc = if issues.len() == 1 {
file_loc(issues[0])
} else {
format!("{file_path} ({} suggestions)", issues.len())
};
let step = idx + 1;
if !quiet {
print_progress_pending(step, total_files, &loc);
}
let abs_path = match super::path_safety::repo_relative_path(repo_root, file_path) {
Ok(p) => p,
Err(reason) => {
record_failure_and_rollback(
&mut outcome,
repo_root,
&mut pending_applied,
issues,
&loc,
step,
total_files,
&reason,
quiet,
);
aborted = true;
break;
}
};
let raw_bytes = match std::fs::read(&abs_path) {
Ok(b) => b,
Err(e) => {
let reason = format!("read failed: {e}");
record_failure_and_rollback(
&mut outcome,
repo_root,
&mut pending_applied,
issues,
&loc,
step,
total_files,
&reason,
quiet,
);
aborted = true;
break;
}
};
let Ok(on_disk) = String::from_utf8(raw_bytes) else {
let reason = "file is not valid UTF-8 — skipped".to_owned();
record_failure_and_rollback(
&mut outcome,
repo_root,
&mut pending_applied,
issues,
&loc,
step,
total_files,
&reason,
quiet,
);
aborted = true;
break;
};
let uses_crlf = dominant_eol_is_crlf(&on_disk);
let file_content = if uses_crlf {
on_disk.replace("\r\n", "\n")
} else {
on_disk
};
let prompt = if issues.len() == 1 {
patch_user_prompt(file_path, &file_content, issues[0])
} else {
batched_patch_user_prompt(file_path, &file_content, issues)
};
if let Some(path) = difflore_core::infra::env::fix_dump_dir() {
std::fs::write(format!("{path}/last_patch_prompt.txt"), &prompt).ok();
std::fs::write(
format!("{path}/last_patch_system.txt"),
patch_system_prompt(),
)
.ok();
}
let raw = match difflore_core::review_engine::complete_with_active_provider(
db,
patch_system_prompt(),
&prompt,
)
.await
{
Ok(s) => s,
Err(e) => {
let raw = e.to_string();
let trimmed = raw
.strip_prefix("Internal error: ")
.or_else(|| raw.strip_prefix("Validation error: "))
.unwrap_or(&raw);
let reason = trimmed.to_owned();
record_failure_and_rollback(
&mut outcome,
repo_root,
&mut pending_applied,
issues,
&loc,
step,
total_files,
&reason,
quiet,
);
aborted = true;
break;
}
};
if let Some(path) = difflore_core::infra::env::fix_dump_dir() {
std::fs::write(format!("{path}/last_patch_raw.txt"), &raw).ok();
}
let Some(diff) = extract_unified_diff(&raw) else {
let reason = "LLM returned no usable diff";
record_failure_and_rollback(
&mut outcome,
repo_root,
&mut pending_applied,
issues,
&loc,
step,
total_files,
reason,
quiet,
);
aborted = true;
break;
};
fix_debug!("generated patch:\n{diff}\n[fix-debug] end patch");
if let Some(path) = difflore_core::infra::env::fix_dump_dir() {
std::fs::write(format!("{path}/last_patch.diff"), &diff).ok();
}
match apply_diff_transactionally(
repo_root,
&diff,
sync_staged_index,
&abs_path,
&file_content,
file_path,
issues,
uses_crlf,
) {
Ok(pending) => {
pending_applied.push(pending);
if !quiet {
print_progress_done(step, total_files, &loc, Ok(()));
}
}
Err(e) => {
record_failure_and_rollback(
&mut outcome,
repo_root,
&mut pending_applied,
issues,
&loc,
step,
total_files,
&e,
quiet,
);
aborted = true;
break;
}
}
}
if !aborted {
for pending in pending_applied {
outcome.applied.extend(pending.issues);
if let Some(proof) = pending.accepted_edit {
outcome.accepted_edits.push(proof);
}
}
}
outcome
}
struct PendingAppliedPatch {
diff: String,
issues: Vec<OutcomeIssue>,
accepted_edit: Option<AcceptedEditProof>,
synced_index: bool,
}
fn dominant_eol_is_crlf(content: &str) -> bool {
let crlf = content.matches("\r\n").count();
if crlf == 0 {
return false;
}
let bare_lf = content.matches('\n').count() - crlf;
crlf >= bare_lf
}
fn crlf_normalize_diff(diff: &str) -> String {
diff.replace("\r\n", "\n").replace('\n', "\r\n")
}
#[allow(clippy::too_many_arguments)] fn apply_diff_transactionally(
repo_root: &Path,
diff: &str,
sync_staged_index: bool,
abs_path: &Path,
before_content: &str,
file_path: &str,
issues: &[&ReviewIssueRecord],
uses_crlf: bool,
) -> Result<PendingAppliedPatch, String> {
super::path_safety::validate_diff_targets(diff, file_path)?;
let applied_diff = if uses_crlf {
crlf_normalize_diff(diff)
} else {
diff.to_owned()
};
let applied_diff = applied_diff.as_str();
with_diff_tempfile(applied_diff, |diff_path| {
let diff_path = diff_path.map_err(|e| format!("tempfile: {e}"))?;
validate_patch_dry_run(repo_root, diff_path)?;
run_git_apply(repo_root, diff_path, false).map_err(|e| format!("apply: {e}"))?;
let synced_index =
sync_staged_index && run_git_apply_cached(repo_root, diff_path, true).is_ok();
if synced_index && let Err(e) = run_git_apply_cached(repo_root, diff_path, false) {
let rollback = run_git_apply_reverse(repo_root, diff_path, false).map_or_else(
|rollback_err| format!("; rollback failed: {rollback_err}"),
|()| "; worktree rolled back".to_owned(),
);
return Err(format!("index apply: {e}{rollback}"));
}
let accepted_edit = match std::fs::read_to_string(abs_path) {
Ok(after_content) => {
let after_content = if uses_crlf {
after_content.replace("\r\n", "\n")
} else {
after_content
};
let diff_signature = difflore_core::contract::accepted_edit_diff_signature(
before_content,
&after_content,
);
Some(AcceptedEditProof {
file_path: file_path.to_owned(),
before_code: before_content.to_owned(),
after_code: after_content,
language: detect_language_from_path(file_path),
diff_signature,
rule_ids: unique_rule_ids(issues),
})
}
Err(e) => {
fix_debug!("accepted edit proof skipped for {file_path}: {e}");
None
}
};
Ok(PendingAppliedPatch {
diff: applied_diff.to_owned(),
issues: issues
.iter()
.map(|issue| OutcomeIssue::from(*issue))
.collect(),
accepted_edit,
synced_index,
})
})
}
fn validate_patch_dry_run(repo_root: &Path, diff_path: &Path) -> Result<(), String> {
run_git_apply(repo_root, diff_path, true).map_err(|e| format!("dry-run validation: {e}"))
}
fn rollback_pending_patches(
outcome: &mut ApplyOutcome,
repo_root: &Path,
pending: &mut Vec<PendingAppliedPatch>,
cause: &str,
) {
if pending.is_empty() {
return;
}
let mut rollback_errors = Vec::new();
for patch in pending.iter().rev() {
with_diff_tempfile(&patch.diff, |diff_path| match diff_path {
Err(e) => rollback_errors.push(format!("tempfile: {e}")),
Ok(diff_path) => {
if patch.synced_index
&& let Err(e) = run_git_apply_reverse(repo_root, diff_path, true)
{
rollback_errors.push(format!("cached reverse apply failed: {e}"));
}
if let Err(e) = run_git_apply_reverse(repo_root, diff_path, false) {
rollback_errors.push(format!("worktree reverse apply failed: {e}"));
}
}
});
}
let reason = if rollback_errors.is_empty() {
format!("rolled back because apply transaction failed: {cause}")
} else {
format!(
"apply transaction failed: {cause}; rollback reported: {}",
rollback_errors.join("; ")
)
};
for patch in pending.drain(..) {
for issue in patch.issues {
outcome.failed.push((issue, reason.clone()));
}
}
}
fn record_failure(
outcome: &mut ApplyOutcome,
issues: &[&ReviewIssueRecord],
loc: &str,
idx: usize,
total: usize,
reason: &str,
quiet: bool,
) {
for issue in issues {
outcome
.failed
.push((OutcomeIssue::from(*issue), reason.to_owned()));
}
if !quiet {
print_progress_done(idx, total, loc, Err(reason));
}
}
#[allow(clippy::too_many_arguments)] fn record_failure_and_rollback(
outcome: &mut ApplyOutcome,
repo_root: &Path,
pending: &mut Vec<PendingAppliedPatch>,
issues: &[&ReviewIssueRecord],
loc: &str,
idx: usize,
total: usize,
reason: &str,
quiet: bool,
) {
record_failure(outcome, issues, loc, idx, total, reason, quiet);
rollback_pending_patches(outcome, repo_root, pending, reason);
}
fn with_diff_tempfile<R>(diff: &str, f: impl FnOnce(Result<&Path, String>) -> R) -> R {
use std::io::Write as _;
let mut tmp = match tempfile::NamedTempFile::new() {
Ok(t) => t,
Err(e) => return f(Err(format!("failed to create tempfile: {e}"))),
};
if let Err(e) = tmp.write_all(diff.as_bytes()) {
return f(Err(format!("failed to write tempfile: {e}")));
}
f(Ok(tmp.path()))
}
fn print_progress_pending(idx: usize, total: usize, file_loc: &str) {
let line = format!(
" [{idx}/{total}] {} generating patch for {file_loc}...",
style::pewter(sym::BULLET),
);
if io::stdout().is_terminal() {
write!(io::stdout(), "\r{line}").ok();
io::stdout().flush().ok();
} else {
println!("{line}");
}
}
fn print_progress_done(idx: usize, total: usize, file_loc: &str, result: Result<(), &str>) {
let body = match result {
Ok(()) => format!(
" [{idx}/{total}] {} applied {file_loc}",
style::ok(sym::OK)
),
Err(reason) => format!(
" [{idx}/{total}] {} {file_loc} ({reason})",
style::warn(sym::WARN)
),
};
if io::stdout().is_terminal() {
write!(io::stdout(), "\r\x1b[K{body}\n").ok();
io::stdout().flush().ok();
} else {
println!("{body}");
}
}
const fn patch_system_prompt() -> &'static str {
"Task: generate a code patch. Given a source file and a code review \
suggestion, output ONLY a unified diff that fixes the issue. \
Strict rules:\n\
1. Use the standard unified diff format with `--- a/<path>` and \
`+++ b/<path>` headers and `@@ ... @@` hunk headers.\n\
2. Match the file content EXACTLY for context lines — preserve \
whitespace, indentation, and trailing characters.\n\
3. Every hunk MUST include at least 3 unchanged context lines before \
and after each change (fewer only when the file boundary leaves \
fewer available). Never emit zero-context hunks — the patch is \
located by matching context, not by line number.\n\
4. Make the smallest possible change that satisfies the suggestion.\n\
5. Do NOT include prose, commentary, or markdown fences in the output.\n\
6. Before outputting a diff, verify the resulting file would satisfy all \
explicit constraints in the suggestion; if it would leave any violation, \
output `NO_PATCH`.\n\
7. If the suggestion cannot be turned into a precise diff against this \
file, output the single line `NO_PATCH` and nothing else."
}
fn patch_user_prompt(file_path: &str, content: &str, issue: &ReviewIssueRecord) -> String {
let suggestion = issue
.suggestion
.as_deref()
.unwrap_or("(no suggestion text)");
let content_for_prompt = focused_patch_context(content, issue.line);
let context_label = if content_for_prompt.len() == content.len() {
"Current file content"
} else {
"Focused current file excerpt near the issue"
};
format!(
"File: {file_path}\n\nReview issue: {}\n\nSuggested change:\n{}\n\n\
{context_label}:\n```\n{}\n```\n\n\
Output the unified diff (or `NO_PATCH`):",
issue.message, suggestion, content_for_prompt,
)
}
fn focused_patch_context(content: &str, line: Option<i32>) -> String {
const FULL_FILE_LIMIT: usize = 4_000;
const CONTEXT_RADIUS: usize = 24;
const HEADER_LINES: usize = 20;
if content.len() <= FULL_FILE_LIMIT {
return content.to_owned();
}
let Some(line) = line
.and_then(|n| usize::try_from(n).ok())
.filter(|n| *n > 0)
else {
return content.to_owned();
};
let lines: Vec<&str> = content.lines().collect();
if lines.len() <= CONTEXT_RADIUS * 2 {
return content.to_owned();
}
let target = line.saturating_sub(1).min(lines.len().saturating_sub(1));
let start = target.saturating_sub(CONTEXT_RADIUS);
let end = (target + CONTEXT_RADIUS + 1).min(lines.len());
let focus = lines[start..end].join("\n");
if start > HEADER_LINES {
let header = lines[..HEADER_LINES.min(lines.len())].join("\n");
format!("{header}\n\n...\n\n{focus}")
} else {
focus
}
}
fn batched_patch_user_prompt(
file_path: &str,
content: &str,
issues: &[&ReviewIssueRecord],
) -> String {
use core::fmt::Write as _;
let mut s = String::new();
write!(s, "File: {file_path}\n\n").ok();
write!(
s,
"Apply ALL of these {} review suggestions to the file in a single \
consolidated unified diff. Each suggestion must be addressed; if any \
single suggestion cannot be cleanly applied alongside the others, \
output `NO_PATCH`.\n\n",
issues.len()
)
.ok();
for (i, issue) in issues.iter().enumerate() {
let suggestion = issue
.suggestion
.as_deref()
.unwrap_or("(no suggestion text)");
write!(
s,
"-- Suggestion {} --\nReview issue: {}\nSuggested change:\n{}\n\n",
i + 1,
issue.message,
suggestion,
)
.ok();
}
write!(
s,
"Current file content:\n```\n{content}\n```\n\n\
Output ONE unified diff covering every suggestion (or `NO_PATCH`):"
)
.ok();
s
}
fn extract_unified_diff(raw: &str) -> Option<String> {
let text = raw.trim();
if text.lines().any(|l| l.trim() == "NO_PATCH") {
return None;
}
let stripped = if text.starts_with("```") {
let after_open = text.split_once('\n').map_or("", |x| x.1);
let body = after_open.trim_end_matches('\n');
body.strip_suffix("```")
.map_or_else(|| body.to_owned(), |s| s.trim_end().to_owned())
} else {
text.to_owned()
};
let start_idx = stripped.find("--- ")?;
let mut diff = stripped[start_idx..].trim_end();
if diff
.lines()
.next_back()
.is_some_and(|line| line.trim() == "```")
{
diff = diff
.rsplit_once('\n')
.map_or("", |(head, _)| head)
.trim_end();
}
let diff = diff.to_owned();
if !diff.contains("+++ ") || !diff.contains("@@ ") {
return None;
}
if diff.ends_with('\n') {
Some(diff)
} else {
Some(format!("{diff}\n"))
}
}
fn run_git_apply(repo_root: &Path, diff_path: &Path, check_only: bool) -> Result<(), String> {
run_git_apply_impl(repo_root, diff_path, check_only, false)
}
fn run_git_apply_cached(
repo_root: &Path,
diff_path: &Path,
check_only: bool,
) -> Result<(), String> {
run_git_apply_impl(repo_root, diff_path, check_only, true)
}
fn run_git_apply_reverse(repo_root: &Path, diff_path: &Path, cached: bool) -> Result<(), String> {
run_git_apply_with_options(repo_root, diff_path, false, cached, true)
}
fn run_git_apply_impl(
repo_root: &Path,
diff_path: &Path,
check_only: bool,
cached: bool,
) -> Result<(), String> {
run_git_apply_with_options(repo_root, diff_path, check_only, cached, false)
}
fn run_git_apply_with_options(
repo_root: &Path,
diff_path: &Path,
check_only: bool,
cached: bool,
reverse: bool,
) -> Result<(), String> {
let mut cmd = difflore_core::infra::git::git_command(repo_root);
cmd.arg("apply");
cmd.arg("-p1");
if check_only {
cmd.arg("--check");
}
if cached {
cmd.arg("--cached");
}
if reverse {
cmd.arg("--reverse");
}
cmd.arg("--recount");
cmd.arg(diff_path);
let out = cmd
.output()
.map_err(|e| format!("{GIT_SPAWN_PREFIX}: {e}"))?;
if out.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&out.stderr);
let trimmed = stderr.trim();
Err(if trimmed.is_empty() {
"git apply failed silently".into()
} else {
trimmed.to_owned()
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_DIFF: &str = "--- a/src/foo.rs\n+++ b/src/foo.rs\n@@ -1,3 +1,3 @@\n fn x() {\n- println!(\"old\");\n+ println!(\"new\");\n }\n";
#[test]
fn extracts_bare_diff() {
let got = extract_unified_diff(SAMPLE_DIFF).unwrap();
assert!(got.starts_with("--- a/src/foo.rs"));
assert!(got.ends_with('\n'));
}
#[test]
fn strips_markdown_fence_no_lang() {
let raw = format!("```\n{SAMPLE_DIFF}```");
let got = extract_unified_diff(&raw).unwrap();
assert!(got.starts_with("--- a/src/foo.rs"));
assert!(!got.contains("```"));
}
#[test]
fn strips_markdown_fence_with_lang_tag() {
let raw = format!("```diff\n{SAMPLE_DIFF}```");
let got = extract_unified_diff(&raw).unwrap();
assert!(got.starts_with("--- a/src/foo.rs"));
assert!(!got.contains("```"));
}
#[test]
fn tolerates_preamble_before_diff() {
let raw = format!("Sure, here's the patch:\n\n{SAMPLE_DIFF}");
let got = extract_unified_diff(&raw).unwrap();
assert!(got.starts_with("--- a/src/foo.rs"));
assert!(!got.contains("here's the patch"));
}
#[test]
fn strips_markdown_fence_after_preamble() {
let raw = format!("Sure, here's the patch:\n\n```diff\n{SAMPLE_DIFF}```");
let got = extract_unified_diff(&raw).unwrap();
assert!(got.starts_with("--- a/src/foo.rs"));
assert!(!got.contains("```"));
}
#[test]
fn rejects_no_patch_sentinel() {
assert!(extract_unified_diff("NO_PATCH").is_none());
assert!(extract_unified_diff("Sorry,\nNO_PATCH\n").is_none());
}
#[test]
fn rejects_response_without_diff_markers() {
assert!(extract_unified_diff("Just some prose, no diff.").is_none());
}
#[test]
fn rejects_partial_diff_missing_hunk_header() {
let bad = "--- a/foo.rs\n+++ b/foo.rs\n(no hunks)\n";
assert!(extract_unified_diff(bad).is_none());
}
#[test]
fn appends_trailing_newline() {
let no_newline = SAMPLE_DIFF.trim_end_matches('\n');
let got = extract_unified_diff(no_newline).unwrap();
assert!(got.ends_with('\n'));
}
fn issue_at(file: Option<&str>, line: Option<i32>, msg: &str) -> ReviewIssueRecord {
ReviewIssueRecord {
severity: "warning".into(),
rule: "R".into(),
rule_id: None,
message: msg.into(),
file: file.map(str::to_owned),
line,
suggestion: Some("do the thing".into()),
source_badge: None,
perspectives: Vec::new(),
confidence: 0.9,
}
}
fn issue_with_rule(rule_id: Option<&str>) -> ReviewIssueRecord {
ReviewIssueRecord {
severity: "warning".into(),
rule: "R".into(),
rule_id: rule_id.map(str::to_owned),
message: "msg".into(),
file: Some("src/foo.rs".into()),
line: Some(1),
suggestion: Some("do the thing".into()),
source_badge: None,
perspectives: Vec::new(),
confidence: 0.9,
}
}
#[test]
fn unique_rule_ids_dedupes_and_drops_missing_ids() {
let a = issue_with_rule(Some("rule-1"));
let b = issue_with_rule(Some(" rule-2 "));
let c = issue_with_rule(Some("rule-1"));
let d = issue_with_rule(None);
let e = issue_with_rule(Some(""));
let issues = vec![&a, &b, &c, &d, &e];
assert_eq!(unique_rule_ids(&issues), vec!["rule-1", "rule-2"]);
}
async fn cloud_rule_id_pool() -> sqlx::SqlitePool {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query("CREATE TABLE auth (key TEXT PRIMARY KEY, value TEXT)")
.execute(&pool)
.await
.unwrap();
sqlx::query("CREATE TABLE skills (id TEXT PRIMARY KEY, cloud_id TEXT)")
.execute(&pool)
.await
.unwrap();
pool
}
#[tokio::test]
async fn resolve_acceptance_cloud_rule_ids_maps_known_local_ids() {
let pool = cloud_rule_id_pool().await;
let auth_cloud_id = "6105b2dd-5b7b-41a4-9af0-5e14c2b245fc";
let skill_cloud_id = "d09b9631-01a9-4aa5-a4f5-cbed12c4c0de";
let direct_cloud_id = "771e2e98-c010-4f9f-a387-45eabe55770a";
sqlx::query("INSERT INTO auth (key, value) VALUES (?1, ?2)")
.bind("rule_cloud_id:conv-review-aabbccdd")
.bind(auth_cloud_id)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO skills (id, cloud_id) VALUES (?1, ?2)")
.bind("local-review")
.bind(skill_cloud_id)
.execute(&pool)
.await
.unwrap();
let mut cache = std::collections::BTreeMap::new();
let resolved = resolve_acceptance_cloud_rule_ids(
&pool,
vec![
"conv-review-aabbccdd".to_owned(),
"local-review".to_owned(),
direct_cloud_id.to_owned(),
"unmapped-local".to_owned(),
"conv-review-aabbccdd".to_owned(),
" ".to_owned(),
],
&mut cache,
)
.await;
assert_eq!(
resolved,
vec![
auth_cloud_id.to_owned(),
skill_cloud_id.to_owned(),
direct_cloud_id.to_owned(),
]
);
assert!(matches!(cache.get("unmapped-local"), Some(None)));
}
#[tokio::test]
async fn resolve_acceptance_cloud_rule_ids_omits_invalid_mappings() {
let pool = cloud_rule_id_pool().await;
sqlx::query("INSERT INTO auth (key, value) VALUES (?1, ?2)")
.bind("rule_cloud_id:conv-bad-aabbccdd")
.bind("not-a-cloud-uuid")
.execute(&pool)
.await
.unwrap();
let mut cache = std::collections::BTreeMap::new();
let resolved = resolve_acceptance_cloud_rule_ids(
&pool,
vec!["conv-bad-aabbccdd".to_owned()],
&mut cache,
)
.await;
assert!(resolved.is_empty());
}
#[test]
fn accepted_edit_upload_request_keeps_launch_grade_provenance() {
let req = accepted_edit_upload_request(
AcceptedEditProof {
file_path: "src/lib.rs".into(),
before_code: "old".into(),
after_code: "new".into(),
language: Some("rust".into()),
diff_signature: "sig-1".into(),
rule_ids: vec!["rule-1".into(), "rule-2".into()],
},
Some("difflore/difflore-cli"),
Some(4543),
);
let value = serde_json::to_value(req).unwrap();
assert_eq!(value["acceptanceSource"], "difflore_fix");
assert_eq!(value["client"], "difflore_cli");
assert_eq!(value["repoFullName"], "difflore/difflore-cli");
assert_eq!(value["targetPrNumber"], 4543);
assert_eq!(value["filePath"], "src/lib.rs");
assert_eq!(value["language"], "rust");
assert_eq!(value["diffSignature"], "sig-1");
assert_eq!(value["ruleIds"][0], "rule-1");
assert_eq!(value["ruleIds"][1], "rule-2");
}
async fn migrated_pool() -> sqlx::SqlitePool {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
difflore_core::infra::db::run_migrations(&pool)
.await
.unwrap();
pool
}
async fn insert_active_skill(pool: &sqlx::SqlitePool, id: &str, confidence: f64) {
sqlx::query(
"INSERT INTO skills (id, name, source, directory, version, confidence_score, status)
VALUES (?1, ?2, 'manual', '/tmp', '1.0.0', ?3, 'active')",
)
.bind(id)
.bind(format!("name-{id}"))
.bind(confidence)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn reinforce_rule_accept_bumps_confidence_and_writes_event() {
let pool = migrated_pool().await;
insert_active_skill(&pool, "rule-accept", 0.80).await;
reinforce_rule(
&pool,
"rule-accept",
"Rule Accept",
"accept",
"fix_accepted",
)
.await;
let after: f64 = sqlx::query_scalar("SELECT confidence_score FROM skills WHERE id = ?1")
.bind("rule-accept")
.fetch_one(&pool)
.await
.unwrap();
assert!(
(after - 0.85).abs() < 1e-9,
"accept must bump confidence by +0.05 (got {after})"
);
let events: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM rule_events WHERE skill_id = ?1 AND kind = 'feedback_accept'",
)
.bind("rule-accept")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(events, 1, "accept must write exactly one rule_events row");
}
#[tokio::test]
async fn record_fix_outcomes_reinforces_each_applied_rule() {
let pool = migrated_pool().await;
insert_active_skill(&pool, "rule-applied", 0.50).await;
let mut outcome = ApplyOutcome::default();
outcome.applied.push(OutcomeIssue {
rule_id: Some("rule-applied".to_owned()),
rule_name: "Rule Applied".to_owned(),
file_path: Some("src/lib.rs".to_owned()),
file_loc: "src/lib.rs:1".to_owned(),
});
record_fix_outcomes(&pool, &mut outcome, &[], None, None, false).await;
let after: f64 = sqlx::query_scalar("SELECT confidence_score FROM skills WHERE id = ?1")
.bind("rule-applied")
.fetch_one(&pool)
.await
.unwrap();
assert!(
(after - 0.55).abs() < 1e-9,
"an applied fix must reinforce its rule by +0.05 (got {after})"
);
}
#[tokio::test]
async fn reinforce_rule_skips_unknown_rule_without_error() {
let pool = migrated_pool().await;
reinforce_rule(&pool, "ghost-rule", "Ghost", "accept", "fix_accepted").await;
let events: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM rule_events")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(events, 0, "unknown rule must not write a rule_events row");
}
#[tokio::test]
async fn record_fix_outcomes_persists_target_pr_identity() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query(
"CREATE TABLE fix_outcomes (
id TEXT PRIMARY KEY NOT NULL,
rule_id TEXT,
rule_name TEXT NOT NULL,
file_path TEXT,
repo_full_name TEXT,
pr_number INTEGER,
diff_signature TEXT,
accepted INTEGER NOT NULL,
applied_ok INTEGER NOT NULL DEFAULT 0,
failed_reason TEXT,
created_at TEXT DEFAULT (datetime('now')) NOT NULL
)",
)
.execute(&pool)
.await
.unwrap();
let mut outcome = ApplyOutcome::default();
outcome.applied.push(OutcomeIssue {
rule_id: Some("rule-1".to_owned()),
rule_name: "Rule 1".to_owned(),
file_path: Some("src/lib.rs".to_owned()),
file_loc: "src/lib.rs:1".to_owned(),
});
record_fix_outcomes(
&pool,
&mut outcome,
&[],
Some("acme/widgets"),
Some(42),
false,
)
.await;
let row: (Option<String>, Option<i64>) =
sqlx::query_as("SELECT repo_full_name, pr_number FROM fix_outcomes WHERE rule_id = ?1")
.bind("rule-1")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.0.as_deref(), Some("acme/widgets"));
assert_eq!(row.1, Some(42));
}
fn accepted_edit_response(
team_id: Option<&str>,
observations_inserted: u32,
) -> RecordAcceptedEditResponse {
RecordAcceptedEditResponse {
ok: true,
acceptance_recorded: true,
acceptance_id: Some("acc-1".into()),
diff_signature: Some("sig-1".into()),
team_id: team_id.map(str::to_owned),
attributed_rule_ids: Vec::new(),
observations_inserted,
memory_reinforcement_recorded: false,
memory_reinforcement_deduped: false,
error: None,
}
}
#[test]
fn accepted_edit_enqueue_failure_does_not_stop_later_warning_accounting() {
let mut summary = AcceptedEditUploadSummary::default();
let mut queued_row_ids = Vec::new();
let attempts = [
(Err("db locked"), 2, true),
(Ok(10), 0, false),
(Ok(11), 3, true),
];
for (result, expected_rule_ids, has_target_pr) in attempts {
if let Some(row_id) = record_accepted_edit_enqueue_result(
&mut summary,
result,
expected_rule_ids,
has_target_pr,
) {
queued_row_ids.push(row_id);
}
}
assert_eq!(queued_row_ids, vec![10, 11]);
assert_eq!(summary.queued, 2);
assert_eq!(summary.uploaded, 0);
assert_eq!(summary.queued.saturating_sub(summary.uploaded), 2);
assert_eq!(summary.missing_rule_ids, 1);
assert_eq!(summary.missing_target_pr, 1);
assert_eq!(summary.missing_team, 0);
assert_eq!(summary.missing_rule_observation, 0);
}
#[test]
fn accepted_edit_upload_summary_flags_missing_team_for_rule_linked_proof() {
let mut summary = AcceptedEditUploadSummary::default();
record_accepted_edit_upload_queued(&mut summary, 2, true);
record_accepted_edit_upload_response(&mut summary, 2, &accepted_edit_response(None, 0));
assert_eq!(summary.queued, 1);
assert_eq!(summary.uploaded, 1);
assert_eq!(summary.linked_observations, 0);
assert_eq!(summary.missing_rule_ids, 0);
assert_eq!(summary.missing_target_pr, 0);
assert_eq!(summary.missing_team, 1);
assert_eq!(summary.missing_rule_observation, 0);
}
#[test]
fn accepted_edit_upload_summary_counts_linked_cloud_proof() {
let mut summary = AcceptedEditUploadSummary::default();
record_accepted_edit_upload_queued(&mut summary, 2, true);
record_accepted_edit_upload_response(
&mut summary,
2,
&accepted_edit_response(Some("team-1"), 2),
);
assert_eq!(summary.queued, 1);
assert_eq!(summary.uploaded, 1);
assert_eq!(summary.linked_observations, 2);
assert_eq!(summary.missing_rule_ids, 0);
assert_eq!(summary.missing_target_pr, 0);
assert_eq!(summary.missing_team, 0);
assert_eq!(summary.missing_rule_observation, 0);
}
#[test]
fn accepted_edit_upload_summary_flags_unlinked_rule_observation() {
let mut summary = AcceptedEditUploadSummary::default();
record_accepted_edit_upload_queued(&mut summary, 2, true);
record_accepted_edit_upload_response(
&mut summary,
2,
&accepted_edit_response(Some("team-1"), 0),
);
assert_eq!(summary.queued, 1);
assert_eq!(summary.uploaded, 1);
assert_eq!(summary.linked_observations, 0);
assert_eq!(summary.missing_rule_ids, 0);
assert_eq!(summary.missing_target_pr, 0);
assert_eq!(summary.missing_team, 0);
assert_eq!(summary.missing_rule_observation, 1);
}
#[test]
fn accepted_edit_upload_summary_flags_missing_rule_ids_before_upload() {
let mut summary = AcceptedEditUploadSummary::default();
record_accepted_edit_upload_queued(&mut summary, 0, true);
record_accepted_edit_upload_response(
&mut summary,
0,
&accepted_edit_response(Some("team-1"), 0),
);
assert_eq!(summary.queued, 1);
assert_eq!(summary.uploaded, 1);
assert_eq!(summary.linked_observations, 0);
assert_eq!(summary.missing_rule_ids, 1);
assert_eq!(summary.missing_target_pr, 0);
assert_eq!(summary.missing_team, 0);
assert_eq!(summary.missing_rule_observation, 0);
}
#[test]
fn accepted_edit_upload_summary_flags_missing_target_pr_before_upload() {
let mut summary = AcceptedEditUploadSummary::default();
record_accepted_edit_upload_queued(&mut summary, 2, false);
record_accepted_edit_upload_response(
&mut summary,
2,
&accepted_edit_response(Some("team-1"), 2),
);
assert_eq!(summary.queued, 1);
assert_eq!(summary.uploaded, 1);
assert_eq!(summary.linked_observations, 2);
assert_eq!(summary.missing_rule_ids, 0);
assert_eq!(summary.missing_target_pr, 1);
assert_eq!(summary.missing_team, 0);
assert_eq!(summary.missing_rule_observation, 0);
}
#[test]
fn accepted_edit_upload_summary_keeps_queued_rows_pending_attribution() {
let mut summary = AcceptedEditUploadSummary::default();
record_accepted_edit_upload_queued(&mut summary, 1, true);
assert_eq!(summary.queued, 1);
assert_eq!(summary.uploaded, 0);
assert_eq!(summary.queued.saturating_sub(summary.uploaded), 1);
}
#[test]
fn failure_reason_is_provider_misconfig_matches_known_auth_and_setup_errors() {
for reason in [
"claude CLI ... Not logged in · Please run /login ...",
"claude CLI ... Not logged in ...",
"No active AI provider configured. Run `difflore providers setup`",
"no LLM provider configured and no supported agent CLI found on PATH",
"failed to spawn `claude` CLI: The filename or extension is too long",
] {
assert!(
failure_reason_is_provider_misconfig(reason),
"expected provider-misconfig classification for: {reason}"
);
}
}
#[test]
fn failure_reason_is_provider_misconfig_does_not_swallow_real_patch_failures() {
for reason in [
"LLM returned no usable diff",
"validation: error: corrupt patch at line 12",
"issue has no file path",
"apply: patch does not apply",
"patch validation failed after the model wrote: not logged in",
] {
assert!(
!failure_reason_is_provider_misconfig(reason),
"must NOT silently drop real fix failure: {reason}"
);
}
}
#[tokio::test]
async fn record_fix_outcomes_skips_provider_misconfig_failures() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query(
"CREATE TABLE fix_outcomes (
id TEXT PRIMARY KEY NOT NULL,
rule_id TEXT,
rule_name TEXT NOT NULL,
file_path TEXT,
repo_full_name TEXT,
pr_number INTEGER,
diff_signature TEXT,
accepted INTEGER NOT NULL,
applied_ok INTEGER NOT NULL DEFAULT 0,
failed_reason TEXT,
created_at TEXT DEFAULT (datetime('now')) NOT NULL
)",
)
.execute(&pool)
.await
.unwrap();
let mut outcome = ApplyOutcome::default();
outcome.failed.push((
OutcomeIssue {
rule_id: Some("rule-a".to_owned()),
rule_name: "Rule A".to_owned(),
file_path: Some("src/a.rs".to_owned()),
file_loc: "src/a.rs:1".to_owned(),
},
"claude CLI ... Not logged in · Please run /login ...".to_owned(),
));
outcome.failed.push((
OutcomeIssue {
rule_id: Some("rule-b".to_owned()),
rule_name: "Rule B".to_owned(),
file_path: Some("src/b.rs".to_owned()),
file_loc: "src/b.rs:1".to_owned(),
},
"LLM returned no usable diff".to_owned(),
));
record_fix_outcomes(&pool, &mut outcome, &[], None, None, false).await;
let rows: Vec<(String, i64, i64, Option<String>)> = sqlx::query_as(
"SELECT rule_name, accepted, applied_ok, failed_reason
FROM fix_outcomes ORDER BY rule_name",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(rows.len(), 1, "auth failure must not land in fix_outcomes");
assert_eq!(rows[0].0, "Rule B");
assert_eq!(rows[0].1, 1);
assert_eq!(rows[0].2, 0);
assert_eq!(rows[0].3.as_deref(), Some("LLM returned no usable diff"));
}
#[test]
fn yes_mode_fails_when_confident_patch_generation_fails() {
let mut outcome = ApplyOutcome::default();
outcome.failed.push((
OutcomeIssue {
rule_id: None,
rule_name: "Pin Actions".into(),
file_path: Some(".github/workflows/pr.yml".into()),
file_loc: ".github/workflows/pr.yml:12".into(),
},
"LLM returned no usable diff".into(),
));
assert!(yes_mode_should_fail(&outcome));
}
#[test]
fn batched_prompt_lists_every_suggestion() {
let a = issue_at(Some("src/foo.ts"), Some(10), "issue A");
let b = issue_at(Some("src/foo.ts"), Some(40), "issue B");
let body = batched_patch_user_prompt("src/foo.ts", "fn x() {}\n", &[&a, &b]);
assert!(body.contains("issue A"));
assert!(body.contains("issue B"));
assert!(body.contains("ONE unified diff"));
assert!(body.contains("NO_PATCH"));
}
#[test]
fn single_prompt_omits_line_number() {
let a = issue_at(Some("src/foo.ts"), Some(42), "do not write null");
let body = patch_user_prompt("src/foo.ts", "fn x() {}\n", &a);
assert!(!body.contains("line 42"));
assert!(!body.contains("(line "));
assert!(body.contains("do not write null"));
}
#[test]
fn record_failure_pushes_one_entry_per_issue_with_same_reason() {
let a = issue_at(Some("src/foo.rs"), Some(10), "issue A");
let b = issue_at(Some("src/foo.rs"), Some(20), "issue B");
let issues: Vec<&ReviewIssueRecord> = vec![&a, &b];
let mut outcome = ApplyOutcome::default();
record_failure(
&mut outcome,
&issues,
"src/foo.rs",
1,
1,
"validation: nope",
false,
);
assert_eq!(outcome.failed.len(), 2);
assert!(outcome.applied.is_empty());
for (_, reason) in &outcome.failed {
assert_eq!(reason, "validation: nope");
}
}
#[test]
fn record_failure_preserves_issue_metadata_in_outcome_issue() {
let a = ReviewIssueRecord {
severity: "warning".into(),
rule: "Pin Actions".into(),
rule_id: Some("pin-actions".into()),
message: "msg".into(),
file: Some(".github/workflows/pr.yml".into()),
line: Some(12),
suggestion: None,
source_badge: None,
perspectives: Vec::new(),
confidence: 0.9,
};
let issues: Vec<&ReviewIssueRecord> = vec![&a];
let mut outcome = ApplyOutcome::default();
record_failure(
&mut outcome,
&issues,
".github/workflows/pr.yml:12",
1,
1,
"boom",
false,
);
let (issue, reason) = &outcome.failed[0];
assert_eq!(issue.rule_id.as_deref(), Some("pin-actions"));
assert_eq!(issue.rule_name, "Pin Actions");
assert_eq!(reason, "boom");
}
#[test]
fn rollback_pending_patches_reverses_worktree_changes() {
let tmp = tempfile::tempdir().unwrap();
difflore_core::infra::git::git_command(tmp.path())
.arg("init")
.output()
.unwrap();
for (key, value) in [("core.autocrlf", "false"), ("core.eol", "lf")] {
difflore_core::infra::git::git_command(tmp.path())
.args(["config", key, value])
.output()
.unwrap();
}
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
let file = tmp.path().join("src/foo.txt");
std::fs::write(&file, "old\n").unwrap();
let diff = "--- a/src/foo.txt\n+++ b/src/foo.txt\n@@ -1 +1 @@\n-old\n+new\n";
with_diff_tempfile(diff, |diff_path| {
run_git_apply(tmp.path(), diff_path.unwrap(), false).unwrap();
});
assert_eq!(std::fs::read_to_string(&file).unwrap(), "new\n");
let mut outcome = ApplyOutcome::default();
let mut pending = vec![PendingAppliedPatch {
diff: diff.to_owned(),
issues: vec![OutcomeIssue {
rule_id: None,
rule_name: "R".into(),
file_path: Some("src/foo.txt".into()),
file_loc: "src/foo.txt:1".into(),
}],
accepted_edit: None,
synced_index: false,
}];
rollback_pending_patches(&mut outcome, tmp.path(), &mut pending, "later file failed");
assert!(pending.is_empty());
assert_eq!(std::fs::read_to_string(&file).unwrap(), "old\n");
assert_eq!(outcome.failed.len(), 1);
assert!(outcome.failed[0].1.contains("rolled back"));
}
fn run_git_cmd(dir: &Path, args: &[&str]) {
let out = difflore_core::infra::git::git_command(dir)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn init_test_repo() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
run_git_cmd(tmp.path(), &["init"]);
run_git_cmd(tmp.path(), &["config", "core.autocrlf", "false"]);
run_git_cmd(tmp.path(), &["config", "core.eol", "lf"]);
run_git_cmd(tmp.path(), &["config", "user.email", "t@difflore.test"]);
run_git_cmd(tmp.path(), &["config", "user.name", "t"]);
tmp
}
fn indexed_content(dir: &Path, rel: &str) -> String {
let out = difflore_core::infra::git::git_command(dir)
.args(["show", &format!(":{rel}")])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[test]
fn unstaged_change_applies_to_worktree_without_failing_on_index() {
let tmp = init_test_repo();
let rel = "src/foo.txt";
let abs = tmp.path().join(rel);
std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
std::fs::write(&abs, "v1\n").unwrap();
run_git_cmd(tmp.path(), &["add", "."]);
run_git_cmd(tmp.path(), &["commit", "-m", "base"]);
std::fs::write(&abs, "v2\n").unwrap();
let diff = "--- a/src/foo.txt\n+++ b/src/foo.txt\n@@ -1 +1 @@\n-v2\n+v3\n";
let issue = issue_at(Some(rel), Some(1), "msg");
let issues = [&issue];
let pending =
apply_diff_transactionally(tmp.path(), diff, true, &abs, "v2\n", rel, &issues, false)
.expect("worktree-only patch must apply even with index-sync requested");
assert!(
!pending.synced_index,
"an unstaged change must not be mirrored into the index"
);
assert_eq!(std::fs::read_to_string(&abs).unwrap(), "v3\n");
assert_eq!(
indexed_content(tmp.path(), rel),
"v1\n",
"index must stay at the committed content"
);
}
#[test]
fn staged_change_syncs_worktree_and_index() {
let tmp = init_test_repo();
let rel = "src/foo.txt";
let abs = tmp.path().join(rel);
std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
std::fs::write(&abs, "v1\n").unwrap();
run_git_cmd(tmp.path(), &["add", "."]);
run_git_cmd(tmp.path(), &["commit", "-m", "base"]);
std::fs::write(&abs, "v2\n").unwrap();
run_git_cmd(tmp.path(), &["add", rel]);
let diff = "--- a/src/foo.txt\n+++ b/src/foo.txt\n@@ -1 +1 @@\n-v2\n+v3\n";
let issue = issue_at(Some(rel), Some(1), "msg");
let issues = [&issue];
let pending =
apply_diff_transactionally(tmp.path(), diff, true, &abs, "v2\n", rel, &issues, false)
.expect("staged patch must apply");
assert!(
pending.synced_index,
"a fully staged change must mirror into the index"
);
assert_eq!(std::fs::read_to_string(&abs).unwrap(), "v3\n");
assert_eq!(
indexed_content(tmp.path(), rel),
"v3\n",
"index must be synced to the applied fix"
);
}
#[test]
fn focused_patch_context_keeps_nearby_issue_lines_for_large_files() {
let content = (1..=120)
.map(|n| format!("line {n} {}", "x".repeat(50)))
.collect::<Vec<_>>()
.join("\n");
let focused = focused_patch_context(&content, Some(60));
assert!(focused.contains("line 1"));
assert!(focused.contains("line 60"));
assert!(focused.contains("line 36"));
assert!(focused.contains("line 84"));
assert!(focused.contains("..."));
assert!(!focused.contains("line 120"));
}
}