use std::collections::HashMap;
use std::path::Path;
use crate::core::ToolDiagnostic;
fn abs_to_rel<'a>(abs_diag_file: &str, rel_real_pairs: &'a [(String, String)]) -> Option<&'a str> {
for (rel, real) in rel_real_pairs {
if abs_diag_file == real.as_str() || abs_diag_file == rel.as_str() {
return Some(rel.as_str());
}
let real_suffix = real.trim_start_matches('/');
let rel_suffix = rel.trim_start_matches('/');
if abs_diag_file.ends_with(&format!("/{real_suffix}"))
|| abs_diag_file.ends_with(&format!("/{rel_suffix}"))
|| real.ends_with(&format!("/{abs_diag_file}"))
|| rel.ends_with(&format!("/{abs_diag_file}"))
{
return Some(rel.as_str());
}
}
None
}
pub fn run_diagnostics_blocking(
by_file: HashMap<String, String>,
language_filter: Option<String>,
tool_filter: Option<Vec<String>>,
root_path: Option<String>,
) -> Vec<ToolDiagnostic> {
use crate::core::global_registry;
run_diagnostics_blocking_with_registry(
by_file,
language_filter,
tool_filter,
root_path,
global_registry(),
)
}
pub fn run_diagnostics_blocking_with_registry(
by_file: HashMap<String, String>,
language_filter: Option<String>,
tool_filter: Option<Vec<String>>,
root_path: Option<String>,
registry: &crate::core::tool_registry::ToolRegistry,
) -> Vec<ToolDiagnostic> {
use crate::lang::LanguageDetector;
let scratch = match tempfile::tempdir() {
Ok(d) => d,
Err(e) => {
tracing::warn!("failed to create scratch dir for diagnostics: {e}");
return Vec::new();
}
};
let mut by_lang: HashMap<String, Vec<(String, String)>> = HashMap::new();
for (file, content) in by_file {
let Some(lang) = LanguageDetector::detect_file(&file) else {
continue;
};
if let Some(want) = &language_filter {
if &lang != want {
continue;
}
}
by_lang.entry(lang).or_default().push((file, content));
}
let mut out = Vec::new();
for (lang, file_pairs) in &by_lang {
let tools = registry.tools_for(lang);
if tools.is_empty() {
continue;
}
let mut proj_tools: Vec<std::sync::Arc<dyn crate::core::tools::StaticTool>> = Vec::new();
let mut file_tools: Vec<std::sync::Arc<dyn crate::core::tools::StaticTool>> = Vec::new();
for tool in tools {
if let Some(names) = &tool_filter {
if !names.iter().any(|n| n == tool.name()) {
continue;
}
}
if tool.is_project_scoped() {
proj_tools.push(tool);
} else {
file_tools.push(tool);
}
}
if !file_tools.is_empty() {
for (idx, (rel_file, content)) in file_pairs.iter().enumerate() {
let name = Path::new(rel_file)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "chunk.txt".to_string());
let file_dir = scratch.path().join(idx.to_string());
if let Err(e) = std::fs::create_dir_all(&file_dir) {
tracing::warn!("failed to create scratch subdir for {name}: {e}");
continue;
}
let path = file_dir.join(&name);
if let Err(e) = std::fs::write(&path, content) {
tracing::warn!("failed to write scratch file {name}: {e}");
continue;
}
for tool in &file_tools {
let result = tool.run(&path, content);
match result {
Ok(mut diags) => {
for d in &mut diags {
d.file = rel_file.clone();
}
out.extend(diags);
}
Err(e) => tracing::warn!("diagnostics for {rel_file} failed: {e:#}"),
}
}
}
}
if proj_tools.is_empty() {
continue;
}
let root = match &root_path {
Some(r) => r,
None => {
tracing::info!(
"project-scoped tools available for {lang} but root_path is None; \
skipping (index was not fetched with ?details=true — \
C# diagnostics will be empty until root_path is available)"
);
continue;
}
};
let rel_real_pairs: Vec<(String, String)> = file_pairs
.iter()
.filter_map(|(rel, _)| {
let real = Path::new(root).join(rel);
if real.exists() {
Some((rel.clone(), real.to_string_lossy().into_owned()))
} else {
None
}
})
.collect();
if rel_real_pairs.is_empty() {
tracing::debug!(
"project-scoped tools for {lang}: no files exist under root {root}; skipping"
);
continue;
}
let real_paths: Vec<std::path::PathBuf> = rel_real_pairs
.iter()
.map(|(_, real)| std::path::PathBuf::from(real))
.collect();
for tool in &proj_tools {
match tool.run_project(&real_paths) {
Ok(diags) => {
for mut diag in diags {
match abs_to_rel(&diag.file, &rel_real_pairs) {
Some(rel) => {
diag.file = rel.to_string();
out.push(diag);
}
None => {
tracing::debug!(
"dropping project-scoped diag for unmapped file: {}",
diag.file
);
}
}
}
}
Err(e) => {
tracing::warn!("project-scoped diagnostics ({}) failed: {e:#}", tool.name())
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_diagnostics_blocking_with_registry_two_files_same_basename() {
use crate::core::tool_registry::ToolRegistry;
use crate::core::tools::{StaticTool, ToolDiagnostic};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct FakeFileScopedTool {
calls: Arc<Mutex<Vec<(PathBuf, String)>>>,
}
impl StaticTool for FakeFileScopedTool {
fn name(&self) -> &str {
"fake-file-scoped"
}
fn language(&self) -> &str {
"rust"
}
fn is_available(&self) -> bool {
true
}
fn is_project_scoped(&self) -> bool {
false
}
fn run(&self, file: &Path, content: &str) -> anyhow::Result<Vec<ToolDiagnostic>> {
self.calls
.lock()
.unwrap()
.push((file.to_path_buf(), content.to_string()));
Ok(vec![ToolDiagnostic {
file: file.to_string_lossy().into_owned(),
line: 1,
col: 1,
message: "fake".into(),
severity: crate::core::tools::Severity::Warning,
tool: "fake-file-scoped".into(),
code: None,
}])
}
fn run_project(&self, _files: &[PathBuf]) -> anyhow::Result<Vec<ToolDiagnostic>> {
Ok(Vec::new())
}
}
let calls = Arc::new(Mutex::new(Vec::<(PathBuf, String)>::new()));
let tool = FakeFileScopedTool {
calls: Arc::clone(&calls),
};
let registry = ToolRegistry::from_tools_for_test(vec![Arc::new(tool)]);
let mut by_file = HashMap::new();
by_file.insert("src/a/main.rs".to_string(), "fn a() {}".to_string());
by_file.insert("src/b/main.rs".to_string(), "fn b() {}".to_string());
let diags = run_diagnostics_blocking_with_registry(
by_file, None, None, None, ®istry,
);
let recorded = calls.lock().unwrap();
assert_eq!(
recorded.len(),
2,
"expected 2 tool invocations (one per file), got {}; \
basename collision likely dropped one",
recorded.len()
);
let path0 = &recorded[0].0;
let path1 = &recorded[1].0;
assert_ne!(
path0, path1,
"the two files were written to the same scratch path ({path0:?}); \
per-file subdir isolation is broken"
);
assert_eq!(
diags.len(),
2,
"expected 2 diagnostics in output (one per file), got {}; \
one file's diagnostics were silently dropped",
diags.len()
);
let files: Vec<&str> = diags.iter().map(|d| d.file.as_str()).collect();
assert!(
files.contains(&"src/a/main.rs"),
"src/a/main.rs missing from output: {files:?}"
);
assert!(
files.contains(&"src/b/main.rs"),
"src/b/main.rs missing from output: {files:?}"
);
}
#[test]
fn abs_to_rel_exact_match() {
let pairs = vec![(
"src/Foo.cs".to_string(),
"/home/user/proj/src/Foo.cs".to_string(),
)];
assert_eq!(
abs_to_rel("/home/user/proj/src/Foo.cs", &pairs),
Some("src/Foo.cs")
);
}
#[test]
fn abs_to_rel_suffix_match_absolute_real() {
let pairs = vec![(
"src/Bar.cs".to_string(),
"/home/user/proj/src/Bar.cs".to_string(),
)];
assert_eq!(
abs_to_rel("/symlink-root/home/user/proj/src/Bar.cs", &pairs),
Some("src/Bar.cs"),
);
assert_eq!(abs_to_rel("/completely/different/Qux.cs", &pairs), None);
}
#[test]
fn abs_to_rel_no_match_returns_none() {
let pairs = vec![(
"src/Baz.cs".to_string(),
"/home/user/proj/src/Baz.cs".to_string(),
)];
assert_eq!(abs_to_rel("/completely/different/path.cs", &pairs), None);
}
#[test]
fn abs_to_rel_rel_exact_match() {
let pairs = vec![(
"src/Qux.cs".to_string(),
"/home/user/proj/src/Qux.cs".to_string(),
)];
assert_eq!(abs_to_rel("src/Qux.cs", &pairs), Some("src/Qux.cs"));
}
}