use std::path::Path;
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::daemon::discover::{TRUSTY_SEARCH_DEFAULT_ADDR, discover_addr};
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,
HttpStatus(u16),
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) => {
let dir = home.join(".trusty-search");
let default = TRUSTY_SEARCH_DEFAULT_ADDR
.parse()
.expect("static default is valid");
let env = std::env::var("TRUSTY_SEARCH_ADDR").ok();
let addr = discover_addr(&dir, default, env.as_deref()).await;
Some(probe_pinned_index(&format!("http://{addr}"), id).await)
}
_ => 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(base: &str, index_id: &str) -> PinProbe {
let client = match reqwest::Client::builder().timeout(PROBE_TIMEOUT).build() {
Ok(c) => c,
Err(e) => return PinProbe::Unreachable(e.to_string()),
};
let url = format!("{base}/indexes/{index_id}/status");
match client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
let chunk_count = resp
.json::<serde_json::Value>()
.await
.ok()
.and_then(|b| b.get("chunk_count").and_then(serde_json::Value::as_u64));
PinProbe::Resolved { chunk_count }
}
Ok(resp) if resp.status().as_u16() == 404 => PinProbe::UnknownIndex,
Ok(resp) => PinProbe::HttpStatus(resp.status().as_u16()),
Err(e) => PinProbe::Unreachable(e.to_string()),
}
}
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 returns 404 \"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::HttpStatus(code)) => DoctorCheck::new(
CHECK,
CheckStatus::Fail,
format!("status of pinned trusty-search index `{id}` returned HTTP {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;