1use anyhow::Result;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use crate::llm::LlmClient;
11
12pub 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 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
50pub 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
93pub 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 let json_str = extract_json_object(&response);
124 match serde_json::from_str::<EditPlan>(&json_str) {
125 Ok(plan) => Ok(plan),
126 Err(_) => {
127 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
138pub 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 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 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 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 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 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 let tree = build_file_tree(Path::new(".")).unwrap();
319 assert!(tree.contains("Cargo.toml"));
320 }
321}