use std::collections::BTreeMap;
use schemars::JsonSchema;
use scryer_db::ScryerDb;
use serde::{Deserialize, Serialize};
use crate::stats::IndexStats;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct IndexHealth {
pub file_count: usize,
pub symbol_count: usize,
#[serde(default)]
pub reference_count: usize,
#[serde(default)]
pub edge_count: usize,
#[serde(default)]
pub files_by_language: BTreeMap<String, usize>,
#[serde(default)]
pub references_by_via: BTreeMap<String, usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_indexed: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stats: Option<IndexStats>,
}
async fn count(db: &ScryerDb, table: &'static str, project_id: u64) -> anyhow::Result<usize> {
let mut guard = db.lock().await;
let rows = toasty::sql::query(format!(
"SELECT COUNT(*) FROM {table} WHERE project_id = ?1"
))
.bind(project_id as i64)
.exec(&mut *guard)
.await?;
Ok(rows
.first()
.and_then(first_field)
.map_or(0, |n| usize::try_from(n).unwrap_or(0)))
}
async fn count_by(
db: &ScryerDb,
table: &'static str,
column: &'static str,
project_id: u64,
) -> anyhow::Result<BTreeMap<String, usize>> {
let mut guard = db.lock().await;
let rows = toasty::sql::query(format!(
"SELECT {column}, COUNT(*) FROM {table} WHERE project_id = ?1 GROUP BY {column}"
))
.bind(project_id as i64)
.exec(&mut *guard)
.await?;
let mut out = BTreeMap::new();
for row in rows {
let toasty::stmt::Value::Record(record) = row else {
continue;
};
if let [key, n] = record.fields.as_slice()
&& let Some(key) = key.as_str()
&& let Some(n) = value_i64(n)
{
out.insert(key.to_string(), usize::try_from(n).unwrap_or(0));
}
}
Ok(out)
}
fn value_i64(value: &toasty::stmt::Value) -> Option<i64> {
match value {
toasty::stmt::Value::I64(n) => Some(*n),
toasty::stmt::Value::U64(n) => i64::try_from(*n).ok(),
toasty::stmt::Value::I32(n) => Some(i64::from(*n)),
_ => None,
}
}
fn first_field(row: &toasty::stmt::Value) -> Option<i64> {
match row {
toasty::stmt::Value::Record(record) => record.fields.first().and_then(value_i64),
other => value_i64(other),
}
}
pub(crate) async fn load_index_stats(
db: &ScryerDb,
project_id: u64,
) -> anyhow::Result<Option<IndexStats>> {
Ok(load_index_stats_row(db, project_id).await?.map(|(s, _)| s))
}
async fn load_index_stats_row(
db: &ScryerDb,
project_id: u64,
) -> anyhow::Result<Option<(IndexStats, String)>> {
let mut guard = db.lock().await;
let row =
scryer_db::IndexStats::filter(scryer_db::IndexStats::fields().project_id().eq(project_id))
.first()
.exec(&mut *guard)
.await?;
Ok(row.and_then(|r| {
serde_json::from_str(&r.stats_json)
.ok()
.map(|s| (s, r.updated_at))
}))
}
pub async fn index_health(db: &ScryerDb, project_id: u64) -> anyhow::Result<IndexHealth> {
let stats = load_index_stats_row(db, project_id).await?;
Ok(IndexHealth {
file_count: count(db, "source_file", project_id).await?,
symbol_count: count(db, "symbol", project_id).await?,
reference_count: count(db, "symbol_reference", project_id).await?,
edge_count: count(db, "code_graph_edge", project_id).await?,
files_by_language: count_by(db, "source_file", "language", project_id).await?,
references_by_via: count_by(db, "symbol_reference", "via", project_id).await?,
last_indexed: stats.as_ref().map(|(_, at)| at.clone()),
stats: stats.map(|(s, _)| s),
})
}
impl IndexHealth {
pub fn problems(&self) -> Vec<String> {
let mut out = Vec::new();
let Some(stats) = &self.stats else {
if self.file_count > 0 {
out.push(
"No health data was recorded for this index (it predates it); run `scryer index` to record it".to_string(),
);
}
return out;
};
if stats.parse_failures.count > 0 {
let first = stats
.parse_failures
.first
.as_ref()
.map(|f| {
format!(
" (first: {}: {})",
f.path,
f.error.lines().next().unwrap_or("")
)
})
.unwrap_or_default();
out.push(format!(
"{} file(s) failed to parse and are not indexed{first}",
stats.parse_failures.count
));
}
if !stats.syntax_error_files.is_empty() {
out.push(format!(
"{} file(s) parsed with syntax errors (the code may be broken, or use syntax or macros the parser does not support), so code around the errors may be missing from the index (first: {})",
stats.syntax_error_files.len(),
stats.syntax_error_files[0]
));
}
if stats.skipped.too_large > 0 {
out.push(format!(
"{} file(s) over the {} MiB size cap are not indexed",
stats.skipped.too_large,
crate::scanner::MAX_SOURCE_FILE_BYTES / (1024 * 1024)
));
}
if stats.skipped.unreadable > 0 {
out.push(format!(
"{} file(s) could not be read and are not indexed",
stats.skipped.unreadable
));
}
if stats.dependencies.is_problem() {
out.push(format!("Dependencies: {}", stats.dependencies.describe()));
}
out
}
pub fn render(&self) -> String {
use std::fmt::Write;
let join = |map: &BTreeMap<String, usize>| {
if map.is_empty() {
"none".to_string()
} else {
map.iter()
.map(|(k, v)| format!("{k} {v}"))
.collect::<Vec<_>>()
.join(" | ")
}
};
let mut out = String::new();
let _ = writeln!(
out,
"Index: {} files | {} symbols | {} references | {} call and reexport edges",
self.file_count, self.symbol_count, self.reference_count, self.edge_count
);
let _ = writeln!(out, "Languages: {}", join(&self.files_by_language));
let _ = writeln!(out, "References: {}", join(&self.references_by_via));
if let Some(at) = &self.last_indexed {
let _ = writeln!(out, "Last indexed: {at}");
}
if let Some(stats) = &self.stats {
let _ = writeln!(
out,
"Unresolved: {} references name nothing in the project or its dependencies (standard library and other external code, or unknown names)",
stats.unresolved_references
);
let _ = writeln!(out, "Dependencies: {}", stats.dependencies.describe());
}
let problems = self.problems();
if problems.is_empty() {
let _ = writeln!(out, "Problems: none");
} else {
let _ = writeln!(out, "Problems:");
for problem in problems {
let _ = writeln!(out, " - {problem}");
}
}
out
}
}