use anyhow::Result;
use std::fs;
use std::path::{Path, PathBuf};
use crate::llm::LlmClient;
pub fn build_file_tree(dir: &Path) -> Result<String> {
let mut tree = String::new();
build_tree_recursive(dir, dir, &mut tree, 0)?;
Ok(tree)
}
fn build_tree_recursive(root: &Path, dir: &Path, tree: &mut String, depth: usize) -> Result<()> {
let mut entries: Vec<_> = fs::read_dir(dir)?.flatten().collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.')
|| name == "node_modules"
|| name == "target"
|| name == "__pycache__"
|| name == ".git"
|| name == "venv"
{
continue;
}
let indent = " ".repeat(depth);
if path.is_dir() {
tree.push_str(&format!("{}{}/\n", indent, name));
build_tree_recursive(root, &path, tree, depth + 1)?;
} else {
let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
tree.push_str(&format!("{}{} ({} bytes)\n", indent, name, size));
}
}
Ok(())
}
pub fn read_project_context(dir: &Path, max_bytes: usize) -> Result<String> {
let source_exts = [
"py", "rs", "ts", "tsx", "js", "jsx", "go", "java", "toml", "json", "yaml", "yml",
];
let mut context = String::new();
let mut total_bytes = 0;
let files = collect_source_files(dir, &source_exts)?;
for file in &files {
if total_bytes >= max_bytes {
context.push_str(&format!("\n... ({} more files truncated)\n", files.len()));
break;
}
let relative = file.strip_prefix(dir).unwrap_or(file);
match fs::read_to_string(file) {
Ok(content) => {
let truncated = if content.len() > 2000 {
format!(
"{}...\n(truncated, {} total lines)",
&content[..2000],
content.lines().count()
)
} else {
content.clone()
};
context.push_str(&format!(
"\n### {}\n```\n{}\n```\n",
relative.display(),
truncated
));
total_bytes += content.len();
}
Err(_) => continue,
}
}
Ok(context)
}
pub async fn plan_edit(
llm: &LlmClient,
project_dir: &Path,
edit_prompt: &str,
quality_bible: &str,
) -> Result<EditPlan> {
let file_tree = build_file_tree(project_dir)?;
let project_context = read_project_context(project_dir, 50_000)?;
let system = format!(
"{}\n\nYou are a Senior Software Engineer editing an existing codebase.\n\
Analyze the file tree and existing code, then produce an edit plan.\n\n\
Output a JSON object with:\n\
- \"files_to_modify\": [{{\"path\": \"...\", \"description\": \"what to change\"}}]\n\
- \"files_to_create\": [{{\"path\": \"...\", \"description\": \"what it contains\"}}]\n\
- \"files_to_delete\": [\"path\"]\n\
- \"summary\": \"1-2 sentence summary of changes\"\n\n\
Output ONLY valid JSON.",
quality_bible
);
let user_prompt = format!(
"Edit request: {}\n\nFile tree:\n{}\n\nExisting code:\n{}",
edit_prompt, file_tree, project_context
);
let response = llm.generate("EDIT-PLAN", &system, &user_prompt).await?;
let json_str = extract_json_object(&response);
match serde_json::from_str::<EditPlan>(&json_str) {
Ok(plan) => Ok(plan),
Err(_) => {
Ok(EditPlan {
files_to_modify: vec![],
files_to_create: vec![],
files_to_delete: vec![],
summary: format!("Edit: {}", edit_prompt),
})
}
}
}
pub async fn apply_edits(
llm: &LlmClient,
project_dir: &Path,
plan: &EditPlan,
edit_prompt: &str,
quality_bible: &str,
) -> Result<Vec<PathBuf>> {
let mut modified_files = Vec::new();
let system = format!(
"{}\n\nYou are editing an existing file. Output the COMPLETE updated file content.\n\
Do not omit any existing code unless the edit requires removing it.\n\
Output ONLY the file content in a code fence, no explanations.",
quality_bible
);
for file_spec in &plan.files_to_modify {
let file_path = match crate::sandbox::validate_path_within(project_dir, &file_spec.path) {
Ok(p) => p,
Err(e) => {
eprintln!("[SECURITY] Skipping modify: {}", e);
continue;
}
};
if let Ok(existing_content) = fs::read_to_string(&file_path) {
let prompt = format!(
"Edit this file according to: {}\n\nChange needed: {}\n\nCurrent content:\n```\n{}\n```",
edit_prompt, file_spec.description, existing_content
);
if let Ok(response) = llm
.generate(&format!("EDIT {}", file_spec.path), &system, &prompt)
.await
{
let new_content = crate::llm::extract_code(&response, "");
if !new_content.is_empty() {
fs::write(&file_path, &new_content)?;
modified_files.push(file_path);
}
}
}
}
for file_spec in &plan.files_to_create {
let file_path = match crate::sandbox::validate_path_within(project_dir, &file_spec.path) {
Ok(p) => p,
Err(e) => {
eprintln!("[SECURITY] Skipping create: {}", e);
continue;
}
};
let prompt = format!(
"Create this new file for: {}\n\nFile: {}\nPurpose: {}",
edit_prompt, file_spec.path, file_spec.description
);
if let Ok(response) = llm
.generate(&format!("CREATE {}", file_spec.path), &system, &prompt)
.await
{
let content = crate::llm::extract_code(&response, "");
if !content.is_empty() {
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&file_path, &content)?;
modified_files.push(file_path);
}
}
}
for path in &plan.files_to_delete {
let file_path = match crate::sandbox::validate_path_within(project_dir, path) {
Ok(p) => p,
Err(e) => {
eprintln!("[SECURITY] Skipping delete: {}", e);
continue;
}
};
if file_path.exists() {
fs::remove_file(&file_path)?;
}
}
Ok(modified_files)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct EditPlan {
#[serde(default)]
pub files_to_modify: Vec<FileSpec>,
#[serde(default)]
pub files_to_create: Vec<FileSpec>,
#[serde(default)]
pub files_to_delete: Vec<String>,
#[serde(default)]
pub summary: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FileSpec {
pub path: String,
pub description: String,
}
fn extract_json_object(raw: &str) -> String {
if let Some(start) = raw.find("```json") {
let after = &raw[start + 7..];
if let Some(end) = after.find("```") {
return after[..end].trim().to_string();
}
}
if let Some(start) = raw.find('{') {
if let Some(end) = raw.rfind('}') {
if end > start {
return raw[start..=end].to_string();
}
}
}
raw.trim().to_string()
}
fn collect_source_files(dir: &Path, extensions: &[&str]) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
if !dir.is_dir() {
return Ok(files);
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.')
|| name == "node_modules"
|| name == "target"
|| name == "__pycache__"
|| name == "venv"
{
continue;
}
if path.is_dir() {
files.extend(collect_source_files(&path, extensions)?);
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if extensions.contains(&ext) {
files.push(path);
}
}
}
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_json_object() {
let raw = "Here's the plan:\n```json\n{\"summary\": \"test\"}\n```";
assert_eq!(extract_json_object(raw), "{\"summary\": \"test\"}");
}
#[test]
fn test_extract_json_object_raw() {
let raw = "blah {\"key\": \"val\"} more";
assert_eq!(extract_json_object(raw), "{\"key\": \"val\"}");
}
#[test]
fn test_build_file_tree() {
let tree = build_file_tree(Path::new(".")).unwrap();
assert!(tree.contains("Cargo.toml"));
}
}