1use crate::errors::{RalphError, Result};
2use ignore::overrides::OverrideBuilder;
3use ignore::WalkBuilder;
4use regex::Regex;
5use std::path::Path;
6
7pub fn read_file(path: &Path) -> Result<String> {
8 if !path.exists() {
9 return Err(RalphError::FileNotFound(path.display().to_string()));
10 }
11 std::fs::read_to_string(path).map_err(RalphError::Io)
12}
13
14pub fn read_file_ranged(
22 path: &Path,
23 offset: Option<usize>,
24 limit: Option<usize>,
25) -> Result<String> {
26 if !path.exists() {
27 return Err(RalphError::FileNotFound(path.display().to_string()));
28 }
29 let content = std::fs::read_to_string(path).map_err(RalphError::Io)?;
30
31 let all_lines: Vec<&str> = content.lines().collect();
32 let total = all_lines.len();
33
34 let start = offset.unwrap_or(0).min(total);
35 let max_lines = limit.unwrap_or(150);
36 let end = (start + max_lines).min(total);
37
38 let mut out = String::new();
39 for line in &all_lines[start..end] {
40 out.push_str(line);
41 out.push('\n');
42 }
43
44 if end < total {
45 out.push_str(&format!(
46 "\n[...truncated after line {} of {}. \
47 To read further use `read_file` with `offset={}`.]",
48 end, total, end
49 ));
50 }
51
52 Ok(out)
53}
54
55pub fn list_dir(path: &Path) -> Result<String> {
56 if !path.exists() {
57 return Err(RalphError::FileNotFound(path.display().to_string()));
58 }
59
60 let mut entries = Vec::new();
61 let walker = WalkBuilder::new(path)
62 .hidden(false)
63 .ignore(true)
64 .git_ignore(true)
65 .max_depth(Some(4))
66 .build();
67
68 for entry in walker.flatten() {
69 let rel = entry
70 .path()
71 .strip_prefix(path)
72 .unwrap_or(entry.path())
73 .display()
74 .to_string();
75 if rel.is_empty() {
76 continue;
77 }
78 let suffix = if entry.path().is_dir() { "/" } else { "" };
79 entries.push(format!("{}{}", rel, suffix));
80 }
81 entries.sort();
82 Ok(entries.join("\n"))
83}
84
85pub fn write_file(path: &Path, content: &str) -> Result<()> {
86 if let Some(parent) = path.parent() {
87 std::fs::create_dir_all(parent)?;
88 }
89 std::fs::write(path, content).map_err(RalphError::Io)
90}
91
92pub fn edit_file(path: &Path, old_string: &str, new_string: &str) -> Result<()> {
93 let content = read_file(path)?;
94 if !content.contains(old_string) {
95 return Err(RalphError::EditNotFound {
96 path: path.display().to_string(),
97 });
98 }
99 let updated = content.replacen(old_string, new_string, 1);
100 std::fs::write(path, updated).map_err(RalphError::Io)
101}
102
103pub fn edit_file_multi(path: &Path, edits: &[(String, String)]) -> Result<Vec<String>> {
107 let mut content = read_file(path)?;
108 let mut applied = Vec::new();
109 for (i, (old, new)) in edits.iter().enumerate() {
110 if !content.contains(old.as_str()) {
111 let hint = find_closest_match_hint(&content, old);
112 return Err(RalphError::ToolFailed {
113 tool: "edit_file_multi".to_string(),
114 message: format!(
115 "edit[{i}]: old_string not found in {}.\n{hint}",
116 path.display()
117 ),
118 });
119 }
120 content = content.replacen(old.as_str(), new.as_str(), 1);
121 applied.push(format!("edit[{i}] applied"));
122 }
123 std::fs::write(path, content).map_err(RalphError::Io)?;
124 Ok(applied)
125}
126
127pub fn find_closest_match_hint(content: &str, old_string: &str) -> String {
131 let content_lines: Vec<&str> = content.lines().collect();
132 let search_lines: Vec<&str> = old_string.lines().collect();
133
134 let anchor = match search_lines.iter().find(|l| !l.trim().is_empty()) {
136 Some(l) => l.trim(),
137 None => return String::new(),
138 };
139
140 let sig_words: Vec<&str> = anchor
142 .split(|c: char| !c.is_alphanumeric() && c != '_')
143 .filter(|w| w.len() >= 3)
144 .collect();
145
146 if sig_words.is_empty() {
147 return String::new();
148 }
149
150 let mut best_score = 0usize;
152 let mut best_line = usize::MAX;
153 for (i, line) in content_lines.iter().enumerate() {
154 let lc = line.to_lowercase();
155 let score = sig_words.iter().filter(|w| lc.contains(*w)).count();
156 if score > best_score {
157 best_score = score;
158 best_line = i;
159 }
160 }
161
162 if best_score == 0 || best_line == usize::MAX {
163 return String::new();
164 }
165
166 let start = best_line.saturating_sub(2);
167 let end = (best_line + search_lines.len() + 3).min(content_lines.len());
168 let mut hint = format!("Closest match near line {}:\n", best_line + 1);
169 for i in start..end {
170 let marker = if i == best_line { ">>>" } else { " " };
171 hint.push_str(&format!("{} {:4}: {}\n", marker, i + 1, content_lines[i]));
172 }
173 hint
174}
175
176pub fn read_file_outline(path: &Path) -> Result<String> {
180 if !path.exists() {
181 return Err(RalphError::FileNotFound(path.display().to_string()));
182 }
183 let content = std::fs::read_to_string(path).map_err(RalphError::Io)?;
184 let lines: Vec<&str> = content.lines().collect();
185 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
186
187 let pats: &[&str] = match ext {
189 "rs" => &[
190 r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+\w+",
191 r"^\s*(?:pub(?:\([^)]*\))?\s+)?struct\s+\w+",
192 r"^\s*(?:pub(?:\([^)]*\))?\s+)?enum\s+\w+",
193 r"^\s*(?:pub(?:\([^)]*\))?\s+)?trait\s+\w+",
194 r"^\s*(?:pub(?:\([^)]*\))?\s+)?impl(?:<[^>]*>)?\s+\w+",
195 ],
196 "py" => &[r"^\s*(?:async\s+)?def\s+\w+", r"^\s*class\s+\w+"],
197 "js" | "ts" | "tsx" | "jsx" | "mjs" | "cjs" => &[
198 r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+\w+",
199 r"^\s*(?:export\s+)?(?:default\s+)?class\s+\w+",
200 r"^\s*(?:export\s+)?(?:const|let)\s+\w+\s*[=:]",
201 ],
202 "go" => &[
203 r"^\s*func\s+(?:\([^)]*\)\s+)?\w+",
204 r"^\s*type\s+\w+\s+(?:struct|interface)",
205 ],
206 "java" | "kt" => &[
207 r"^\s*(?:(?:public|private|protected|static|final|abstract|override)\s+)*\w+\s+\w+\s*\(",
208 r"^\s*(?:public\s+|private\s+|protected\s+)?(?:class|interface|enum|object)\s+\w+",
209 ],
210 "rb" => &[r"^\s*def\s+\w+", r"^\s*class\s+\w+", r"^\s*module\s+\w+"],
211 _ => &[],
212 };
213
214 let compiled: Vec<Regex> = pats.iter().filter_map(|p| Regex::new(p).ok()).collect();
215
216 if compiled.is_empty() {
217 return Ok(format!(
218 "{} ({} lines) — unknown file type; use read_file with offset/limit to navigate",
219 path.display(),
220 lines.len()
221 ));
222 }
223
224 let mut out = format!("{} ({} lines)\n", path.display(), lines.len());
225 for (i, line) in lines.iter().enumerate() {
226 if compiled.iter().any(|re| re.is_match(line)) {
227 out.push_str(&format!("{:5}: {}\n", i + 1, line.trim_end()));
228 }
229 }
230
231 if out.trim_end().ends_with(')') {
232 out.push_str("(no recognisable top-level definitions found)\n");
234 }
235
236 Ok(out)
237}
238
239pub fn delete_file(path: &Path) -> Result<()> {
240 if !path.exists() {
241 return Err(RalphError::FileNotFound(path.display().to_string()));
242 }
243 std::fs::remove_file(path).map_err(RalphError::Io)
244}
245
246pub fn load_files(pattern: &str, root: &Path) -> Result<String> {
250 let mut overrides = OverrideBuilder::new(root);
251 overrides.add(pattern).map_err(|e| RalphError::ToolFailed {
252 tool: "load_files".to_string(),
253 message: format!("Invalid glob pattern: {}", e),
254 })?;
255 let overrides = overrides.build().map_err(|e| RalphError::ToolFailed {
256 tool: "load_files".to_string(),
257 message: format!("Failed to build override: {}", e),
258 })?;
259
260 let walker = WalkBuilder::new(root)
261 .hidden(false)
262 .ignore(true)
263 .git_ignore(true)
264 .overrides(overrides)
265 .build();
266
267 let mut output = String::new();
268 let mut file_count = 0usize;
269 const MAX_FILES: usize = 100;
270 const MAX_BYTES: usize = 512 * 1024; for entry in walker.flatten() {
273 let path = entry.path();
274 if !path.is_file() {
275 continue;
276 }
277 let rel = path.strip_prefix(root).unwrap_or(path);
278 let content = match std::fs::read_to_string(path) {
279 Ok(c) => c,
280 Err(_) => continue, };
282
283 let header = format!("=== {} ===\n", rel.display());
284 if output.len() + header.len() + content.len() > MAX_BYTES {
285 output.push_str(&format!(
286 "\n[load_files] Output truncated at {} files / 500 KB limit.",
287 file_count
288 ));
289 break;
290 }
291
292 output.push_str(&header);
293 output.push_str(&content);
294 output.push('\n');
295 file_count += 1;
296
297 if file_count >= MAX_FILES {
298 output.push_str("\n[load_files] Reached 100-file limit.");
299 break;
300 }
301 }
302
303 if output.is_empty() {
304 Ok(format!("No files matched pattern: {}", pattern))
305 } else {
306 Ok(output)
307 }
308}
309
310struct ProjectInfo {
313 kind: &'static str,
314 manifest: &'static str,
315 src_ext: &'static str,
316}
317
318const PROJECT_SIGNATURES: &[ProjectInfo] = &[
319 ProjectInfo {
320 kind: "Rust",
321 manifest: "Cargo.toml",
322 src_ext: "rs",
323 },
324 ProjectInfo {
325 kind: "Node.js",
326 manifest: "package.json",
327 src_ext: "js",
328 },
329 ProjectInfo {
330 kind: "TypeScript",
331 manifest: "tsconfig.json",
332 src_ext: "ts",
333 },
334 ProjectInfo {
335 kind: "Python",
336 manifest: "pyproject.toml",
337 src_ext: "py",
338 },
339 ProjectInfo {
340 kind: "Python",
341 manifest: "setup.py",
342 src_ext: "py",
343 },
344 ProjectInfo {
345 kind: "Go",
346 manifest: "go.mod",
347 src_ext: "go",
348 },
349 ProjectInfo {
350 kind: "Java",
351 manifest: "pom.xml",
352 src_ext: "java",
353 },
354 ProjectInfo {
355 kind: "Java",
356 manifest: "build.gradle",
357 src_ext: "java",
358 },
359 ProjectInfo {
360 kind: "Ruby",
361 manifest: "Gemfile",
362 src_ext: "rb",
363 },
364 ProjectInfo {
365 kind: "PHP",
366 manifest: "composer.json",
367 src_ext: "php",
368 },
369];
370
371pub fn explain_code(root: &Path) -> Result<String> {
374 if !root.exists() {
375 return Err(RalphError::FileNotFound(root.display().to_string()));
376 }
377
378 let mut report = String::new();
379
380 let mut detected_kind = "Unknown";
382 let mut src_ext = "";
383
384 for sig in PROJECT_SIGNATURES {
385 let manifest_path = root.join(sig.manifest);
386 if manifest_path.exists() {
387 detected_kind = sig.kind;
388 src_ext = sig.src_ext;
389 let manifest_content = std::fs::read_to_string(&manifest_path).unwrap_or_default();
390 report.push_str(&format!("## Project Type\n{}\n\n", sig.kind));
391 report.push_str(&format!(
392 "## {} ({})\n```\n{}\n```\n\n",
393 sig.manifest,
394 sig.kind,
395 manifest_content
396 .lines()
397 .take(50)
398 .collect::<Vec<_>>()
399 .join("\n")
400 ));
401 break;
402 }
403 }
404
405 if detected_kind == "Unknown" {
406 report.push_str("## Project Type\nUnknown (no recognized manifest file found)\n\n");
407 }
408
409 report.push_str("## Directory Structure\n```\n");
411 let walker = WalkBuilder::new(root)
412 .hidden(false)
413 .ignore(true)
414 .git_ignore(true)
415 .max_depth(Some(3))
416 .build();
417
418 let mut entries: Vec<String> = walker
419 .flatten()
420 .filter_map(|e| {
421 let rel = e.path().strip_prefix(root).ok()?.display().to_string();
422 if rel.is_empty() {
423 return None;
424 }
425 let suffix = if e.path().is_dir() { "/" } else { "" };
426 Some(format!("{}{}", rel, suffix))
427 })
428 .collect();
429 entries.sort();
430 report.push_str(&entries.join("\n"));
431 report.push_str("\n```\n\n");
432
433 if !src_ext.is_empty() {
435 report.push_str("## Source Files\n");
436
437 let walker2 = WalkBuilder::new(root)
438 .hidden(false)
439 .ignore(true)
440 .git_ignore(true)
441 .build();
442
443 let mut source_files: Vec<std::path::PathBuf> = walker2
444 .flatten()
445 .filter(|e| {
446 e.path().is_file()
447 && e.path()
448 .extension()
449 .and_then(|x| x.to_str())
450 .map(|x| x == src_ext)
451 .unwrap_or(false)
452 })
453 .map(|e| e.into_path())
454 .collect();
455 source_files.sort();
456
457 for file in &source_files {
458 let rel = file.strip_prefix(root).unwrap_or(file.as_path());
459 let content = std::fs::read_to_string(file).unwrap_or_default();
460 let lines: Vec<&str> = content.lines().collect();
461 let preview: String = lines
462 .iter()
463 .take(30)
464 .cloned()
465 .collect::<Vec<_>>()
466 .join("\n");
467 report.push_str(&format!(
468 "\n### {} ({} lines)\n```{}\n{}\n```\n",
469 rel.display(),
470 lines.len(),
471 src_ext,
472 preview
473 ));
474 }
475 report.push('\n');
476 }
477
478 let entry_candidates = [
480 "src/main.rs",
481 "src/lib.rs",
482 "main.py",
483 "app.py",
484 "index.js",
485 "index.ts",
486 "main.go",
487 "main.java",
488 "app.rb",
489 "index.php",
490 ];
491 let mut found_entries = Vec::new();
492 for candidate in &entry_candidates {
493 if root.join(candidate).exists() {
494 found_entries.push(*candidate);
495 }
496 }
497 if !found_entries.is_empty() {
498 report.push_str("## Entry Points\n");
499 for ep in found_entries {
500 report.push_str(&format!("- `{}`\n", ep));
501 }
502 report.push('\n');
503 }
504
505 Ok(report)
506}