use crate::errors::{RalphError, Result};
use ignore::overrides::OverrideBuilder;
use ignore::WalkBuilder;
use regex::Regex;
use std::path::Path;
pub fn read_file(path: &Path) -> Result<String> {
if !path.exists() {
return Err(RalphError::FileNotFound(path.display().to_string()));
}
std::fs::read_to_string(path).map_err(RalphError::Io)
}
pub fn read_file_ranged(
path: &Path,
offset: Option<usize>,
limit: Option<usize>,
) -> Result<String> {
if !path.exists() {
return Err(RalphError::FileNotFound(path.display().to_string()));
}
let content = std::fs::read_to_string(path).map_err(RalphError::Io)?;
let all_lines: Vec<&str> = content.lines().collect();
let total = all_lines.len();
let start = offset.unwrap_or(0).min(total);
let max_lines = limit.unwrap_or(150);
let end = (start + max_lines).min(total);
let mut out = String::new();
for line in &all_lines[start..end] {
out.push_str(line);
out.push('\n');
}
if end < total {
out.push_str(&format!(
"\n[...truncated after line {} of {}. \
To read further use `read_file` with `offset={}`.]",
end, total, end
));
}
Ok(out)
}
pub fn list_dir(path: &Path) -> Result<String> {
if !path.exists() {
return Err(RalphError::FileNotFound(path.display().to_string()));
}
let mut entries = Vec::new();
let walker = WalkBuilder::new(path)
.hidden(false)
.ignore(true)
.git_ignore(true)
.max_depth(Some(4))
.build();
for entry in walker.flatten() {
let rel = entry
.path()
.strip_prefix(path)
.unwrap_or(entry.path())
.display()
.to_string();
if rel.is_empty() {
continue;
}
let suffix = if entry.path().is_dir() { "/" } else { "" };
entries.push(format!("{}{}", rel, suffix));
}
entries.sort();
Ok(entries.join("\n"))
}
pub fn write_file(path: &Path, content: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, content).map_err(RalphError::Io)
}
pub fn edit_file(path: &Path, old_string: &str, new_string: &str) -> Result<()> {
let content = read_file(path)?;
if !content.contains(old_string) {
return Err(RalphError::EditNotFound {
path: path.display().to_string(),
});
}
let updated = content.replacen(old_string, new_string, 1);
std::fs::write(path, updated).map_err(RalphError::Io)
}
pub fn edit_file_multi(path: &Path, edits: &[(String, String)]) -> Result<Vec<String>> {
let mut content = read_file(path)?;
let mut applied = Vec::new();
for (i, (old, new)) in edits.iter().enumerate() {
if !content.contains(old.as_str()) {
let hint = find_closest_match_hint(&content, old);
return Err(RalphError::ToolFailed {
tool: "edit_file_multi".to_string(),
message: format!(
"edit[{i}]: old_string not found in {}.\n{hint}",
path.display()
),
});
}
content = content.replacen(old.as_str(), new.as_str(), 1);
applied.push(format!("edit[{i}] applied"));
}
std::fs::write(path, content).map_err(RalphError::Io)?;
Ok(applied)
}
pub fn find_closest_match_hint(content: &str, old_string: &str) -> String {
let content_lines: Vec<&str> = content.lines().collect();
let search_lines: Vec<&str> = old_string.lines().collect();
let anchor = match search_lines.iter().find(|l| !l.trim().is_empty()) {
Some(l) => l.trim(),
None => return String::new(),
};
let sig_words: Vec<&str> = anchor
.split(|c: char| !c.is_alphanumeric() && c != '_')
.filter(|w| w.len() >= 3)
.collect();
if sig_words.is_empty() {
return String::new();
}
let mut best_score = 0usize;
let mut best_line = usize::MAX;
for (i, line) in content_lines.iter().enumerate() {
let lc = line.to_lowercase();
let score = sig_words.iter().filter(|w| lc.contains(*w)).count();
if score > best_score {
best_score = score;
best_line = i;
}
}
if best_score == 0 || best_line == usize::MAX {
return String::new();
}
let start = best_line.saturating_sub(2);
let end = (best_line + search_lines.len() + 3).min(content_lines.len());
let mut hint = format!("Closest match near line {}:\n", best_line + 1);
for i in start..end {
let marker = if i == best_line { ">>>" } else { " " };
hint.push_str(&format!("{} {:4}: {}\n", marker, i + 1, content_lines[i]));
}
hint
}
pub fn read_file_outline(path: &Path) -> Result<String> {
if !path.exists() {
return Err(RalphError::FileNotFound(path.display().to_string()));
}
let content = std::fs::read_to_string(path).map_err(RalphError::Io)?;
let lines: Vec<&str> = content.lines().collect();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let pats: &[&str] = match ext {
"rs" => &[
r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+\w+",
r"^\s*(?:pub(?:\([^)]*\))?\s+)?struct\s+\w+",
r"^\s*(?:pub(?:\([^)]*\))?\s+)?enum\s+\w+",
r"^\s*(?:pub(?:\([^)]*\))?\s+)?trait\s+\w+",
r"^\s*(?:pub(?:\([^)]*\))?\s+)?impl(?:<[^>]*>)?\s+\w+",
],
"py" => &[r"^\s*(?:async\s+)?def\s+\w+", r"^\s*class\s+\w+"],
"js" | "ts" | "tsx" | "jsx" | "mjs" | "cjs" => &[
r"^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+\w+",
r"^\s*(?:export\s+)?(?:default\s+)?class\s+\w+",
r"^\s*(?:export\s+)?(?:const|let)\s+\w+\s*[=:]",
],
"go" => &[
r"^\s*func\s+(?:\([^)]*\)\s+)?\w+",
r"^\s*type\s+\w+\s+(?:struct|interface)",
],
"java" | "kt" => &[
r"^\s*(?:(?:public|private|protected|static|final|abstract|override)\s+)*\w+\s+\w+\s*\(",
r"^\s*(?:public\s+|private\s+|protected\s+)?(?:class|interface|enum|object)\s+\w+",
],
"rb" => &[r"^\s*def\s+\w+", r"^\s*class\s+\w+", r"^\s*module\s+\w+"],
_ => &[],
};
let compiled: Vec<Regex> = pats.iter().filter_map(|p| Regex::new(p).ok()).collect();
if compiled.is_empty() {
return Ok(format!(
"{} ({} lines) — unknown file type; use read_file with offset/limit to navigate",
path.display(),
lines.len()
));
}
let mut out = format!("{} ({} lines)\n", path.display(), lines.len());
for (i, line) in lines.iter().enumerate() {
if compiled.iter().any(|re| re.is_match(line)) {
out.push_str(&format!("{:5}: {}\n", i + 1, line.trim_end()));
}
}
if out.trim_end().ends_with(')') {
out.push_str("(no recognisable top-level definitions found)\n");
}
Ok(out)
}
pub fn delete_file(path: &Path) -> Result<()> {
if !path.exists() {
return Err(RalphError::FileNotFound(path.display().to_string()));
}
std::fs::remove_file(path).map_err(RalphError::Io)
}
pub fn load_files(pattern: &str, root: &Path) -> Result<String> {
let mut overrides = OverrideBuilder::new(root);
overrides.add(pattern).map_err(|e| RalphError::ToolFailed {
tool: "load_files".to_string(),
message: format!("Invalid glob pattern: {}", e),
})?;
let overrides = overrides.build().map_err(|e| RalphError::ToolFailed {
tool: "load_files".to_string(),
message: format!("Failed to build override: {}", e),
})?;
let walker = WalkBuilder::new(root)
.hidden(false)
.ignore(true)
.git_ignore(true)
.overrides(overrides)
.build();
let mut output = String::new();
let mut file_count = 0usize;
const MAX_FILES: usize = 100;
const MAX_BYTES: usize = 512 * 1024;
for entry in walker.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let rel = path.strip_prefix(root).unwrap_or(path);
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue, };
let header = format!("=== {} ===\n", rel.display());
if output.len() + header.len() + content.len() > MAX_BYTES {
output.push_str(&format!(
"\n[load_files] Output truncated at {} files / 500 KB limit.",
file_count
));
break;
}
output.push_str(&header);
output.push_str(&content);
output.push('\n');
file_count += 1;
if file_count >= MAX_FILES {
output.push_str("\n[load_files] Reached 100-file limit.");
break;
}
}
if output.is_empty() {
Ok(format!("No files matched pattern: {}", pattern))
} else {
Ok(output)
}
}
struct ProjectInfo {
kind: &'static str,
manifest: &'static str,
src_ext: &'static str,
}
const PROJECT_SIGNATURES: &[ProjectInfo] = &[
ProjectInfo {
kind: "Rust",
manifest: "Cargo.toml",
src_ext: "rs",
},
ProjectInfo {
kind: "Node.js",
manifest: "package.json",
src_ext: "js",
},
ProjectInfo {
kind: "TypeScript",
manifest: "tsconfig.json",
src_ext: "ts",
},
ProjectInfo {
kind: "Python",
manifest: "pyproject.toml",
src_ext: "py",
},
ProjectInfo {
kind: "Python",
manifest: "setup.py",
src_ext: "py",
},
ProjectInfo {
kind: "Go",
manifest: "go.mod",
src_ext: "go",
},
ProjectInfo {
kind: "Java",
manifest: "pom.xml",
src_ext: "java",
},
ProjectInfo {
kind: "Java",
manifest: "build.gradle",
src_ext: "java",
},
ProjectInfo {
kind: "Ruby",
manifest: "Gemfile",
src_ext: "rb",
},
ProjectInfo {
kind: "PHP",
manifest: "composer.json",
src_ext: "php",
},
];
pub fn explain_code(root: &Path) -> Result<String> {
if !root.exists() {
return Err(RalphError::FileNotFound(root.display().to_string()));
}
let mut report = String::new();
let mut detected_kind = "Unknown";
let mut src_ext = "";
for sig in PROJECT_SIGNATURES {
let manifest_path = root.join(sig.manifest);
if manifest_path.exists() {
detected_kind = sig.kind;
src_ext = sig.src_ext;
let manifest_content = std::fs::read_to_string(&manifest_path).unwrap_or_default();
report.push_str(&format!("## Project Type\n{}\n\n", sig.kind));
report.push_str(&format!(
"## {} ({})\n```\n{}\n```\n\n",
sig.manifest,
sig.kind,
manifest_content
.lines()
.take(50)
.collect::<Vec<_>>()
.join("\n")
));
break;
}
}
if detected_kind == "Unknown" {
report.push_str("## Project Type\nUnknown (no recognized manifest file found)\n\n");
}
report.push_str("## Directory Structure\n```\n");
let walker = WalkBuilder::new(root)
.hidden(false)
.ignore(true)
.git_ignore(true)
.max_depth(Some(3))
.build();
let mut entries: Vec<String> = walker
.flatten()
.filter_map(|e| {
let rel = e.path().strip_prefix(root).ok()?.display().to_string();
if rel.is_empty() {
return None;
}
let suffix = if e.path().is_dir() { "/" } else { "" };
Some(format!("{}{}", rel, suffix))
})
.collect();
entries.sort();
report.push_str(&entries.join("\n"));
report.push_str("\n```\n\n");
if !src_ext.is_empty() {
report.push_str("## Source Files\n");
let walker2 = WalkBuilder::new(root)
.hidden(false)
.ignore(true)
.git_ignore(true)
.build();
let mut source_files: Vec<std::path::PathBuf> = walker2
.flatten()
.filter(|e| {
e.path().is_file()
&& e.path()
.extension()
.and_then(|x| x.to_str())
.map(|x| x == src_ext)
.unwrap_or(false)
})
.map(|e| e.into_path())
.collect();
source_files.sort();
for file in &source_files {
let rel = file.strip_prefix(root).unwrap_or(file.as_path());
let content = std::fs::read_to_string(file).unwrap_or_default();
let lines: Vec<&str> = content.lines().collect();
let preview: String = lines
.iter()
.take(30)
.cloned()
.collect::<Vec<_>>()
.join("\n");
report.push_str(&format!(
"\n### {} ({} lines)\n```{}\n{}\n```\n",
rel.display(),
lines.len(),
src_ext,
preview
));
}
report.push('\n');
}
let entry_candidates = [
"src/main.rs",
"src/lib.rs",
"main.py",
"app.py",
"index.js",
"index.ts",
"main.go",
"main.java",
"app.rb",
"index.php",
];
let mut found_entries = Vec::new();
for candidate in &entry_candidates {
if root.join(candidate).exists() {
found_entries.push(*candidate);
}
}
if !found_entries.is_empty() {
report.push_str("## Entry Points\n");
for ep in found_entries {
report.push_str(&format!("- `{}`\n", ep));
}
report.push('\n');
}
Ok(report)
}