use crate::server::helpers::{pathfinder_to_error_data, serialize_metadata};
use crate::server::PathfinderServer;
use pathfinder_common::error::PathfinderError;
const LIVENESS_PROBE_INTERVAL_SECS: u64 = 120;
impl PathfinderServer {
#[allow(clippy::too_many_lines)]
#[tracing::instrument(skip(self, params), fields(language = ?params.language))]
pub(crate) async fn lsp_health_impl(
&self,
params: crate::server::types::LspHealthParams,
) -> Result<rmcp::model::CallToolResult, rmcp::model::ErrorData> {
if params.action.as_deref() == Some("restart") {
let lang = match ¶ms.language {
Some(l) => l.clone(),
None => {
return Err(pathfinder_to_error_data(&PathfinderError::IoError {
message: "lsp_health action='restart' requires 'language' to be set"
.to_owned(),
}));
}
};
tracing::info!(language = %lang, "lsp_health: restart requested by agent");
match self.lawyer.force_respawn(&lang).await {
Ok(()) => {
tracing::info!(language = %lang, "lsp_health: restart successful");
}
Err(e) => {
tracing::warn!(language = %lang, error = %e, "lsp_health: restart failed");
}
}
}
let capability_status = self.lawyer.capability_status().await;
let mut languages = Vec::new();
let mut overall_status = "unavailable";
for (lang, status) in &capability_status {
if let Some(ref filter) = params.language {
if lang != filter {
continue;
}
}
let (status_str, uptime) = if status.navigation_ready == Some(true) {
("ready", status.uptime_seconds.map(format_uptime))
} else if status.navigation_ready == Some(false)
|| status.indexing_complete == Some(false)
{
("warming_up", status.uptime_seconds.map(format_uptime))
} else if status.uptime_seconds.is_some() {
("starting", status.uptime_seconds.map(format_uptime))
} else {
("unavailable", None)
};
let indexing_status = match status.indexing_complete {
Some(true) => Some("complete".to_owned()),
Some(false) => Some("in_progress".to_owned()),
None => None,
};
match status_str {
"ready" => overall_status = "ready",
"warming_up" if overall_status != "ready" => {
overall_status = "warming_up";
}
"starting" if overall_status != "ready" && overall_status != "warming_up" => {
overall_status = "starting";
}
_ => {}
}
languages.push(crate::server::types::LspLanguageHealth {
language: lang.clone(),
status: status_str.to_owned(),
uptime,
diagnostics_strategy: status.diagnostics_strategy.clone(),
supports_call_hierarchy: status.supports_call_hierarchy,
supports_diagnostics: status.supports_diagnostics,
supports_definition: status.supports_definition,
indexing_status,
navigation_ready: status.navigation_ready,
probe_verified: false,
install_hint: None,
indexing_progress_percent: status.indexing_progress_percent,
degraded_tools: compute_degraded_tools(status),
indexing_source: status.indexing_source.clone(),
indexing_duration_secs: status.indexing_duration_secs,
});
}
for lang_health in &mut languages {
if lang_health.status == "warming_up" {
let cache_action = {
let cache = self
.probe_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match cache.get(&lang_health.language) {
Some(entry) if entry.is_valid() && entry.success => {
ProbeAction::UseCachedReady
}
Some(entry) if entry.is_valid() && !entry.success => {
ProbeAction::SkipProbe
}
Some(_) => {
ProbeAction::Probe
}
None => ProbeAction::Probe,
}
};
match cache_action {
ProbeAction::UseCachedReady => {
"ready".clone_into(&mut lang_health.status);
lang_health.probe_verified = true;
if overall_status != "ready" {
overall_status = "ready";
}
continue;
}
ProbeAction::SkipProbe => {
continue;
}
ProbeAction::Probe => {}
}
let uptime_secs = parse_uptime_to_seconds(lang_health.uptime.as_deref());
if let Some(secs) = uptime_secs {
if secs > 10 {
let probe_result =
self.probe_language_readiness(&lang_health.language).await;
if probe_result {
"ready".clone_into(&mut lang_health.status);
lang_health.probe_verified = true;
self.probe_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(
lang_health.language.clone(),
crate::server::ProbeCacheEntry::new(true),
);
if overall_status != "ready" {
overall_status = "ready";
}
} else {
self.probe_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(
lang_health.language.clone(),
crate::server::ProbeCacheEntry::new(false),
);
}
}
}
}
}
for lang_health in &mut languages {
if lang_health.status != "ready" {
continue;
}
let cache_action = {
let cache = self
.probe_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match cache.get(&lang_health.language) {
Some(entry) if entry.is_valid() && entry.success => {
if entry.age_secs() < LIVENESS_PROBE_INTERVAL_SECS {
ProbeAction::UseCachedReady
} else {
ProbeAction::Probe }
}
Some(entry) if entry.is_valid() && !entry.success => ProbeAction::SkipProbe,
Some(_) => {
ProbeAction::Probe }
None => ProbeAction::Probe, }
};
match cache_action {
ProbeAction::UseCachedReady => {
lang_health.probe_verified = true;
continue;
}
ProbeAction::SkipProbe => continue,
ProbeAction::Probe => {}
}
let probe_result = match self.find_probe_file(&lang_health.language) {
Some(_) => self.probe_language_readiness(&lang_health.language).await,
None => {
continue;
}
};
if probe_result {
lang_health.probe_verified = true;
self.probe_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(
lang_health.language.clone(),
crate::server::ProbeCacheEntry::new(true),
);
} else {
"degraded".clone_into(&mut lang_health.status);
lang_health.probe_verified = false;
self.probe_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(
lang_health.language.clone(),
crate::server::ProbeCacheEntry::new(false),
);
}
}
if !languages.iter().any(|l| l.status == "ready") && overall_status == "ready" {
overall_status = "degraded";
}
let missing_languages = self.lawyer.missing_languages();
for missing in &missing_languages {
if let Some(ref filter) = params.language {
if &missing.language_id != filter {
continue;
}
}
languages.push(crate::server::types::LspLanguageHealth {
language: missing.language_id.clone(),
status: "unavailable".to_owned(),
uptime: None,
diagnostics_strategy: None,
supports_call_hierarchy: None,
supports_diagnostics: None,
supports_definition: None,
indexing_status: None,
navigation_ready: None,
probe_verified: false,
install_hint: Some(missing.install_hint.clone()),
indexing_progress_percent: None,
degraded_tools: vec![
crate::server::types::DegradedToolInfo {
tool: "analyze_impact".to_owned(),
severity: "unavailable".to_owned(),
description:
"No LSP available. Use search_codebase for manual reference search."
.to_owned(),
},
crate::server::types::DegradedToolInfo {
tool: "read_with_deep_context".to_owned(),
severity: "unavailable".to_owned(),
description:
"No LSP available. Returns source only, no dependency signatures."
.to_owned(),
},
],
indexing_source: None,
indexing_duration_secs: None,
});
}
if languages.is_empty() && params.language.is_none() {
overall_status = "unavailable";
}
let mut known_limitations = Vec::new();
if !missing_languages.is_empty() {
let langs: Vec<&str> = missing_languages
.iter()
.map(|m| m.language_id.as_str())
.collect();
known_limitations.push(format!(
"Missing LSP binaries for: {}. Install them for full navigation support.",
langs.join(", ")
));
}
for lang_health in &languages {
if lang_health.supports_call_hierarchy == Some(false)
&& lang_health.supports_definition == Some(true)
{
known_limitations.push(format!(
"{}: call hierarchy not supported — analyze_impact uses grep fallback (less accurate)",
lang_health.language
));
}
}
if !self.lawyer.is_warm_start_complete() {
known_limitations.push(
"LSP warm_start still in progress — results may be incomplete until indexing finishes"
.to_owned(),
);
}
let response = crate::server::types::LspHealthResponse {
status: overall_status.to_owned(),
languages,
warm_start_complete: self.lawyer.is_warm_start_complete(),
known_limitations,
};
let mut lang_lines = Vec::new();
for l in &response.languages {
let mut detail_parts = Vec::new();
if l.probe_verified {
detail_parts.push("probe_verified".to_owned());
}
if let Some(ref idx) = l.indexing_status {
if let Some(pct) = l.indexing_progress_percent {
detail_parts.push(format!("indexing: {pct}%"));
} else if idx == "complete" {
detail_parts.push("indexing: 100% complete".to_owned());
} else {
detail_parts.push(format!("indexing: {idx}"));
}
} else if let Some(pct) = l.indexing_progress_percent {
detail_parts.push(format!("indexing: {pct}%"));
}
if let Some(ref uptime) = l.uptime {
detail_parts.push(format!("uptime: {uptime}"));
}
let details = if detail_parts.is_empty() {
String::new()
} else {
format!(" ({})", detail_parts.join(", "))
};
lang_lines.push(format!("{}: {}{}", l.language, l.status, details));
if !l.degraded_tools.is_empty() {
let tools_with_severity: Vec<_> = l
.degraded_tools
.iter()
.map(|t| format!("{} ({})", t.tool, t.severity))
.collect();
lang_lines.push(format!(
" ⚠️ degraded_tools: {}",
tools_with_severity.join(", ")
));
let mut reasons = Vec::new();
if l.supports_definition != Some(true) {
reasons.push("supports_definition = false");
}
if l.supports_call_hierarchy != Some(true) {
reasons.push("supports_call_hierarchy = false");
}
if l.supports_diagnostics != Some(true) {
reasons.push("supports_diagnostics = false");
}
if !reasons.is_empty() {
lang_lines.push(format!(
" → Reason: {}. Use search_codebase as fallback.",
reasons.join(", ")
));
}
}
}
let text = if lang_lines.is_empty() {
format!("LSP status: {} — no languages detected", response.status)
} else {
let mut parts = vec![
format!("LSP status: {}", response.status),
lang_lines.join("\n"),
];
if !response.known_limitations.is_empty() {
parts.push(format!(
"Known limitations:\n{}",
response
.known_limitations
.iter()
.map(|l| format!(" - {l}"))
.collect::<Vec<_>>()
.join("\n")
));
}
parts.join("\n")
};
let mut res = rmcp::model::CallToolResult::success(vec![rmcp::model::Content::text(text)]);
res.structured_content = serialize_metadata(&response);
Ok(res)
}
async fn probe_language_readiness(&self, language_id: &str) -> bool {
let probe_file = self.find_probe_file(language_id);
let Some(file_path) = probe_file else {
return false; };
let content = tokio::fs::read_to_string(self.workspace_root.path().join(&file_path))
.await
.unwrap_or_default();
let _ = self
.lawyer
.open_document(self.workspace_root.path(), &file_path, &content)
.await;
let result = self
.lawyer
.goto_definition(self.workspace_root.path(), &file_path, 1, 1)
.await;
result.is_ok()
}
pub(crate) fn find_probe_file(&self, language_id: &str) -> Option<std::path::PathBuf> {
let extensions: &[&str] = match language_id {
"rust" => &["rs"],
"go" => &["go"],
"typescript" => &["ts", "tsx"],
"javascript" => &["js", "jsx"],
"python" => &["py"],
"ruby" => &["rb"],
"java" => &["java"],
_ => return None,
};
let candidates = match language_id {
"rust" => vec!["src/main.rs", "src/lib.rs"],
"go" => vec!["main.go", "cmd/main.go"],
"typescript" => vec![
"src/index.ts",
"index.ts",
"src/main.ts",
"src/index.tsx",
"index.tsx",
"src/main.tsx",
],
"javascript" => vec![
"src/index.js",
"index.js",
"src/main.js",
"src/index.jsx",
"index.jsx",
"src/main.jsx",
],
"python" => vec!["src/__init__.py", "main.py", "setup.py", "__init__.py"],
"ruby" => vec!["lib/main.rb", "main.rb"],
"java" => vec!["src/main/java/Main.java"],
_ => vec![],
};
for candidate in candidates {
let path = self.workspace_root.path().join(candidate);
if path.exists() {
return Some(std::path::PathBuf::from(candidate));
}
}
self.find_file_by_extension_recursive(self.workspace_root.path(), extensions, 0, 4)
}
fn find_file_by_extension_recursive(
&self,
current_dir: &std::path::Path,
extensions: &[&str],
current_depth: usize,
max_depth: usize,
) -> Option<std::path::PathBuf> {
if current_depth > max_depth {
return None;
}
let Ok(entries) = std::fs::read_dir(current_dir) else {
return None;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(metadata) = entry.metadata() else {
continue;
};
if metadata.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.')
|| name == "node_modules"
|| name == "target"
|| name == "vendor"
|| name == "dist"
|| name == "build"
|| name == "__pycache__"
|| name == ".git"
{
continue;
}
}
if let Some(found) = self.find_file_by_extension_recursive(
&path,
extensions,
current_depth + 1,
max_depth,
) {
return Some(found);
}
} else if metadata.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if extensions.iter().any(|&e| e.eq_ignore_ascii_case(ext)) {
if let Ok(rel_path) = path.strip_prefix(self.workspace_root.path()) {
return Some(rel_path.to_path_buf());
}
}
}
}
}
None
}
}
pub(super) fn format_uptime(seconds: u64) -> String {
if seconds < 60 {
format!("{seconds}s")
} else if seconds < 3600 {
let mins = seconds / 60;
let secs = seconds % 60;
if secs == 0 {
format!("{mins}m")
} else {
format!("{mins}m{secs}s")
}
} else {
let hours = seconds / 3600;
let mins = (seconds % 3600) / 60;
if mins == 0 {
format!("{hours}h")
} else {
format!("{hours}h{mins}m")
}
}
}
pub(super) enum ProbeAction {
UseCachedReady,
SkipProbe,
Probe,
}
pub(super) fn compute_degraded_tools(
status: &pathfinder_lsp::types::LspLanguageStatus,
) -> Vec<crate::server::types::DegradedToolInfo> {
let mut degraded = Vec::new();
if status.supports_definition != Some(true) {
degraded.push(crate::server::types::DegradedToolInfo {
tool: "get_definition".to_owned(),
severity: "grep_fallback".to_owned(),
description:
"Uses ripgrep heuristic instead of LSP. May find wrong definition or miss re-exports."
.to_owned(),
});
}
if status.supports_call_hierarchy != Some(true) {
degraded.push(crate::server::types::DegradedToolInfo {
tool: "analyze_impact".to_owned(),
severity: "grep_fallback".to_owned(),
description:
"Uses text search instead of call hierarchy. May over/under-count references."
.to_owned(),
});
degraded.push(crate::server::types::DegradedToolInfo {
tool: "read_with_deep_context".to_owned(),
severity: "unavailable".to_owned(),
description:
"Returns source only, no dependency signatures. Use search_codebase as alternative."
.to_owned(),
});
}
degraded
}
pub(super) fn parse_uptime_to_seconds(uptime: Option<&str>) -> Option<u64> {
let uptime = uptime?;
let mut seconds = 0u64;
if let Some(h_pos) = uptime.find('h') {
let h_str = &uptime[..h_pos];
if let Ok(h) = h_str.parse::<u64>() {
seconds += h * 3600;
}
}
let min_part = if let Some(h_pos) = uptime.find('h') {
&uptime[h_pos + 1..]
} else {
uptime
};
if let Some(m_pos) = min_part.find('m') {
let m_str = &min_part[..m_pos];
if let Ok(m) = m_str.parse::<u64>() {
seconds += m * 60;
}
}
let sec_part = if let Some(m_pos) = min_part.find('m') {
&min_part[m_pos + 1..]
} else {
min_part
};
if let Some(s_pos) = sec_part.find('s') {
let s_str = &sec_part[..s_pos];
if let Ok(s) = s_str.parse::<u64>() {
seconds += s;
}
}
Some(seconds)
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::super::test_helpers::make_server_with_lawyer;
use super::*;
use pathfinder_treesitter::mock::MockSurgeon;
use std::sync::Arc;
fn unpack_health(res: rmcp::model::CallToolResult) -> crate::server::types::LspHealthResponse {
serde_json::from_value(res.structured_content.expect("structured_content")).unwrap()
}
#[tokio::test]
async fn test_lsp_health_includes_diagnostics_strategy() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
let params = crate::server::types::LspHealthParams::default();
let result = server.lsp_health_impl(params).await;
let call_res = result.expect("should succeed");
let val = unpack_health(call_res);
assert_eq!(val.status, "unavailable");
assert!(val.languages.is_empty());
}
#[tokio::test]
async fn test_lsp_health_shows_push_for_go() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let lawyer_clone = lawyer.clone();
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
lawyer_clone.set_capability_status(std::collections::HashMap::from([(
"go".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true),
indexing_complete: Some(true),
uptime_seconds: Some(15),
diagnostics_strategy: Some("push".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(false),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("go".to_string()),
};
let result = server.lsp_health_impl(params).await;
let call_res = result.expect("should succeed");
let val = unpack_health(call_res);
assert_eq!(val.languages.len(), 1);
let go_health = &val.languages[0];
assert_eq!(go_health.language, "go");
assert_eq!(go_health.status, "ready");
assert_eq!(go_health.diagnostics_strategy, Some("push".to_string()));
assert_eq!(go_health.supports_call_hierarchy, Some(true));
assert_eq!(go_health.supports_diagnostics, Some(true));
}
#[tokio::test]
async fn test_lsp_health_shows_pull_for_rust() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let lawyer_clone = lawyer.clone();
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
lawyer_clone.set_capability_status(std::collections::HashMap::from([(
"rust".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true),
indexing_complete: Some(true),
uptime_seconds: Some(20),
diagnostics_strategy: Some("pull".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(true),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("rust".to_string()),
};
let result = server.lsp_health_impl(params).await;
let call_res = result.expect("should succeed");
let val = unpack_health(call_res);
assert_eq!(val.languages.len(), 1);
let rust_health = &val.languages[0];
assert_eq!(rust_health.language, "rust");
assert_eq!(rust_health.status, "ready");
assert_eq!(rust_health.diagnostics_strategy, Some("pull".to_string()));
assert_eq!(rust_health.supports_call_hierarchy, Some(true));
assert_eq!(rust_health.supports_diagnostics, Some(true));
}
#[tokio::test]
async fn test_lsp_health_shows_capabilities() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let lawyer_clone = lawyer.clone();
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
lawyer_clone.set_capability_status(std::collections::HashMap::from([(
"typescript".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true),
indexing_complete: Some(true),
uptime_seconds: Some(10),
diagnostics_strategy: Some("push".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true), supports_diagnostics: Some(true),
supports_formatting: Some(false),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
let params = crate::server::types::LspHealthParams::default();
let result = server.lsp_health_impl(params).await;
let call_res = result.expect("should succeed");
let val = unpack_health(call_res);
assert_eq!(val.languages.len(), 1);
let ts_health = &val.languages[0];
assert_eq!(ts_health.supports_definition, Some(true));
assert_eq!(ts_health.supports_call_hierarchy, Some(true));
assert_eq!(ts_health.supports_diagnostics, Some(true));
}
#[tokio::test]
async fn test_lsp_health_probe_upgrades_warming_up_to_ready() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let (server, ws_dir) = make_server_with_lawyer(surgeon, lawyer.clone());
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
std::fs::write(
ws_dir.path().join("src/main.rs"),
r#"fn main() { println!("Hello"); }"#,
)
.unwrap();
lawyer.set_capability_status(std::collections::HashMap::from([(
"rust".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true),
indexing_complete: Some(false), uptime_seconds: Some(30), diagnostics_strategy: Some("pull".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(true),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
lawyer.set_goto_definition_result(Ok(Some(pathfinder_lsp::types::DefinitionLocation {
file: "src/main.rs".to_string(),
line: 1,
column: 0,
preview: "fn main()".to_string(),
})));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("rust".to_string()),
};
let result = server.lsp_health_impl(params).await;
let call_res = result.expect("should succeed");
let val = unpack_health(call_res);
assert_eq!(val.status, "ready");
assert_eq!(val.languages.len(), 1);
let rust_health = &val.languages[0];
assert_eq!(rust_health.language, "rust");
assert_eq!(rust_health.status, "ready");
assert_eq!(rust_health.uptime, Some("30s".to_string()));
assert_eq!(rust_health.indexing_status, Some("in_progress".to_string()));
assert!(rust_health.probe_verified);
}
#[tokio::test]
async fn test_lsp_health_probe_keeps_warming_up_when_probe_fails() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let (server, ws_dir) = make_server_with_lawyer(surgeon, lawyer.clone());
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
std::fs::write(ws_dir.path().join("src/main.rs"), "fn main() {}").unwrap();
lawyer.set_capability_status(std::collections::HashMap::from([(
"rust".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true),
indexing_complete: Some(false), uptime_seconds: Some(30), diagnostics_strategy: Some("pull".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(true),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
lawyer.set_goto_definition_result(Err(pathfinder_lsp::LspError::ConnectionLost));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("rust".to_string()),
};
let result = server.lsp_health_impl(params).await;
let call_res = result.expect("should succeed");
let val = unpack_health(call_res);
assert_eq!(val.status, "degraded");
assert_eq!(val.languages.len(), 1);
let rust_health = &val.languages[0];
assert_eq!(rust_health.language, "rust");
assert_eq!(rust_health.status, "degraded");
assert!(!rust_health.probe_verified);
}
#[tokio::test]
async fn test_lsp_health_no_probe_for_recently_started() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let (server, ws_dir) = make_server_with_lawyer(surgeon, lawyer.clone());
let src_dir = ws_dir.path().join("src");
let _ = std::fs::remove_file(src_dir.join("main.rs"));
let _ = std::fs::remove_file(src_dir.join("auth.rs"));
let _ = std::fs::remove_file(src_dir.join("token.rs"));
let _ = std::fs::remove_file(src_dir.join("service.rs"));
let _ = std::fs::remove_file(src_dir.join("user.rs"));
let _ = std::fs::remove_file(src_dir.join("auth.go"));
lawyer.set_capability_status(std::collections::HashMap::from([(
"rust".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true),
indexing_complete: Some(false), uptime_seconds: Some(5), diagnostics_strategy: Some("pull".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(true),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
lawyer.set_goto_definition_result(Ok(Some(pathfinder_lsp::types::DefinitionLocation {
file: "src/main.rs".to_string(),
line: 1,
column: 0,
preview: "fn main()".to_string(),
})));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("rust".to_string()),
};
let result = server.lsp_health_impl(params).await;
let call_res = result.expect("should succeed");
let val = unpack_health(call_res);
assert_eq!(val.status, "ready");
assert_eq!(val.languages.len(), 1);
let rust_health = &val.languages[0];
assert_eq!(rust_health.language, "rust");
assert_eq!(rust_health.status, "ready");
assert_eq!(rust_health.indexing_status, Some("in_progress".to_string()));
assert!(!rust_health.probe_verified);
}
#[tokio::test]
async fn test_lsp_health_no_probe_for_already_ready() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let (server, ws_dir) = make_server_with_lawyer(surgeon, lawyer.clone());
let src_dir = ws_dir.path().join("src");
let _ = std::fs::remove_file(src_dir.join("main.rs"));
let _ = std::fs::remove_file(src_dir.join("auth.rs"));
let _ = std::fs::remove_file(src_dir.join("token.rs"));
let _ = std::fs::remove_file(src_dir.join("service.rs"));
let _ = std::fs::remove_file(src_dir.join("user.rs"));
let _ = std::fs::remove_file(src_dir.join("auth.go"));
lawyer.set_capability_status(std::collections::HashMap::from([(
"rust".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true),
indexing_complete: Some(true), uptime_seconds: Some(60), diagnostics_strategy: Some("pull".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(true),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
lawyer.set_goto_definition_result(Ok(Some(pathfinder_lsp::types::DefinitionLocation {
file: "src/main.rs".to_string(),
line: 1,
column: 0,
preview: "fn main()".to_string(),
})));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("rust".to_string()),
};
let result = server.lsp_health_impl(params).await;
let call_res = result.expect("should succeed");
let val = unpack_health(call_res);
assert_eq!(val.status, "ready");
assert_eq!(val.languages.len(), 1);
let rust_health = &val.languages[0];
assert_eq!(rust_health.status, "ready");
assert!(!rust_health.probe_verified);
}
#[tokio::test]
async fn test_parse_uptime_to_seconds() {
assert_eq!(parse_uptime_to_seconds(Some("5s")), Some(5));
assert_eq!(parse_uptime_to_seconds(Some("1m30s")), Some(90));
assert_eq!(parse_uptime_to_seconds(Some("2h15m")), Some(8100));
assert_eq!(parse_uptime_to_seconds(Some("1h30m45s")), Some(5445));
assert_eq!(parse_uptime_to_seconds(Some("1m")), Some(60));
assert_eq!(parse_uptime_to_seconds(Some("1h")), Some(3600));
assert_eq!(parse_uptime_to_seconds(None), None);
}
#[tokio::test]
async fn test_find_probe_file() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let (server, ws_dir) = make_server_with_lawyer(surgeon, lawyer);
let src_dir = ws_dir.path().join("src");
let _ = std::fs::remove_file(src_dir.join("main.rs"));
let _ = std::fs::remove_file(src_dir.join("auth.rs"));
let _ = std::fs::remove_file(src_dir.join("token.rs"));
let _ = std::fs::remove_file(src_dir.join("service.rs"));
let _ = std::fs::remove_file(src_dir.join("user.rs"));
let _ = std::fs::remove_file(src_dir.join("auth.go"));
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::write(ws_dir.path().join("main.go"), "package main").unwrap();
std::fs::write(src_dir.join("index.ts"), "export const x = 1;").unwrap();
assert_eq!(
server.find_probe_file("go"),
Some(std::path::PathBuf::from("main.go"))
);
assert_eq!(
server.find_probe_file("typescript"),
Some(std::path::PathBuf::from("src/index.ts"))
);
assert_eq!(server.find_probe_file("rust"), None); }
#[tokio::test]
async fn test_find_probe_file_recursive_monorepo() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let (server, ws_dir) = make_server_with_lawyer(surgeon, lawyer);
std::fs::create_dir_all(
ws_dir
.path()
.join("apps")
.join("backend")
.join("cmd")
.join("server"),
)
.unwrap();
std::fs::write(
ws_dir
.path()
.join("apps")
.join("backend")
.join("cmd")
.join("server")
.join("main.go"),
"package main\nfunc main() {}",
)
.unwrap();
std::fs::create_dir_all(ws_dir.path().join("node_modules").join("react")).unwrap();
std::fs::write(
ws_dir
.path()
.join("node_modules")
.join("react")
.join("index.ts"),
"export const React = {};",
)
.unwrap();
let probe = server.find_probe_file("go");
assert!(probe.is_some(), "Should find Go file in monorepo structure");
let probe_path = probe.unwrap();
assert!(
probe_path.to_str().unwrap().contains("main.go"),
"Should find a main.go file, got: {probe_path:?}"
);
std::fs::create_dir_all(ws_dir.path().join("tools").join("fath-factory").join("src"))
.unwrap();
std::fs::write(
ws_dir
.path()
.join("tools")
.join("fath-factory")
.join("src")
.join("__init__.py"),
"",
)
.unwrap();
let py_probe = server.find_probe_file("python");
assert!(
py_probe.is_some(),
"Should find Python file in tools/ directory"
);
}
#[tokio::test]
async fn test_lsp_health_includes_missing_languages_with_install_hint() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let lawyer_clone = lawyer.clone();
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
lawyer_clone.set_capability_status(std::collections::HashMap::from([(
"typescript".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true),
indexing_complete: Some(true),
uptime_seconds: Some(60),
diagnostics_strategy: Some("push".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(false),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
lawyer_clone.set_missing_languages(vec![
pathfinder_lsp::client::MissingLanguage {
language_id: "python".to_string(),
marker_file: "pyproject.toml".to_string(),
tried_binaries: vec!["pyright".to_string(), "pylsp".to_string()],
install_hint: "Install pyright: npm install -g pyright".to_string(),
},
pathfinder_lsp::client::MissingLanguage {
language_id: "go".to_string(),
marker_file: "go.mod".to_string(),
tried_binaries: vec!["gopls".to_string()],
install_hint: "Install gopls: go install golang.org/x/tools/gopls@latest"
.to_string(),
},
]);
let params = crate::server::types::LspHealthParams::default();
let result = server.lsp_health_impl(params).await;
let call_res = result.expect("should succeed");
let val = unpack_health(call_res);
assert_eq!(val.languages.len(), 3);
let python_health = val.languages.iter().find(|l| l.language == "python");
let go_health = val.languages.iter().find(|l| l.language == "go");
let ts_health = val.languages.iter().find(|l| l.language == "typescript");
assert!(ts_health.is_some());
assert_eq!(ts_health.unwrap().status, "ready");
assert!(python_health.is_some());
assert_eq!(python_health.unwrap().status, "unavailable");
assert_eq!(
python_health.unwrap().install_hint,
Some("Install pyright: npm install -g pyright".to_string())
);
assert!(go_health.is_some());
assert_eq!(go_health.unwrap().status, "unavailable");
assert_eq!(
go_health.unwrap().install_hint,
Some("Install gopls: go install golang.org/x/tools/gopls@latest".to_string())
);
}
#[tokio::test]
async fn test_lsp_health_missing_language_filter_works() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let lawyer_clone = lawyer.clone();
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
lawyer_clone.set_capability_status(std::collections::HashMap::new());
lawyer_clone.set_missing_languages(vec![
pathfinder_lsp::client::MissingLanguage {
language_id: "python".to_string(),
marker_file: "pyproject.toml".to_string(),
tried_binaries: vec!["pyright".to_string()],
install_hint: "Install pyright".to_string(),
},
pathfinder_lsp::client::MissingLanguage {
language_id: "rust".to_string(),
marker_file: "Cargo.toml".to_string(),
tried_binaries: vec!["rust-analyzer".to_string()],
install_hint: "Install rust-analyzer".to_string(),
},
]);
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("python".to_string()),
};
let result = server.lsp_health_impl(params).await;
let call_res = result.expect("should succeed");
let val = unpack_health(call_res);
assert_eq!(val.languages.len(), 1);
assert_eq!(val.languages[0].language, "python");
assert_eq!(
val.languages[0].install_hint,
Some("Install pyright".to_string())
);
}
#[tokio::test]
async fn test_health_shows_degraded_tools_for_no_diagnostics() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let lawyer_clone = lawyer.clone();
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
lawyer_clone.set_capability_status(std::collections::HashMap::from([(
"go".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true),
indexing_complete: Some(true),
uptime_seconds: Some(60),
diagnostics_strategy: None,
supports_definition: Some(true),
supports_call_hierarchy: None,
supports_diagnostics: None,
supports_formatting: Some(true),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("go".to_string()),
};
let result = server.lsp_health_impl(params).await;
let call_res = result.expect("should succeed");
let val = unpack_health(call_res);
assert_eq!(val.languages.len(), 1);
let go_health = &val.languages[0];
assert_eq!(go_health.language, "go");
let analyze_impact = go_health
.degraded_tools
.iter()
.find(|t| t.tool == "analyze_impact");
assert!(
analyze_impact.is_some(),
"degraded_tools should include analyze_impact when call hierarchy unsupported"
);
let ai = analyze_impact.unwrap();
assert_eq!(
ai.severity, "grep_fallback",
"analyze_impact should have severity=grep_fallback"
);
assert!(
ai.description.contains("text search"),
"analyze_impact description should mention text search fallback"
);
let rwdc = go_health
.degraded_tools
.iter()
.find(|t| t.tool == "read_with_deep_context");
assert!(
rwdc.is_some(),
"degraded_tools should include read_with_deep_context when call hierarchy unsupported"
);
let rwdc = rwdc.unwrap();
assert_eq!(
rwdc.severity, "unavailable",
"read_with_deep_context should have severity=unavailable"
);
assert!(
rwdc.description.contains("source only"),
"read_with_deep_context description should mention source-only limitation"
);
let has_validate_only = go_health
.degraded_tools
.iter()
.any(|t| t.tool == "validate_only");
assert!(
!has_validate_only,
"degraded_tools must not include the removed validate_only tool"
);
}
#[tokio::test]
async fn test_health_shows_empty_degraded_when_fully_capable() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let lawyer_clone = lawyer.clone();
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
lawyer_clone.set_capability_status(std::collections::HashMap::from([(
"rust".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true),
indexing_complete: Some(true),
uptime_seconds: Some(60),
diagnostics_strategy: Some("pull".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(true),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("rust".to_string()),
};
let result = server.lsp_health_impl(params).await;
let call_res = result.expect("should succeed");
let val = unpack_health(call_res);
assert_eq!(val.languages.len(), 1);
let rust_health = &val.languages[0];
assert_eq!(rust_health.language, "rust");
assert!(
rust_health.degraded_tools.is_empty(),
"degraded_tools should be empty when all capabilities supported, got: {:?}",
rust_health.degraded_tools
);
}
#[tokio::test]
async fn test_health_shows_push_latency() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let lawyer_clone = lawyer.clone();
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
lawyer_clone.set_capability_status(std::collections::HashMap::from([(
"go".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true),
indexing_complete: Some(true),
uptime_seconds: Some(60),
diagnostics_strategy: Some("push".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(true),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("go".to_string()),
};
let result = server.lsp_health_impl(params).await;
let call_res = result.expect("should succeed");
let val = unpack_health(call_res);
assert_eq!(val.languages.len(), 1);
let go_health = &val.languages[0];
assert_eq!(go_health.language, "go");
assert!(
go_health.degraded_tools.is_empty(),
"fully capable LSP should have no degraded tools"
);
}
#[tokio::test]
async fn test_health_shows_pull_latency() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let lawyer_clone = lawyer.clone();
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
lawyer_clone.set_capability_status(std::collections::HashMap::from([(
"rust".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true),
indexing_complete: Some(true),
uptime_seconds: Some(60),
diagnostics_strategy: Some("pull".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(true),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("rust".to_string()),
};
let result = server.lsp_health_impl(params).await;
result.expect("pull-diagnostics language should return successfully");
}
#[tokio::test]
async fn test_lsp_health_ready_but_still_indexing_shows_confidence_gradient() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let lawyer_clone = lawyer.clone();
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
lawyer_clone.set_capability_status(std::collections::HashMap::from([(
"python".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: false, reason: "LSP connected but does not support diagnostics".to_string(),
navigation_ready: Some(true), indexing_complete: Some(false), uptime_seconds: Some(5),
diagnostics_strategy: Some("none".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(false),
supports_formatting: Some(false),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("python".to_string()),
};
let result = server.lsp_health_impl(params).await;
let val = unpack_health(result.expect("should succeed"));
let py_health = &val.languages[0];
assert_eq!(py_health.status, "ready");
assert_eq!(py_health.indexing_status, Some("in_progress".to_string()));
assert_eq!(py_health.navigation_ready, Some(true));
assert_eq!(py_health.diagnostics_strategy, Some("none".to_string()));
let has_validate_only = py_health
.degraded_tools
.iter()
.any(|t| t.tool == "validate_only");
assert!(!has_validate_only);
}
#[tokio::test]
async fn test_lsp_health_fully_indexed_shows_complete_confidence() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let lawyer_clone = lawyer.clone();
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
lawyer_clone.set_capability_status(std::collections::HashMap::from([(
"rust".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true), indexing_complete: Some(true), uptime_seconds: Some(120),
diagnostics_strategy: Some("pull".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(true),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("rust".to_string()),
};
let result = server.lsp_health_impl(params).await;
let val = unpack_health(result.expect("should succeed"));
let rust_health = &val.languages[0];
assert_eq!(rust_health.status, "ready");
assert_eq!(rust_health.navigation_ready, Some(true));
assert_eq!(rust_health.indexing_status, Some("complete".to_string()));
assert!(rust_health.degraded_tools.is_empty());
}
#[tokio::test]
async fn test_probe_cache_positive_result_never_expires() {
let entry = crate::server::ProbeCacheEntry::new(true);
assert!(entry.is_valid(), "positive entry should always be valid");
}
#[tokio::test]
async fn test_probe_cache_negative_result_is_initially_valid() {
let entry = crate::server::ProbeCacheEntry::new(false);
assert!(entry.is_valid(), "fresh negative entry should be valid");
}
#[tokio::test]
async fn test_probe_negative_cache_skips_reprobe() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let lawyer_clone = lawyer.clone();
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
server
.probe_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(
"rust".to_string(),
crate::server::ProbeCacheEntry::new(false),
);
lawyer_clone.set_capability_status(std::collections::HashMap::from([(
"rust".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(false),
indexing_complete: Some(false),
uptime_seconds: Some(30), diagnostics_strategy: Some("pull".to_string()),
supports_definition: Some(false),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(true),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("rust".to_string()),
};
let result = server.lsp_health_impl(params).await;
let val = unpack_health(result.expect("should succeed"));
let rust_health = &val.languages[0];
assert_eq!(rust_health.status, "warming_up");
assert!(
!rust_health.probe_verified,
"should not be probe-verified when using negative cache"
);
}
#[tokio::test]
async fn test_probe_cache_positive_upgrades_to_ready() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let lawyer_clone = lawyer.clone();
let (server, _ws) = make_server_with_lawyer(surgeon, lawyer);
server
.probe_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(
"rust".to_string(),
crate::server::ProbeCacheEntry::new(true),
);
lawyer_clone.set_capability_status(std::collections::HashMap::from([(
"rust".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(false),
indexing_complete: Some(false),
uptime_seconds: Some(30),
diagnostics_strategy: Some("pull".to_string()),
supports_definition: Some(false),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(true),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("rust".to_string()),
};
let result = server.lsp_health_impl(params).await;
let val = unpack_health(result.expect("should succeed"));
let rust_health = &val.languages[0];
assert_eq!(rust_health.status, "ready");
assert!(
rust_health.probe_verified,
"should be probe-verified from cache"
);
}
#[tokio::test]
async fn test_lsp_health_liveness_probe_downgrades_dead_lsp() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let (server, ws_dir) = make_server_with_lawyer(surgeon, lawyer.clone());
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
std::fs::write(ws_dir.path().join("src/main.rs"), "fn main() {}").unwrap();
lawyer.set_capability_status(std::collections::HashMap::from([(
"rust".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true),
indexing_complete: Some(true),
uptime_seconds: Some(120),
diagnostics_strategy: Some("pull".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(true),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
lawyer.set_goto_definition_result(Err(pathfinder_lsp::LspError::Timeout {
operation: "goto_definition".to_string(),
timeout_ms: 10000,
}));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("rust".to_string()),
};
let result = server.lsp_health_impl(params).await;
let val = unpack_health(result.expect("should succeed"));
assert_eq!(val.status, "degraded");
let rust_health = &val.languages[0];
assert_eq!(rust_health.status, "degraded");
assert!(!rust_health.probe_verified);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn test_lsp_health_liveness_probe_caches_positive() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let (server, ws_dir) = make_server_with_lawyer(surgeon, lawyer.clone());
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
std::fs::write(ws_dir.path().join("src/main.rs"), "fn main() {}").unwrap();
lawyer.set_capability_status(std::collections::HashMap::from([(
"rust".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true),
indexing_complete: Some(true),
uptime_seconds: Some(120),
diagnostics_strategy: Some("pull".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(true),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
lawyer.set_goto_definition_result(Ok(Some(pathfinder_lsp::types::DefinitionLocation {
file: "src/main.rs".to_string(),
line: 1,
column: 0,
preview: "fn main()".to_string(),
})));
let result1 = server
.lsp_health_impl(crate::server::types::LspHealthParams {
action: None,
language: Some("rust".to_string()),
})
.await;
let val1 = unpack_health(result1.expect("should succeed"));
assert!(val1.languages[0].probe_verified);
let cache = server.probe_cache.lock().unwrap();
assert!(cache.contains_key("rust"));
let entry = cache.get("rust").unwrap();
assert!(entry.success);
drop(cache);
let call_count_before = lawyer.goto_definition_call_count();
let result2 = server
.lsp_health_impl(crate::server::types::LspHealthParams {
action: None,
language: Some("rust".to_string()),
})
.await;
let val2 = unpack_health(result2.expect("should succeed"));
assert!(val2.languages[0].probe_verified);
assert_eq!(lawyer.goto_definition_call_count(), call_count_before);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn test_liveness_probe_interval_skips_recent() {
let surgeon = Arc::new(MockSurgeon::default());
let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
let (server, ws_dir) = make_server_with_lawyer(surgeon, lawyer.clone());
std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
std::fs::write(ws_dir.path().join("src/main.rs"), "fn main() {}").unwrap();
lawyer.set_capability_status(std::collections::HashMap::from([(
"rust".to_string(),
pathfinder_lsp::types::LspLanguageStatus {
validation: true,
reason: "LSP connected".to_string(),
navigation_ready: Some(true),
indexing_complete: Some(true),
uptime_seconds: Some(120),
diagnostics_strategy: Some("pull".to_string()),
supports_definition: Some(true),
supports_call_hierarchy: Some(true),
supports_diagnostics: Some(true),
supports_formatting: Some(true),
server_name: None,
indexing_source: None,
indexing_duration_secs: None,
indexing_progress_percent: None,
},
)]));
let mut cache = server.probe_cache.lock().unwrap();
cache.insert(
"rust".to_string(),
crate::server::ProbeCacheEntry::new(true),
);
drop(cache);
lawyer.set_goto_definition_result(Ok(Some(pathfinder_lsp::types::DefinitionLocation {
file: "src/main.rs".to_string(),
line: 1,
column: 0,
preview: "fn main()".to_string(),
})));
let params = crate::server::types::LspHealthParams {
action: None,
language: Some("rust".to_string()),
};
let call_count_before = lawyer.goto_definition_call_count();
let result = server.lsp_health_impl(params).await;
let val = unpack_health(result.expect("should succeed"));
assert!(val.languages[0].probe_verified);
assert_eq!(lawyer.goto_definition_call_count(), call_count_before);
}
}