use crate::lsp::LspActionResult;
use crate::tool::lsp::LspTool;
use anyhow::Result;
use serde::Deserialize;
use serde_json::Value;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use tokio::process::Command;
const MAX_DIAGNOSTICS_PER_FILE: usize = 25;
#[derive(Debug, Clone)]
pub struct ValidationReport {
pub issue_count: usize,
pub prompt: String,
}
pub async fn capture_git_dirty_files(workspace_dir: &Path) -> HashSet<PathBuf> {
let mut files = HashSet::new();
for args in [
&["diff", "--name-only", "--relative", "--"][..],
&["diff", "--cached", "--name-only", "--relative", "--"][..],
&["ls-files", "--others", "--exclude-standard"][..],
] {
let output = Command::new("git")
.args(args)
.current_dir(workspace_dir)
.output()
.await;
let Ok(output) = output else {
continue;
};
if !output.status.success() {
continue;
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
files.insert(normalize_workspace_path(workspace_dir, line));
}
}
files
}
pub fn track_touched_files(
touched_files: &mut HashSet<PathBuf>,
workspace_dir: &Path,
tool_name: &str,
tool_input: &Value,
tool_metadata: Option<&HashMap<String, Value>>,
) {
if !is_mutating_tool(tool_name) {
return;
}
let mut files = Vec::new();
match tool_name {
"write" | "edit" | "confirm_edit" => {
if let Some(path) = string_field(tool_input, &["path"]) {
files.push(path.to_string());
}
}
"advanced_edit" => {
if let Some(path) = string_field(tool_input, &["filePath", "file_path", "path"]) {
files.push(path.to_string());
}
}
"multiedit" | "confirm_multiedit" => {
if let Some(edits) = tool_input.get("edits").and_then(Value::as_array) {
for edit in edits {
if let Some(path) = string_field(edit, &["path", "filePath", "file_path"]) {
files.push(path.to_string());
}
}
}
}
"patch" => {
collect_metadata_paths(&mut files, tool_metadata, &["files"]);
}
_ => {}
}
collect_metadata_paths(&mut files, tool_metadata, &["path", "file"]);
for file in files {
touched_files.insert(normalize_workspace_path(workspace_dir, &file));
}
}
pub async fn build_validation_report(
workspace_dir: &Path,
touched_files: &HashSet<PathBuf>,
baseline_git_dirty_files: &HashSet<PathBuf>,
) -> Result<Option<ValidationReport>> {
let mut candidate_files = touched_files.clone();
let current_git_dirty = capture_git_dirty_files(workspace_dir).await;
candidate_files.extend(
current_git_dirty
.difference(baseline_git_dirty_files)
.cloned(),
);
let mut existing_files: Vec<PathBuf> = candidate_files
.into_iter()
.filter(|path| path.is_file())
.collect();
existing_files.sort();
existing_files.dedup();
if existing_files.is_empty() {
return Ok(None);
}
let root_uri = format!("file://{}", workspace_dir.display());
let lsp_tool = LspTool::with_root(root_uri);
let manager = lsp_tool.get_manager().await;
let mut issues_by_file = BTreeMap::new();
let mut issue_count = 0usize;
for path in existing_files {
let mut rendered = Vec::new();
let client = manager.get_client_for_file(&path).await.ok();
if let Some(client) = client
&& let Ok(LspActionResult::Diagnostics { diagnostics }) =
client.diagnostics(&path).await
{
rendered.extend(render_diagnostics(workspace_dir, &diagnostics));
}
let linter_diagnostics = manager.linter_diagnostics(&path).await;
rendered.extend(render_diagnostics(workspace_dir, &linter_diagnostics));
rendered.extend(collect_external_linter_diagnostics(workspace_dir, &path).await);
rendered.sort();
rendered.dedup();
if !rendered.is_empty() {
issue_count += rendered.len();
issues_by_file.insert(relative_display_path(workspace_dir, &path), rendered);
}
}
if issues_by_file.is_empty() {
return Ok(None);
}
let mut prompt = String::from(
"Mandatory post-edit verification found unresolved diagnostics in files you changed. \
Do not finish yet. Fix every issue below, respecting workspace config files such as eslint, biome, \
ruff, stylelint, tsconfig, and project-local language-server settings. Prefer direct file-edit \
tools on the listed files. Do not wander into unrelated files or exploratory bash loops unless a \
minimal validation command is strictly necessary. After fixing them, re-check the same files and \
only then provide the final answer.\n\n",
);
for (path, diagnostics) in issues_by_file {
prompt.push_str(&format!("{path}\n"));
for diagnostic in diagnostics.iter().take(MAX_DIAGNOSTICS_PER_FILE) {
prompt.push_str(" - ");
prompt.push_str(diagnostic);
prompt.push('\n');
}
if diagnostics.len() > MAX_DIAGNOSTICS_PER_FILE {
prompt.push_str(&format!(
" - ... {} more diagnostics omitted\n",
diagnostics.len() - MAX_DIAGNOSTICS_PER_FILE
));
}
prompt.push('\n');
}
Ok(Some(ValidationReport {
issue_count,
prompt: prompt.trim_end().to_string(),
}))
}
fn render_diagnostics(
workspace_dir: &Path,
diagnostics: &[crate::lsp::DiagnosticInfo],
) -> Vec<String> {
diagnostics
.iter()
.map(|diagnostic| {
let path = diagnostic
.uri
.strip_prefix("file://")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(&diagnostic.uri));
let path = relative_display_path(workspace_dir, &path);
let severity = diagnostic.severity.as_deref().unwrap_or("unknown");
let source = diagnostic.source.as_deref().unwrap_or("lsp");
let code = diagnostic
.code
.as_ref()
.map(|code| format!(" ({code})"))
.unwrap_or_default();
format!(
"[{severity}] {path}:{}:{} [{source}]{} {}",
diagnostic.range.start.line + 1,
diagnostic.range.start.character + 1,
code,
diagnostic.message.replace('\n', " ")
)
})
.collect()
}
async fn collect_external_linter_diagnostics(workspace_dir: &Path, path: &Path) -> Vec<String> {
let ext = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default();
if !matches!(ext, "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs") {
return Vec::new();
}
let relative_path = path
.strip_prefix(workspace_dir)
.unwrap_or(path)
.display()
.to_string();
let output = Command::new("npx")
.args(["--no-install", "eslint", "--format", "json", &relative_path])
.current_dir(workspace_dir)
.output()
.await;
let Ok(output) = output else {
return Vec::new();
};
if output.stdout.is_empty() {
return Vec::new();
}
let reports: Result<Vec<EslintFileReport>, _> = serde_json::from_slice(&output.stdout);
let Ok(reports) = reports else {
return Vec::new();
};
reports
.into_iter()
.flat_map(|report| {
let file_path = report.file_path;
report.messages.into_iter().map(move |message| {
let severity = match message.severity {
2 => "error",
1 => "warning",
_ => "info",
};
let code = message
.rule_id
.as_deref()
.map(|rule_id| format!(" ({rule_id})"))
.unwrap_or_default();
format!(
"[{severity}] {}:{}:{} [eslint-cli]{} {}",
relative_display_path(workspace_dir, Path::new(&file_path)),
message.line,
message.column,
code,
message.message.replace('\n', " ")
)
})
})
.collect()
}
fn is_mutating_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"write"
| "edit"
| "advanced_edit"
| "confirm_edit"
| "multiedit"
| "confirm_multiedit"
| "patch"
)
}
fn collect_metadata_paths(
files: &mut Vec<String>,
tool_metadata: Option<&HashMap<String, Value>>,
keys: &[&str],
) {
let Some(tool_metadata) = tool_metadata else {
return;
};
for key in keys {
let Some(value) = tool_metadata.get(*key) else {
continue;
};
match value {
Value::String(path) => files.push(path.clone()),
Value::Array(paths) => {
for path in paths.iter().filter_map(Value::as_str) {
files.push(path.to_string());
}
}
_ => {}
}
}
}
fn string_field<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> {
keys.iter()
.find_map(|key| value.get(*key).and_then(Value::as_str))
}
fn normalize_workspace_path(workspace_dir: &Path, raw_path: &str) -> PathBuf {
let path = PathBuf::from(raw_path);
if path.is_absolute() {
path
} else {
workspace_dir.join(path)
}
}
fn relative_display_path(workspace_dir: &Path, path: &Path) -> String {
path.strip_prefix(workspace_dir)
.unwrap_or(path)
.display()
.to_string()
}
#[derive(Debug, Deserialize)]
struct EslintFileReport {
#[serde(rename = "filePath")]
file_path: String,
messages: Vec<EslintMessage>,
}
#[derive(Debug, Deserialize)]
struct EslintMessage {
#[serde(rename = "ruleId")]
rule_id: Option<String>,
severity: u8,
message: String,
line: u32,
column: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn tracks_edit_paths_from_arguments() {
let workspace_dir = Path::new("/workspace");
let mut touched_files = HashSet::new();
track_touched_files(
&mut touched_files,
workspace_dir,
"edit",
&json!({ "path": "src/main.ts" }),
None,
);
assert!(touched_files.contains(&PathBuf::from("/workspace/src/main.ts")));
}
#[test]
fn tracks_patch_paths_from_metadata() {
let workspace_dir = Path::new("/workspace");
let mut touched_files = HashSet::new();
let metadata =
HashMap::from([("files".to_string(), json!(["src/lib.rs", "tests/app.rs"]))]);
track_touched_files(
&mut touched_files,
workspace_dir,
"patch",
&json!({}),
Some(&metadata),
);
assert!(touched_files.contains(&PathBuf::from("/workspace/src/lib.rs")));
assert!(touched_files.contains(&PathBuf::from("/workspace/tests/app.rs")));
}
}