Skip to main content

battlecommand_forge/
editor.rs

1//! Edit existing codebases with AI assistance.
2//!
3//! Reads an existing project, sends the file tree + relevant code to the LLM,
4//! and applies changes through the same quality pipeline.
5
6use anyhow::Result;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use crate::llm::LlmClient;
11
12/// Build a file tree summary of an existing project.
13pub fn build_file_tree(dir: &Path) -> Result<String> {
14    let mut tree = String::new();
15    build_tree_recursive(dir, dir, &mut tree, 0)?;
16    Ok(tree)
17}
18
19fn build_tree_recursive(root: &Path, dir: &Path, tree: &mut String, depth: usize) -> Result<()> {
20    let mut entries: Vec<_> = fs::read_dir(dir)?.flatten().collect();
21    entries.sort_by_key(|e| e.file_name());
22
23    for entry in entries {
24        let path = entry.path();
25        let name = entry.file_name().to_string_lossy().to_string();
26
27        // Skip hidden dirs, node_modules, target, __pycache__, .git
28        if name.starts_with('.')
29            || name == "node_modules"
30            || name == "target"
31            || name == "__pycache__"
32            || name == ".git"
33            || name == "venv"
34        {
35            continue;
36        }
37
38        let indent = "  ".repeat(depth);
39        if path.is_dir() {
40            tree.push_str(&format!("{}{}/\n", indent, name));
41            build_tree_recursive(root, &path, tree, depth + 1)?;
42        } else {
43            let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
44            tree.push_str(&format!("{}{}  ({} bytes)\n", indent, name, size));
45        }
46    }
47    Ok(())
48}
49
50/// Read relevant source files from a project (up to a size limit).
51pub fn read_project_context(dir: &Path, max_bytes: usize) -> Result<String> {
52    let source_exts = [
53        "py", "rs", "ts", "tsx", "js", "jsx", "go", "java", "toml", "json", "yaml", "yml",
54    ];
55    let mut context = String::new();
56    let mut total_bytes = 0;
57
58    let files = collect_source_files(dir, &source_exts)?;
59
60    for file in &files {
61        if total_bytes >= max_bytes {
62            context.push_str(&format!("\n... ({} more files truncated)\n", files.len()));
63            break;
64        }
65
66        let relative = file.strip_prefix(dir).unwrap_or(file);
67        match fs::read_to_string(file) {
68            Ok(content) => {
69                let truncated = if content.len() > 2000 {
70                    format!(
71                        "{}...\n(truncated, {} total lines)",
72                        &content[..2000],
73                        content.lines().count()
74                    )
75                } else {
76                    content.clone()
77                };
78
79                context.push_str(&format!(
80                    "\n### {}\n```\n{}\n```\n",
81                    relative.display(),
82                    truncated
83                ));
84                total_bytes += content.len();
85            }
86            Err(_) => continue,
87        }
88    }
89
90    Ok(context)
91}
92
93/// Generate an edit plan for an existing codebase.
94pub async fn plan_edit(
95    llm: &LlmClient,
96    project_dir: &Path,
97    edit_prompt: &str,
98    quality_bible: &str,
99) -> Result<EditPlan> {
100    let file_tree = build_file_tree(project_dir)?;
101    let project_context = read_project_context(project_dir, 50_000)?;
102
103    let system = format!(
104        "{}\n\nYou are a Senior Software Engineer editing an existing codebase.\n\
105         Analyze the file tree and existing code, then produce an edit plan.\n\n\
106         Output a JSON object with:\n\
107         - \"files_to_modify\": [{{\"path\": \"...\", \"description\": \"what to change\"}}]\n\
108         - \"files_to_create\": [{{\"path\": \"...\", \"description\": \"what it contains\"}}]\n\
109         - \"files_to_delete\": [\"path\"]\n\
110         - \"summary\": \"1-2 sentence summary of changes\"\n\n\
111         Output ONLY valid JSON.",
112        quality_bible
113    );
114
115    let user_prompt = format!(
116        "Edit request: {}\n\nFile tree:\n{}\n\nExisting code:\n{}",
117        edit_prompt, file_tree, project_context
118    );
119
120    let response = llm.generate("EDIT-PLAN", &system, &user_prompt).await?;
121
122    // Parse the plan
123    let json_str = extract_json_object(&response);
124    match serde_json::from_str::<EditPlan>(&json_str) {
125        Ok(plan) => Ok(plan),
126        Err(_) => {
127            // Fallback plan
128            Ok(EditPlan {
129                files_to_modify: vec![],
130                files_to_create: vec![],
131                files_to_delete: vec![],
132                summary: format!("Edit: {}", edit_prompt),
133            })
134        }
135    }
136}
137
138/// Apply edits to files using the LLM.
139pub async fn apply_edits(
140    llm: &LlmClient,
141    project_dir: &Path,
142    plan: &EditPlan,
143    edit_prompt: &str,
144    quality_bible: &str,
145) -> Result<Vec<PathBuf>> {
146    let mut modified_files = Vec::new();
147
148    let system = format!(
149        "{}\n\nYou are editing an existing file. Output the COMPLETE updated file content.\n\
150         Do not omit any existing code unless the edit requires removing it.\n\
151         Output ONLY the file content in a code fence, no explanations.",
152        quality_bible
153    );
154
155    // Modify existing files
156    for file_spec in &plan.files_to_modify {
157        let file_path = match crate::sandbox::validate_path_within(project_dir, &file_spec.path) {
158            Ok(p) => p,
159            Err(e) => {
160                eprintln!("[SECURITY] Skipping modify: {}", e);
161                continue;
162            }
163        };
164        if let Ok(existing_content) = fs::read_to_string(&file_path) {
165            let prompt = format!(
166                "Edit this file according to: {}\n\nChange needed: {}\n\nCurrent content:\n```\n{}\n```",
167                edit_prompt, file_spec.description, existing_content
168            );
169
170            if let Ok(response) = llm
171                .generate(&format!("EDIT {}", file_spec.path), &system, &prompt)
172                .await
173            {
174                let new_content = crate::llm::extract_code(&response, "");
175                if !new_content.is_empty() {
176                    fs::write(&file_path, &new_content)?;
177                    modified_files.push(file_path);
178                }
179            }
180        }
181    }
182
183    // Create new files
184    for file_spec in &plan.files_to_create {
185        let file_path = match crate::sandbox::validate_path_within(project_dir, &file_spec.path) {
186            Ok(p) => p,
187            Err(e) => {
188                eprintln!("[SECURITY] Skipping create: {}", e);
189                continue;
190            }
191        };
192        let prompt = format!(
193            "Create this new file for: {}\n\nFile: {}\nPurpose: {}",
194            edit_prompt, file_spec.path, file_spec.description
195        );
196
197        if let Ok(response) = llm
198            .generate(&format!("CREATE {}", file_spec.path), &system, &prompt)
199            .await
200        {
201            let content = crate::llm::extract_code(&response, "");
202            if !content.is_empty() {
203                if let Some(parent) = file_path.parent() {
204                    fs::create_dir_all(parent)?;
205                }
206                fs::write(&file_path, &content)?;
207                modified_files.push(file_path);
208            }
209        }
210    }
211
212    // Delete files
213    for path in &plan.files_to_delete {
214        let file_path = match crate::sandbox::validate_path_within(project_dir, path) {
215            Ok(p) => p,
216            Err(e) => {
217                eprintln!("[SECURITY] Skipping delete: {}", e);
218                continue;
219            }
220        };
221        if file_path.exists() {
222            fs::remove_file(&file_path)?;
223        }
224    }
225
226    Ok(modified_files)
227}
228
229#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
230pub struct EditPlan {
231    #[serde(default)]
232    pub files_to_modify: Vec<FileSpec>,
233    #[serde(default)]
234    pub files_to_create: Vec<FileSpec>,
235    #[serde(default)]
236    pub files_to_delete: Vec<String>,
237    #[serde(default)]
238    pub summary: String,
239}
240
241#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
242pub struct FileSpec {
243    pub path: String,
244    pub description: String,
245}
246
247fn extract_json_object(raw: &str) -> String {
248    // Try inside code fences
249    if let Some(start) = raw.find("```json") {
250        let after = &raw[start + 7..];
251        if let Some(end) = after.find("```") {
252            return after[..end].trim().to_string();
253        }
254    }
255
256    // Try raw JSON object
257    if let Some(start) = raw.find('{') {
258        if let Some(end) = raw.rfind('}') {
259            if end > start {
260                return raw[start..=end].to_string();
261            }
262        }
263    }
264
265    raw.trim().to_string()
266}
267
268fn collect_source_files(dir: &Path, extensions: &[&str]) -> Result<Vec<PathBuf>> {
269    let mut files = Vec::new();
270    if !dir.is_dir() {
271        return Ok(files);
272    }
273
274    for entry in fs::read_dir(dir)? {
275        let entry = entry?;
276        let path = entry.path();
277        let name = entry.file_name().to_string_lossy().to_string();
278
279        if name.starts_with('.')
280            || name == "node_modules"
281            || name == "target"
282            || name == "__pycache__"
283            || name == "venv"
284        {
285            continue;
286        }
287
288        if path.is_dir() {
289            files.extend(collect_source_files(&path, extensions)?);
290        } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
291            if extensions.contains(&ext) {
292                files.push(path);
293            }
294        }
295    }
296    Ok(files)
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_extract_json_object() {
305        let raw = "Here's the plan:\n```json\n{\"summary\": \"test\"}\n```";
306        assert_eq!(extract_json_object(raw), "{\"summary\": \"test\"}");
307    }
308
309    #[test]
310    fn test_extract_json_object_raw() {
311        let raw = "blah {\"key\": \"val\"} more";
312        assert_eq!(extract_json_object(raw), "{\"key\": \"val\"}");
313    }
314
315    #[test]
316    fn test_build_file_tree() {
317        // Should not panic on current directory
318        let tree = build_file_tree(Path::new(".")).unwrap();
319        assert!(tree.contains("Cargo.toml"));
320    }
321}