use super::helpers::wrap_with_meta;
use super::protocol::JsonRpcError;
use crate::cli::registry::ProjectRegistry;
use serde_json::Value;
use std::sync::Arc;
#[derive(Clone)]
pub struct DiagnosticsHandler;
impl DiagnosticsHandler {
pub fn name(&self) -> &str {
"leindex_diagnostics"
}
pub fn title(&self) -> &str {
"LeIndex [Diagnostics]"
}
pub fn description(&self) -> &str {
"Get diagnostic information about the indexed project, including memory usage, index statistics, and system health."
}
pub fn argument_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Project directory (omit to use current project)"
}
},
"required": []
})
}
pub async fn execute(
&self,
registry: &Arc<ProjectRegistry>,
args: Value,
) -> Result<Value, JsonRpcError> {
let project_path = args.get("project_path").and_then(|v| v.as_str());
let handle = registry.get_or_load(project_path).await?;
let guard = handle.read().await;
let diagnostics = live_diagnostics(&guard);
let health = crate::cli::index_freshness::load_health(guard.storage_path());
let stale_bool = health.as_ref().is_some_and(|health| {
matches!(
health.status,
crate::cli::leindex::ComponentStatus::Stale
| crate::cli::leindex::ComponentStatus::Partial
| crate::cli::leindex::ComponentStatus::Failed
)
});
let (changed, deleted) = git_delta(&guard);
let storage_path = guard.storage_path().display().to_string();
let db_size = std::fs::metadata(guard.storage_path().join("leindex.db"))
.map(|m| m.len())
.unwrap_or(0);
let coverage = coverage_json(health.as_ref());
let values = diag_values(health.as_ref(), diagnostics.as_ref());
let process_rss_bytes = crate::cli::memory_report::current_rss_bytes();
let memory_rss_mb = (process_rss_bytes as f64 / 1024.0 / 1024.0 * 100.0).round() / 100.0;
let store_disk_bytes = storage_dir_size_bytes(std::path::Path::new(&storage_path));
let size_mb = (store_disk_bytes as f64 / 1024.0 / 1024.0 * 100.0).round() / 100.0;
let heap_estimate_mb =
(values.memory_usage_bytes as f64 / 1024.0 / 1024.0 * 100.0).round() / 100.0;
let mut diag_json = match diagnostics {
Some(diagnostics) => serde_json::to_value(diagnostics)
.map_err(|e| JsonRpcError::internal_error(format!("Serialization error: {}", e)))?,
None => serde_json::json!({}),
};
let (ort_path, ort_version, execution_provider, execution_provider_active) =
crate::cli::cli::collect_ort_diagnostics();
if let Value::Object(ref mut map) = diag_json {
insert_ort_fields(
map,
&storage_path,
db_size,
(
ort_path,
ort_version,
execution_provider,
execution_provider_active,
),
);
map.insert(
"memory_rss_mb".to_string(),
serde_json::json!(memory_rss_mb),
);
map.insert("engram".to_string(), engram_json());
map.insert(
"indexed_files".to_string(),
serde_json::json!(values.indexed_files),
);
map.insert(
"symbol_count".to_string(),
serde_json::json!(values.symbol_count),
);
map.insert("index_size_mb".to_string(), serde_json::json!(size_mb));
map.insert(
"index_heap_estimate_mb".to_string(),
serde_json::json!(heap_estimate_mb),
);
map.insert("stale".to_string(), serde_json::json!(stale_bool));
insert_system_health(map, &values, guard.storage_path());
map.insert(
"issues".to_string(),
serde_json::json!(issues_json(&values, stale_bool)),
);
map.insert(
"freshness".to_string(),
staleness_json(stale_bool, &changed, &deleted),
);
if let Some(cov) = coverage {
map.insert("coverage".to_string(), cov);
}
}
Ok(wrap_with_meta(diag_json, &guard))
}
}
fn live_diagnostics(
index: &crate::cli::leindex::LeIndex,
) -> Option<crate::cli::leindex::Diagnostics> {
match index.get_diagnostics() {
Ok(diagnostics) => Some(diagnostics),
Err(error) => {
tracing::warn!(
project = %index.project_path().display(),
"Live diagnostics unavailable; returning persisted health only: {error}"
);
None
}
}
}
fn git_delta(index: &crate::cli::leindex::LeIndex) -> (Vec<std::path::PathBuf>, Vec<String>) {
crate::cli::git::status(index.project_path())
.ok()
.map(|status| {
let changed = status
.modified
.into_iter()
.chain(status.staged)
.chain(status.untracked)
.map(|path| index.project_path().join(path))
.collect::<Vec<_>>();
let deleted = status
.deleted
.into_iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>();
(changed, deleted)
})
.unwrap_or_else(|| (Vec::new(), Vec::new()))
}
fn coverage_json(health: Option<&crate::cli::leindex::IndexHealth>) -> Option<Value> {
health.map(|snapshot| {
let total = snapshot
.indexed_file_count
.saturating_add(snapshot.changed_unindexed_count);
serde_json::json!({
"total_source_files": total,
"indexed_files": snapshot.indexed_file_count,
"missing_files": [],
"orphaned_entries": [],
"coverage_pct": if total == 0 { 100.0 } else {
snapshot.indexed_file_count as f64 / total as f64 * 100.0
},
"source": "persisted_health",
})
})
}
struct DiagValues {
indexed_files: usize,
symbol_count: usize,
memory_usage_bytes: usize,
failed_parses: usize,
index_health: String,
pdg_nodes: usize,
pdg_edges: usize,
embedding_model: String,
embedding_preset: String,
measured_embedding_dimension: Option<usize>,
pdg_loaded: bool,
search_index_nodes: usize,
total_signatures: usize,
signature_scope: String,
indexed_nodes: usize,
files_parsed: usize,
indexing_time_ms: u64,
available: bool,
}
fn diag_values(
health: Option<&crate::cli::leindex::IndexHealth>,
diagnostics: Option<&crate::cli::leindex::Diagnostics>,
) -> DiagValues {
let stats = diagnostics.map(|d| &d.stats);
DiagValues {
indexed_files: health
.map(|snapshot| snapshot.indexed_file_count)
.or_else(|| stats.map(|s| s.files_parsed))
.unwrap_or(0),
symbol_count: stats.map(|s| s.indexed_nodes).unwrap_or(0),
memory_usage_bytes: diagnostics.map(|d| d.memory_usage_bytes).unwrap_or(0),
failed_parses: stats.map(|s| s.failed_parses).unwrap_or(0),
index_health: diagnostics
.map(|d| d.index_health.clone())
.unwrap_or_else(|| "unknown".to_string()),
pdg_nodes: diagnostics.map(|d| d.pdg_nodes).unwrap_or(0),
pdg_edges: diagnostics.map(|d| d.pdg_edges).unwrap_or(0),
embedding_model: diagnostics
.map(|d| d.embedding_model.clone())
.unwrap_or_else(|| "unknown".to_string()),
embedding_preset: diagnostics
.map(|d| d.embedding_preset.clone())
.unwrap_or_else(|| "unknown".to_string()),
measured_embedding_dimension: diagnostics.and_then(|d| d.measured_embedding_dimension),
pdg_loaded: diagnostics.map(|d| d.pdg_loaded).unwrap_or(false),
search_index_nodes: diagnostics.map(|d| d.search_index_nodes).unwrap_or(0),
total_signatures: stats.map(|s| s.total_signatures).unwrap_or(0),
signature_scope: stats
.map(|s| s.signature_scope.clone())
.unwrap_or_else(|| "full".to_string()),
indexed_nodes: stats.map(|s| s.indexed_nodes).unwrap_or(0),
files_parsed: stats.map(|s| s.files_parsed).unwrap_or(0),
indexing_time_ms: stats.map(|s| s.indexing_time_ms).unwrap_or(0),
available: diagnostics.is_some(),
}
}
fn engram_json() -> Value {
let engram = serde_json::to_value(crate::search::engram::global_stats())
.unwrap_or_else(|_| serde_json::json!({}));
#[cfg(feature = "onnx")]
let engram = {
let mut engram = engram;
if let Value::Object(ref mut engram_map) = engram {
let (hits, misses) = crate::search::onnx::embed_cache_frontend::counters();
engram_map.insert(
"embed_cache".to_string(),
serde_json::json!({ "hits": hits, "misses": misses }),
);
}
engram
};
engram
}
fn insert_ort_fields(
map: &mut serde_json::Map<String, Value>,
storage_path: &str,
db_size: u64,
ort: (Option<String>, Option<String>, String, Option<String>),
) {
let (ort_path, ort_version, execution_provider, execution_provider_active) = ort;
map.insert("storage_path".to_string(), serde_json::json!(storage_path));
map.insert("db_size_bytes".to_string(), serde_json::json!(db_size));
map.insert("ort_path".to_string(), serde_json::json!(ort_path));
map.insert("ort_version".to_string(), serde_json::json!(ort_version));
map.insert(
"execution_provider".to_string(),
serde_json::json!(execution_provider),
);
map.insert(
"execution_provider_active".to_string(),
serde_json::json!(execution_provider_active),
);
}
fn insert_system_health(
map: &mut serde_json::Map<String, Value>,
values: &DiagValues,
storage_path: &std::path::Path,
) {
map.insert(
"embedding_preset".to_string(),
serde_json::json!(values.embedding_preset),
);
map.insert(
"measured_embedding_dimension".to_string(),
serde_json::json!(values.measured_embedding_dimension),
);
map.insert(
"system_health".to_string(),
serde_json::json!({
"index_health": values.index_health,
"pdg_loaded": values.pdg_loaded,
"pdg_nodes": values.pdg_nodes,
"pdg_edges": values.pdg_edges,
"search_index_nodes": values.search_index_nodes,
"embedding_model": values.embedding_model,
"total_signatures": values.total_signatures,
"signature_scope": values.signature_scope,
"indexed_nodes": values.indexed_nodes,
"files_parsed": values.files_parsed,
"failed_parses": values.failed_parses,
"indexing_time_ms": values.indexing_time_ms,
}),
);
let lm = std::fs::metadata(storage_path.join("leindex.db"))
.and_then(|m| m.modified())
.ok();
let secs_ago = lm.and_then(|t| {
std::time::SystemTime::now()
.duration_since(t)
.ok()
.map(|d| d.as_secs())
});
map.insert(
"last_indexed_secs_ago".to_string(),
serde_json::json!(secs_ago),
);
}
fn issues_json(values: &DiagValues, stale_bool: bool) -> Vec<Value> {
let mut issues: Vec<Value> = Vec::new();
if values.failed_parses > 0 {
issues.push(serde_json::json!({
"severity": "warning",
"message": format!("{} files failed to parse", values.failed_parses),
}));
}
if !values.available {
issues.push(serde_json::json!({
"severity": "warning",
"message": "Live diagnostics unavailable — showing persisted health snapshot only. Reindex (LeIndex [Index] with force_reindex=true) to restore full diagnostics.",
}));
}
if stale_bool {
issues.push(serde_json::json!({
"severity": "warning",
"message": "Index may be stale. Call leindex_manage action=index with force_reindex=true for fresh results.",
}));
}
issues
}
fn staleness_json(stale_bool: bool, changed: &[std::path::PathBuf], deleted: &[String]) -> Value {
if !stale_bool {
serde_json::json!({
"status": "fresh",
"changed_files": 0,
"deleted_files": 0,
"uncommitted_git_files": changed.len() + deleted.len(),
})
} else {
serde_json::json!({
"status": "stale",
"changed_files": changed.len(),
"deleted_files": deleted.len(),
"changed_sample": changed.iter().take(10).map(|p| p.display().to_string()).collect::<Vec<_>>(),
"deleted_sample": deleted.iter().take(10).cloned().collect::<Vec<_>>(),
"suggestion": "Call leindex_manage action=index with force_reindex=true to refresh",
})
}
}
#[cfg(test)]
#[test]
fn test_mcp_diagnostics_embedding_fields_survive_trim() {
let project = tempfile::tempdir().unwrap();
let mut map = serde_json::Map::new();
insert_system_health(&mut map, &diag_values(None, None), project.path());
assert_eq!(map["embedding_preset"], "unknown");
assert!(map["measured_embedding_dimension"].is_null());
let index = crate::cli::leindex::LeIndex::new(project.path()).unwrap();
let mut diagnostics = index.get_diagnostics().unwrap();
diagnostics.embedding_preset = "embeddinggemma-2".to_string();
diagnostics.measured_embedding_dimension = Some(768);
insert_system_health(
&mut map,
&diag_values(None, Some(&diagnostics)),
project.path(),
);
let trimmed =
crate::cli::mcp::output::trim::trim_llm_payload("leindex_diagnostics", &Value::Object(map));
assert_eq!(trimmed["embedding_preset"], "embeddinggemma-2");
assert_eq!(trimmed["measured_embedding_dimension"], 768);
}
fn storage_dir_size_bytes(root: &std::path::Path) -> u64 {
fn dir_size(dir: &std::path::Path) -> u64 {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut total = 0u64;
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
total += dir_size(&entry.path());
} else if file_type.is_file() {
total += entry.metadata().map(|m| m.len()).unwrap_or(0);
}
}
total
}
dir_size(root)
}