use std::path::Path;
use std::fs::{self, File};
use std::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;
use std::io::Write;
use tracing::error;
use std::process::Command;
pub fn atomic_write(path: &str, content: &str) -> Result<(), String> {
let tmp = format!("{}.tmp.{}", path, std::process::id());
let mut file = File::create(&tmp).map_err(|e| format!("create tmp {}: {}", tmp, e))?;
file.write_all(content.as_bytes()).map_err(|e| format!("write: {}", e))?;
file.sync_all().map_err(|e| format!("sync: {}", e))?;
std::fs::rename(&tmp, path).map_err(|e| format!("rename: {}", e))?;
Ok(())
}
fn change_hash(file: &str, new_content: &str) -> (u64, u64) {
let mut h = DefaultHasher::new();
file.hash(&mut h);
new_content.hash(&mut h);
let file_hash = h.finish();
let mut h2 = DefaultHasher::new();
new_content.hash(&mut h2);
let ident_hash = h2.finish();
(file_hash, ident_hash)
}
pub fn heal_edit(file: &str, new_content: &str, workdir: &str) -> Result<(), String> {
if !Path::new(file).exists() {
return Err(format!("File not found: {}", file));
}
let edit_cache_disabled = std::env::var("RELIARY_PROXY_DISABLE_EDIT_CACHE").is_ok_and(|v| v == "1");
let (file_hash, ident_hash) = change_hash(file, new_content);
let chronicle_path = format!("{}/.reliary/chronicle.sqlite", workdir.trim_end_matches('/'));
if !edit_cache_disabled {
if let Ok(db) = rusqlite::Connection::open(&chronicle_path) {
if let Some(outcome) = crate::chronicle::edit_cache_get(&db, file_hash, ident_hash) {
tracing::info!("edit_cache: hit (outcome={}) for {} ident={}", outcome, file, ident_hash);
if outcome == "pass" {
return Ok(());
}
} else {
tracing::info!("edit_cache: miss for {} ident={}", file, ident_hash);
}
}
}
let original = fs::read_to_string(file).map_err(|e| format!("Read: {}", e))?;
atomic_write(file, new_content)?;
let output = Command::new("cargo")
.args(["test", "--quiet"])
.current_dir(workdir)
.output()
.map_err(|e| format!("Test exec: {}", e))?;
let result = if output.status.success() {
if let Ok(db) = rusqlite::Connection::open(&chronicle_path) {
crate::chronicle::edit_cache_set(&db, file_hash, ident_hash, "pass");
}
Ok(())
} else {
if let Err(e) = atomic_write(file, &original) {
error!("heal revert FAILED for {}: {} — FILE MAY BE CORRUPTED", file, e);
}
if let Ok(db) = rusqlite::Connection::open(&chronicle_path) {
crate::chronicle::edit_cache_set(&db, file_hash, ident_hash, "fail");
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("{}{}", stdout, stderr);
let summary = extract_first_failure(&combined);
Err(summary)
};
result
}
fn extract_first_failure(output: &str) -> String {
for line in output.lines() {
let t = line.trim();
if t.contains("FAILED") && !t.contains("test result") {
return t.chars().take(120).collect();
}
if t.contains("panicked at") {
return t.chars().take(120).collect();
}
if t.contains("expected `true`, got `false`") || t.contains("assertion") {
return t.chars().take(120).collect();
}
}
let lines: Vec<&str> = output.lines().collect();
let count = lines.len();
if count >= 3 {
lines[count-3..].join(" | ").chars().take(150).collect()
} else {
"Tests failed — reverted".to_string()
}
}
pub fn heal_fix(file: &str, old: &str, new: &str, workdir: &str) -> Result<String, String> {
let content = fs::read_to_string(file).map_err(|e| format!("Read: {}", e))?;
let fixes = vec![(old.to_string(), new.to_string())];
let (modified, count) = reliary_fix::apply_fixes(&content, &fixes);
if count == 0 {
return Err("No matches found".to_string());
}
match heal_edit(file, &modified, workdir) {
Ok(()) => Ok(format!("OK: {} replacements, tests pass", count)),
Err(e) => Err(format!("{} (reverted)", e)),
}
}
pub fn batch_heal(edits: &[(String, String, String)], workdir: &str) -> String {
let mut originals: Vec<(String, String)> = Vec::new();
for (file, old, new) in edits {
let content = match fs::read_to_string(file) {
Ok(c) => c,
Err(e) => return format!("FAIL: cannot read {} — {}", file, e),
};
let fixes = vec![(old.clone(), new.clone())];
let (modified, count) = reliary_fix::apply_fixes(&content, &fixes);
if count == 0 { return format!("FAIL: no match in {}", file); }
originals.push((file.clone(), content));
if let Err(e) = atomic_write(file, &modified) {
return format!("FAIL: atomic write error {} — {}", file, e);
}
}
let output = Command::new("cargo").args(["test", "--quiet"]).current_dir(workdir).output();
match output {
Ok(out) if out.status.success() => {
format!("OK: {} files edited, tests pass", edits.len())
}
Ok(out) => {
for (file, original) in &originals { let _ = atomic_write(file, original); }
let combined = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
format!("REVERTED ({} files): {}", edits.len(), extract_first_failure(&combined))
}
Err(e) => {
for (file, original) in &originals { let _ = atomic_write(file, original); }
format!("REVERTED (all files): {}", e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_failed_line() {
let output = "running 1 test\ntest line_zone_code ... FAILED\n assertion failed\n";
let r = extract_first_failure(output);
assert!(r.contains("FAILED"), "Got: {}", r);
}
#[test]
fn test_extract_panicked_at() {
let output = "thread 'main' panicked at src/main.rs:42:\nindex out of bounds\n";
let r = extract_first_failure(output);
assert!(r.contains("panicked"), "Got: {}", r);
}
#[test]
fn test_extract_assertion() {
let output = "expected `true`, got `false`\n";
let r = extract_first_failure(output);
assert!(r.contains("expected"), "Got: {}", r);
}
#[test]
fn test_extract_fallback() {
let output = "line1\nline2\nline3\nline4\n";
let r = extract_first_failure(output);
assert!(r.contains("line2"), "Got: {}", r);
}
}