use std::path::Path;
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::daemon::search_rpc::{self, SearchRpcError};
use super::PROBE_TIMEOUT;
const CHECK: &str = "search_index_pin";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum PinState {
NoMcpJson,
Unreadable(String),
NoSearchServer,
Unpinned,
Pinned(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum PinProbe {
Resolved { chunk_count: Option<u64> },
UnknownIndex,
RpcRefusal(i64),
Unreachable(String),
}
pub(super) async fn check_search_index_pin(
_home: &Path,
project_dir: Option<&Path>,
) -> DoctorCheck {
let Some(project) = project_dir else {
return DoctorCheck::new(
CHECK,
CheckStatus::Warn,
"no project directory supplied — cannot resolve a pinned trusty-search index",
);
};
let pin = read_pinned_index_id(project);
let probe = match &pin {
PinState::Pinned(id) => Some(match search_rpc::search_socket() {
Ok(socket) => probe_pinned_index(&socket, id).await,
Err(e) => PinProbe::Unreachable(format!("{e:#}")),
}),
_ => None,
};
build_pin_check(&pin, probe.as_ref())
}
pub(super) fn read_pinned_index_id(project_dir: &Path) -> PinState {
let path = project_dir.join(".mcp.json");
if !path.exists() {
return PinState::NoMcpJson;
}
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(e) => return PinState::Unreadable(e.to_string()),
};
let value: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => return PinState::Unreadable(e.to_string()),
};
let Some(args) = value
.get("mcpServers")
.and_then(|m| m.get("trusty-search"))
.and_then(|s| s.get("args"))
.and_then(serde_json::Value::as_array)
else {
return PinState::NoSearchServer;
};
let pinned = args
.iter()
.position(|a| a.as_str() == Some("--index"))
.and_then(|i| args.get(i + 1))
.and_then(serde_json::Value::as_str)
.filter(|id| !id.trim().is_empty());
match pinned {
Some(id) => PinState::Pinned(id.to_string()),
None => PinState::Unpinned,
}
}
pub(super) async fn probe_pinned_index(socket: &Path, index_id: &str) -> PinProbe {
match search_rpc::call_at(
socket,
search_rpc::METHOD_INDEX_STATUS,
serde_json::json!({ "index_id": index_id }),
PROBE_TIMEOUT,
)
.await
{
Ok(body) => PinProbe::Resolved {
chunk_count: body.get("chunk_count").and_then(serde_json::Value::as_u64),
},
Err(e) => match e.downcast_ref::<SearchRpcError>() {
Some(rpc) if rpc.is_not_found() => PinProbe::UnknownIndex,
Some(rpc) => PinProbe::RpcRefusal(rpc.code),
None => PinProbe::Unreachable(format!("{e:#}")),
},
}
}
pub(super) fn build_pin_check(pin: &PinState, probe: Option<&PinProbe>) -> DoctorCheck {
let id = match pin {
PinState::NoMcpJson => {
return DoctorCheck::new(
CHECK,
CheckStatus::Ok,
"no .mcp.json in this project — no trusty-search index pin to resolve",
);
}
PinState::Unreadable(e) => {
return DoctorCheck::new(
CHECK,
CheckStatus::Warn,
format!(
".mcp.json is present but unreadable ({e}) — whether this session pins a \
trusty-search index, and whether that index exists, is UNVERIFIABLE"
),
);
}
PinState::NoSearchServer => {
return DoctorCheck::new(
CHECK,
CheckStatus::Ok,
".mcp.json registers no trusty-search server — no index pin to resolve",
);
}
PinState::Unpinned => {
return DoctorCheck::new(
CHECK,
CheckStatus::Warn,
"the trusty-search MCP stub carries no `--index` pin — a bare `search` falls back \
to whichever index the daemon guesses for this path, which is the wrong-index \
bug issue #1373 fixed by pinning. Relaunch the session to rewrite .mcp.json",
);
}
PinState::Pinned(id) => id,
};
match probe {
Some(PinProbe::UnknownIndex) => DoctorCheck::new(
CHECK,
CheckStatus::Fail,
format!(
"this session is pinned to trusty-search index `{id}` but the daemon has NO such \
index — every `search`/`grep` call in this session is refused with \"unknown \
index\", and the `search` health check stays green because it reports the daemon, not the \
pin (issue #5045). Either the index was deleted since the pin was written, or the \
pin predates #5091. Run `trusty-search index create` for this project, or \
relaunch the session"
),
),
Some(PinProbe::RpcRefusal(code)) => DoctorCheck::new(
CHECK,
CheckStatus::Fail,
format!("status of pinned trusty-search index `{id}` was refused with code {code}"),
),
Some(PinProbe::Resolved { chunk_count }) => match chunk_count {
Some(0) => DoctorCheck::new(
CHECK,
CheckStatus::Warn,
format!(
"pinned trusty-search index `{id}` exists but holds 0 chunks — it was \
registered and never populated, so every query returns nothing. Trigger a \
reindex for this project"
),
),
Some(n) => DoctorCheck::new(
CHECK,
CheckStatus::Ok,
format!("pinned trusty-search index `{id}` resolves ({n} chunks)"),
),
None => DoctorCheck::new(
CHECK,
CheckStatus::Ok,
format!("pinned trusty-search index `{id}` resolves"),
),
},
Some(PinProbe::Unreachable(e)) => DoctorCheck::new(
CHECK,
CheckStatus::Unknown,
format!(
"this session is pinned to trusty-search index `{id}` but the daemon did not \
answer ({e}) — whether that index exists is UNKNOWN. The `search` check above \
reports the daemon itself"
),
),
None => DoctorCheck::new(
CHECK,
CheckStatus::Unknown,
format!("pinned trusty-search index `{id}` was not probed"),
),
}
}
#[cfg(test)]
#[path = "doctor_search_pin_tests.rs"]
mod tests;