use axum::{
extract::{Query, State},
response::{IntoResponse, Json, Response},
};
use serde::Deserialize;
use std::sync::Arc;
use crate::core::registry::{IndexHandle, IndexId};
use super::helpers::{embedder_error_response, embedder_initializing_response, validate_root_path};
use super::router::{CreateIndexRequest, IndexDetailEntry, IndexListResponse};
use super::state::{DaemonEvent, SearchAppState};
use super::status::index_disk_and_mtime;
pub(super) use super::indexes_relocate::relocate_index_handler;
#[derive(Deserialize, Default)]
pub(super) struct ListIndexesParams {
#[serde(default)]
pub(super) format: Option<String>,
#[serde(default)]
pub(super) details: bool,
}
pub(super) async fn list_indexes_handler(
State(state): State<Arc<SearchAppState>>,
Query(params): Query<ListIndexesParams>,
) -> Response {
let want_tree = params
.format
.as_deref()
.map(|f| f == "tree")
.unwrap_or(false);
if want_tree {
let handles = state.registry.list_handles();
let entries = crate::core::search::hierarchy::build_tree_entries(&state.registry, &handles);
Json(serde_json::json!({ "indexes": entries })).into_response()
} else if params.details {
let entries: Vec<IndexDetailEntry> = state
.registry
.list_handles()
.into_iter()
.map(|handle| {
let (size_bytes, _) = index_disk_and_mtime(&handle.id.0);
let root_path = handle.root_path.to_str().map(|s| s.to_string());
IndexDetailEntry {
id: handle.id.0.clone(),
root_path,
size_bytes,
}
})
.collect();
Json(serde_json::json!({ "indexes": entries })).into_response()
} else {
Json(IndexListResponse {
indexes: state.registry.list().into_iter().map(|id| id.0).collect(),
})
.into_response()
}
}
pub(super) async fn create_index_handler(
State(state): State<Arc<SearchAppState>>,
Json(mut req): Json<CreateIndexRequest>,
) -> Response {
let id = IndexId::new(req.id.clone());
let canonical_root = match validate_root_path(&req.root_path).await {
Ok(p) => p,
Err(resp) => return resp,
};
req.root_path = canonical_root;
if state.registry.get(&id).is_some() {
return Json(serde_json::json!({
"id": req.id,
"created": false,
"reason": "already exists",
}))
.into_response();
}
let Some(embedder) = state.current_embedder().await else {
if let Some(err) = state.current_embedder_error() {
return embedder_error_response(&err);
}
return embedder_initializing_response();
};
let init_entry = crate::service::persistence::PersistedIndex {
id: req.id.clone(),
root_path: req.root_path.clone(),
colocated: true,
..Default::default()
};
let mut indexer =
match crate::service::persistence_loader::build_indexer_from_entry(&init_entry, &embedder)
.await
{
Ok(idx) => idx,
Err(e) => {
tracing::error!(
"create_index: HNSW allocator failed for '{}': {e} (closes #954)",
req.id
);
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(serde_json::json!({
"error": format!("HNSW allocation failed (OOM): {e}")
})),
)
.into_response();
}
};
let include_paths: Vec<std::path::PathBuf> = req
.include_paths
.clone()
.unwrap_or_default()
.into_iter()
.filter(|p| !p.trim().is_empty() && p.trim() != ".")
.map(|p| req.root_path.join(p.trim()))
.collect();
let exclude_globs: Vec<String> = req.exclude_globs.clone().unwrap_or_default();
let extensions: Vec<String> = req
.extensions
.clone()
.unwrap_or_default()
.into_iter()
.map(|e| e.trim_start_matches('.').to_string())
.filter(|e| !e.is_empty())
.collect();
let domain_terms: Vec<String> = req.domain_terms.clone().unwrap_or_default();
let path_filter: Vec<String> = req
.path_filter
.clone()
.unwrap_or_default()
.into_iter()
.filter(|p| !p.trim().is_empty())
.collect();
indexer.set_domain_terms(domain_terms.clone());
let include_docs: bool = req.include_docs.unwrap_or(true);
let respect_gitignore: bool = req.respect_gitignore.unwrap_or(true);
let lexical_only: bool = req.lexical_only.unwrap_or(false);
let skip_kg: bool = req.skip_kg.unwrap_or_else(|| {
let v = std::env::var("TRUSTY_NO_KG").unwrap_or_default();
matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes")
});
let defer_embed: bool = req.defer_embed.unwrap_or(true);
let extra_skip_dirs: Vec<String> = req
.extra_skip_dirs
.clone()
.unwrap_or_else(crate::service::walker::default_extra_skip_dirs);
let data_file_max_bytes_opt: Option<u64> = req.data_file_max_bytes;
let data_file_max_bytes: u64 =
crate::service::persistence::resolve_data_file_max_bytes(data_file_max_bytes_opt);
let colocated = true;
if let Err(e) = crate::service::roots_registry::upsert_root(req.root_path.clone()) {
tracing::warn!("could not register root in roots.toml for {}: {e}", req.id);
}
if let Err(e) = crate::service::colocated_storage::ensure_gitignored(&req.root_path) {
tracing::warn!(
"could not add .trusty-search/ to .gitignore for {}: {e}",
req.id
);
}
if let Err(e) = crate::service::persistence::upsert_index_registry_entry(
crate::service::persistence::PersistedIndex {
id: req.id.clone(),
root_path: req.root_path.clone(),
include_paths: req.include_paths.clone().unwrap_or_default(),
exclude_globs: exclude_globs.clone(),
extensions: extensions.clone(),
domain_terms: domain_terms.clone(),
path_filter: path_filter.clone(),
include_docs,
respect_gitignore,
extra_skip_dirs: extra_skip_dirs.clone(),
data_file_max_bytes: data_file_max_bytes_opt,
lexical_only,
skip_kg,
defer_embed,
colocated,
last_queried_unix: None,
last_indexed_unix: None,
},
) {
tracing::warn!("could not persist index registry for {}: {e}", req.id);
}
let indexed_head_sha = crate::core::git::head_sha(&req.root_path);
let stages = if lexical_only {
crate::core::registry::IndexStages {
lexical: crate::core::registry::StageState::pending(),
semantic: crate::core::registry::StageState::skipped(),
graph: crate::core::registry::StageState::skipped(),
}
} else if skip_kg {
crate::core::registry::IndexStages {
lexical: crate::core::registry::StageState::pending(),
semantic: crate::core::registry::StageState::pending(),
graph: crate::core::registry::StageState::skipped(),
}
} else {
crate::core::registry::IndexStages::default()
};
let handle = IndexHandle {
id: id.clone(),
indexer: Arc::new(tokio::sync::RwLock::new(indexer)),
root_path: req.root_path,
include_paths,
exclude_globs,
extensions,
domain_terms,
include_docs,
respect_gitignore,
extra_skip_dirs,
data_file_max_bytes,
path_filter,
context_embedding: Arc::new(tokio::sync::RwLock::new(None)),
context_summary: Arc::new(tokio::sync::RwLock::new(None)),
indexed_head_sha: Arc::new(tokio::sync::RwLock::new(indexed_head_sha)),
last_indexed_at: Arc::new(tokio::sync::RwLock::new(None)),
lexical_only,
skip_kg,
defer_embed,
stages: Arc::new(tokio::sync::RwLock::new(stages)),
search_pressure: Arc::new(tokio::sync::Notify::new()),
walk_diagnostics: Arc::new(tokio::sync::RwLock::new(
crate::core::registry::WalkDiagnostics::default(),
)),
};
state.registry.register(handle);
crate::service::metrics::set_index_count(state.registry.list().len());
state.emit(DaemonEvent::IndexRegistered { id: req.id.clone() });
Json(serde_json::json!({ "id": req.id, "created": true })).into_response()
}