Skip to main content

ralph/tools/
file_tools.rs

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
14/// Read a file with optional pagination.
15///
16/// - `offset`: 0-based line index to start from (default: 0).
17/// - `limit`:  maximum number of lines to return (default: 150).
18///
19/// Returns a truncation notice when more lines remain so the caller
20/// knows the exact offset to pass in a follow-up call.
21pub 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
103/// Apply multiple (old_string → new_string) replacements to a file in one call.
104/// All replacements are validated before any are applied: if any `old_string` is
105/// not found, the entire operation fails with an index hint.
106pub 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
127/// Given a file's content and a failed `old_string`, return a human-readable
128/// hint pointing at the closest matching block — helps the LLM fix whitespace
129/// or indentation mismatches without re-reading the entire file.
130pub 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    // Find the first non-blank search line to use as the anchor.
135    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    // Extract words of length ≥ 3 as "significant tokens".
141    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    // Score each content line by how many significant words it shares with the anchor.
151    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
176/// Return a structural outline of a file — function/class/struct signatures
177/// with line numbers, without the bodies. Ideal for surveying a large file
178/// before deciding which section to read in full.
179pub 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    // Per-language signature patterns.
188    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        // Nothing matched after the header
233        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
246/// Load all files matching a glob pattern (e.g. `src/**/*.rs`), returning
247/// their contents concatenated with path headers.  Caps at 100 files and
248/// 500 KB total to keep context manageable.
249pub 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; // 500 KB
271
272    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, // skip binary files
281        };
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
310// ── Project type detection helpers ───────────────────────────────────────────
311
312struct 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
371/// Analyze the code structure at `root` and return a structured summary that
372/// the LLM can use to explain the codebase to the user.
373pub 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    // ── Project type ──────────────────────────────────────────────────────────
381    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    // ── Directory tree ────────────────────────────────────────────────────────
410    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    // ── Source file summaries ─────────────────────────────────────────────────
434    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    // ── Entry points ──────────────────────────────────────────────────────────
479    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}