use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct AstMutationRequest {
pub target_file: PathBuf,
pub target_fn: String,
pub mutation_type: MutationType,
pub new_body: Option<String>,
}
#[derive(Debug, Clone)]
pub enum MutationType {
ReplaceFnBody,
AddParameter { name: String, ty: String },
WrapInCache { cache_key: String },
ExtractToModule { module_name: String },
InlineConstants,
}
#[derive(Debug)]
pub struct AstMutationResult {
pub success: bool,
pub compiler_errors: Vec<CompilerDiagnostic>,
pub diff: String,
pub worktree_path: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct CompilerDiagnostic {
pub level: DiagnosticLevel,
pub message: String,
pub file: String,
pub line: u32,
pub column: u32,
pub span_text: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DiagnosticLevel {
Error,
Warning,
Note,
Help,
}
impl AstMutationResult {
pub fn compile_failed(errors: Vec<CompilerDiagnostic>) -> Self {
Self {
success: false,
compiler_errors: errors,
diff: String::new(),
worktree_path: None,
}
}
pub fn not_found(fn_name: &str) -> Self {
Self {
success: false,
compiler_errors: vec![CompilerDiagnostic {
level: DiagnosticLevel::Error,
message: format!("Function `{}` not found in target file", fn_name),
file: String::new(),
line: 0,
column: 0,
span_text: String::new(),
}],
diff: String::new(),
worktree_path: None,
}
}
pub fn error_prompt(&self) -> String {
if self.success {
return String::from("Mutation compiled successfully.");
}
let mut prompt = String::from("FROST ❄️ — Compiler rejected mutation:\n\n");
for err in &self.compiler_errors {
prompt.push_str(&format!(
" [{:?}] {}:{},{}: {}\n",
err.level, err.file, err.line, err.column, err.message
));
if !err.span_text.is_empty() {
prompt.push_str(&format!(" | {}\n", err.span_text));
}
}
prompt
}
}
pub fn create_shadow_worktree(repo_root: &Path) -> Result<PathBuf, WorktreeError> {
let worktree_name = format!("evolution-{}", uuid_short());
let worktree_path = repo_root.join(".worktrees").join(&worktree_name);
let output = Command::new("git")
.args(["worktree", "add", "--detach"])
.arg(&worktree_path)
.arg("HEAD")
.current_dir(repo_root)
.output()
.map_err(|e| WorktreeError::GitFailed(e.to_string()))?;
if !output.status.success() {
return Err(WorktreeError::GitFailed(
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
Ok(worktree_path)
}
pub fn cleanup_worktree(repo_root: &Path, worktree_path: &Path) -> Result<(), WorktreeError> {
let output = Command::new("git")
.args(["worktree", "remove", "--force"])
.arg(worktree_path)
.current_dir(repo_root)
.output()
.map_err(|e| WorktreeError::GitFailed(e.to_string()))?;
if !output.status.success() {
let _ = std::fs::remove_dir_all(worktree_path);
let _ = Command::new("git")
.args(["worktree", "prune"])
.current_dir(repo_root)
.output();
}
Ok(())
}
pub fn cargo_check_json(working_dir: &Path) -> Result<Vec<CompilerDiagnostic>, String> {
let output = Command::new("cargo")
.args(["check", "--message-format=json"])
.current_dir(working_dir)
.output()
.map_err(|e| format!("Failed to run cargo check: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut diagnostics = Vec::new();
for line in stdout.lines() {
if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
if msg["reason"] == "compiler-message" {
if let Some(diag) = parse_diagnostic(&msg["message"]) {
diagnostics.push(diag);
}
}
}
}
Ok(diagnostics)
}
fn parse_diagnostic(msg: &serde_json::Value) -> Option<CompilerDiagnostic> {
let level = match msg["level"].as_str()? {
"error" => DiagnosticLevel::Error,
"warning" => DiagnosticLevel::Warning,
"note" => DiagnosticLevel::Note,
"help" => DiagnosticLevel::Help,
_ => return None,
};
let message = msg["message"].as_str()?.to_string();
let spans = msg["spans"].as_array()?;
let primary = spans
.iter()
.find(|s| s["is_primary"].as_bool() == Some(true))?;
Some(CompilerDiagnostic {
level,
message,
file: primary["file_name"].as_str().unwrap_or("").to_string(),
line: primary["line_start"].as_u64().unwrap_or(0) as u32,
column: primary["column_start"].as_u64().unwrap_or(0) as u32,
span_text: primary["text"]
.as_array()
.and_then(|t| t.first())
.and_then(|t| t["text"].as_str())
.unwrap_or("")
.to_string(),
})
}
pub fn verify_edit_or_rollback(repo_root: &Path, edited_file: &Path) -> Result<bool, String> {
if edited_file.extension().and_then(|e| e.to_str()) != Some("rs") {
return Ok(true);
}
let diagnostics = cargo_check_json(repo_root)?;
let has_errors = diagnostics
.iter()
.any(|d| d.level == DiagnosticLevel::Error);
if has_errors {
let _ = Command::new("git")
.args(["checkout", "--"])
.arg(edited_file)
.current_dir(repo_root)
.output();
}
Ok(!has_errors)
}
fn uuid_short() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let t = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("{:x}", t % 0xFFFF_FFFF)
}
#[derive(Debug)]
pub enum WorktreeError {
GitFailed(String),
IoError(std::io::Error),
}
impl std::fmt::Display for WorktreeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::GitFailed(msg) => write!(f, "Git worktree operation failed: {}", msg),
Self::IoError(e) => write!(f, "IO error: {}", e),
}
}
}
impl std::error::Error for WorktreeError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_diagnostic_parsing() {
let json = serde_json::json!({
"level": "error",
"message": "cannot find value `x` in this scope",
"spans": [{
"is_primary": true,
"file_name": "src/main.rs",
"line_start": 42,
"column_start": 5,
"text": [{ "text": " let y = x + 1;" }]
}]
});
let diag = parse_diagnostic(&json).unwrap();
assert_eq!(diag.level, DiagnosticLevel::Error);
assert_eq!(diag.line, 42);
assert_eq!(diag.file, "src/main.rs");
}
#[test]
fn test_error_prompt_formatting() {
let result = AstMutationResult::compile_failed(vec![CompilerDiagnostic {
level: DiagnosticLevel::Error,
message: "mismatched types".to_string(),
file: "src/memory.rs".to_string(),
line: 301,
column: 12,
span_text: "fn evict_oldest(&mut self) -> u64".to_string(),
}]);
let prompt = result.error_prompt();
assert!(prompt.contains("FROST"));
assert!(prompt.contains("mismatched types"));
assert!(prompt.contains("memory.rs:301"));
}
#[test]
fn test_not_found_result() {
let result = AstMutationResult::not_found("nonexistent_fn");
assert!(!result.success);
assert!(result.error_prompt().contains("nonexistent_fn"));
}
#[test]
fn test_is_protected_from_parent() {
use super::super::is_protected;
assert!(is_protected(Path::new("src/evolution/ast_tools.rs")));
assert!(!is_protected(Path::new("src/tools/file_edit.rs")));
}
#[test]
fn test_error_prompt_success_case() {
let result = AstMutationResult {
success: true,
compiler_errors: vec![],
diff: "some diff".to_string(),
worktree_path: Some(PathBuf::from("/tmp/test")),
};
assert_eq!(result.error_prompt(), "Mutation compiled successfully.");
}
#[test]
fn test_compile_failed_empty_errors() {
let result = AstMutationResult::compile_failed(vec![]);
assert!(!result.success);
assert!(result.compiler_errors.is_empty());
assert!(result.diff.is_empty());
assert!(result.worktree_path.is_none());
let prompt = result.error_prompt();
assert!(prompt.contains("FROST"));
}
#[test]
fn test_diagnostic_parsing_missing_primary() {
let json = serde_json::json!({
"level": "error",
"message": "some error",
"spans": [{
"is_primary": false,
"file_name": "src/main.rs",
"line_start": 1,
"column_start": 1,
"text": [{ "text": "code" }]
}]
});
assert!(parse_diagnostic(&json).is_none());
}
#[test]
fn test_diagnostic_parsing_unknown_level() {
let json = serde_json::json!({
"level": "ice",
"message": "internal compiler error",
"spans": [{
"is_primary": true,
"file_name": "src/main.rs",
"line_start": 1,
"column_start": 1,
"text": [{ "text": "code" }]
}]
});
assert!(parse_diagnostic(&json).is_none());
}
#[test]
fn test_diagnostic_parsing_missing_fields() {
let json = serde_json::json!({});
assert!(parse_diagnostic(&json).is_none());
let json = serde_json::json!({
"level": "error"
});
assert!(parse_diagnostic(&json).is_none());
let json = serde_json::json!({
"level": "error",
"message": "test"
});
assert!(parse_diagnostic(&json).is_none());
}
#[test]
fn test_uuid_short_uniqueness() {
let a = uuid_short();
std::thread::sleep(std::time::Duration::from_millis(1));
let b = uuid_short();
assert_ne!(a, b, "Two uuid_short calls should produce different values");
}
#[test]
fn test_error_prompt_multiple_errors() {
let result = AstMutationResult::compile_failed(vec![
CompilerDiagnostic {
level: DiagnosticLevel::Error,
message: "type mismatch".to_string(),
file: "src/lib.rs".to_string(),
line: 10,
column: 5,
span_text: "let x: u32 = \"hello\"".to_string(),
},
CompilerDiagnostic {
level: DiagnosticLevel::Warning,
message: "unused variable".to_string(),
file: "src/lib.rs".to_string(),
line: 20,
column: 9,
span_text: String::new(), },
]);
let prompt = result.error_prompt();
assert!(prompt.contains("type mismatch"));
assert!(prompt.contains("unused variable"));
assert!(prompt.contains("lib.rs:10"));
assert!(prompt.contains("lib.rs:20"));
assert!(prompt.contains("let x: u32"));
}
#[test]
fn test_diagnostic_all_levels() {
for (level_str, expected) in [
("error", DiagnosticLevel::Error),
("warning", DiagnosticLevel::Warning),
("note", DiagnosticLevel::Note),
("help", DiagnosticLevel::Help),
] {
let json = serde_json::json!({
"level": level_str,
"message": "test message",
"spans": [{
"is_primary": true,
"file_name": "test.rs",
"line_start": 1,
"column_start": 1,
"text": [{ "text": "code" }]
}]
});
let diag = parse_diagnostic(&json).unwrap();
assert_eq!(diag.level, expected);
}
}
#[test]
fn test_worktree_error_display() {
let git_err = WorktreeError::GitFailed("branch conflict".to_string());
assert!(format!("{}", git_err).contains("branch conflict"));
let io_err = WorktreeError::IoError(std::io::Error::new(
std::io::ErrorKind::NotFound,
"file not found",
));
assert!(format!("{}", io_err).contains("IO error"));
}
}