use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::core::registry::{IndexHandle, IndexId};
use super::state::{DaemonEvent, SearchAppState};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IndexConfigView {
pub extra_skip_dirs: Vec<String>,
pub data_file_max_bytes: u64,
pub extensions: Vec<String>,
pub exclude_globs: Vec<String>,
pub include_docs: bool,
pub respect_gitignore: bool,
}
impl IndexConfigView {
fn from_handle(handle: &IndexHandle) -> Self {
Self {
extra_skip_dirs: handle.extra_skip_dirs.clone(),
data_file_max_bytes: handle.data_file_max_bytes,
extensions: handle.extensions.clone(),
exclude_globs: handle.exclude_globs.clone(),
include_docs: handle.include_docs,
respect_gitignore: handle.respect_gitignore,
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PatchIndexConfigRequest {
#[serde(default)]
pub extra_skip_dirs: Option<Vec<String>>,
#[serde(default)]
pub data_file_max_bytes: Option<u64>,
#[serde(default)]
pub extensions: Option<Vec<String>>,
#[serde(default)]
pub exclude_globs: Option<Vec<String>>,
#[serde(default)]
pub include_docs: Option<bool>,
#[serde(default)]
pub respect_gitignore: Option<bool>,
}
pub(super) async fn index_config_handler(
State(state): State<Arc<SearchAppState>>,
Path(id): Path<String>,
) -> Response {
let index_id = IndexId::new(id);
let Some(handle) = state.registry.get(&index_id) else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": format!("unknown index '{}'", index_id.0) })),
)
.into_response();
};
Json(IndexConfigView::from_handle(&handle)).into_response()
}
pub(super) async fn patch_index_config_handler(
State(state): State<Arc<SearchAppState>>,
Path(id): Path<String>,
Json(req): Json<PatchIndexConfigRequest>,
) -> Response {
let index_id = IndexId::new(id);
if matches!(req.data_file_max_bytes, Some(0)) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "data_file_max_bytes must be greater than zero"
})),
)
.into_response();
}
let new_handle = {
let Some(existing) = state.registry.get(&index_id) else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": format!("unknown index '{}'", index_id.0) })),
)
.into_response();
};
let extra_skip_dirs = req
.extra_skip_dirs
.map(sanitize_dirs)
.unwrap_or_else(|| existing.extra_skip_dirs.clone());
let data_file_max_bytes = req
.data_file_max_bytes
.unwrap_or(existing.data_file_max_bytes);
let extensions = req
.extensions
.map(sanitize_extensions)
.unwrap_or_else(|| existing.extensions.clone());
let exclude_globs = req
.exclude_globs
.map(sanitize_dirs)
.unwrap_or_else(|| existing.exclude_globs.clone());
let include_docs = req.include_docs.unwrap_or(existing.include_docs);
let respect_gitignore = req.respect_gitignore.unwrap_or(existing.respect_gitignore);
IndexHandle {
id: index_id.clone(),
indexer: Arc::clone(&existing.indexer),
root_path: existing.root_path.clone(),
include_paths: existing.include_paths.clone(),
exclude_globs,
extensions,
domain_terms: existing.domain_terms.clone(),
include_docs,
respect_gitignore,
extra_skip_dirs,
data_file_max_bytes,
path_filter: existing.path_filter.clone(),
context_embedding: Arc::clone(&existing.context_embedding),
context_summary: Arc::clone(&existing.context_summary),
indexed_head_sha: Arc::clone(&existing.indexed_head_sha),
last_indexed_at: Arc::clone(&existing.last_indexed_at),
lexical_only: existing.lexical_only,
skip_kg: existing.skip_kg,
defer_embed: existing.defer_embed,
stages: Arc::clone(&existing.stages),
search_pressure: Arc::clone(&existing.search_pressure),
walk_diagnostics: Arc::clone(&existing.walk_diagnostics),
}
};
let view = IndexConfigView::from_handle(&new_handle);
let root_path = new_handle.root_path.clone();
let extra_skip_dirs = new_handle.extra_skip_dirs.clone();
let data_file_max_bytes = new_handle.data_file_max_bytes;
let extensions = new_handle.extensions.clone();
let exclude_globs = new_handle.exclude_globs.clone();
let include_docs = new_handle.include_docs;
let respect_gitignore = new_handle.respect_gitignore;
state.registry.register(new_handle);
if let Err(e) = persist_hygiene_update(
&index_id.0,
&root_path,
&extra_skip_dirs,
data_file_max_bytes,
&extensions,
&exclude_globs,
include_docs,
respect_gitignore,
) {
tracing::error!(
"patch_index_config[{}]: persistence failed: {e}",
index_id.0
);
state.emit(DaemonEvent::IndexRegistered {
id: index_id.0.clone(),
});
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": format!(
"config applied in memory but could not be persisted to indexes.toml: {e}. \
The change will be lost on the next daemon restart."
),
"config": view,
"persisted": false,
})),
)
.into_response();
}
state.emit(DaemonEvent::IndexRegistered {
id: index_id.0.clone(),
});
Json(serde_json::json!({
"id": index_id.0,
"config": view,
"reindex_required": true,
"persisted": true,
}))
.into_response()
}
fn sanitize_dirs(v: Vec<String>) -> Vec<String> {
v.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn sanitize_extensions(v: Vec<String>) -> Vec<String> {
v.into_iter()
.map(|e| e.trim().trim_start_matches('.').to_string())
.filter(|e| !e.is_empty())
.collect()
}
#[allow(clippy::too_many_arguments)]
fn persist_hygiene_update(
id: &str,
root_path: &std::path::Path,
extra_skip_dirs: &[String],
data_file_max_bytes: u64,
extensions: &[String],
exclude_globs: &[String],
include_docs: bool,
respect_gitignore: bool,
) -> anyhow::Result<()> {
use crate::service::persistence::{
load_index_registry, upsert_index_registry_entry, PersistedIndex,
};
let mut entry = match load_index_registry() {
Ok(entries) => entries
.into_iter()
.find(|e| e.id == id)
.unwrap_or_else(|| PersistedIndex {
id: id.to_string(),
root_path: root_path.to_path_buf(),
..Default::default()
}),
Err(e) => {
tracing::warn!("patch_index_config[{id}]: could not load registry to persist: {e}");
PersistedIndex {
id: id.to_string(),
root_path: root_path.to_path_buf(),
..Default::default()
}
}
};
entry.extra_skip_dirs = extra_skip_dirs.to_vec();
entry.data_file_max_bytes = Some(data_file_max_bytes);
entry.extensions = extensions.to_vec();
entry.exclude_globs = exclude_globs.to_vec();
entry.include_docs = include_docs;
entry.respect_gitignore = respect_gitignore;
upsert_index_registry_entry(entry)
}