use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use serde::Deserialize;
use serde_json::Value;
use sqry_core::watch::{ChangeSet, GitChangeClass};
use crate::error::DaemonError;
use crate::workspace::WorkspaceState;
use super::super::path_policy::resolve_index_root;
use super::super::protocol::{RebuildResult, ResponseEnvelope, ResponseMeta};
use super::{HandlerContext, MethodError};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RebuildParams {
pub path: std::path::PathBuf,
#[serde(default)]
pub force: bool,
}
pub(crate) async fn handle(ctx: &HandlerContext, params: Value) -> Result<Value, MethodError> {
let params: RebuildParams =
serde_json::from_value(params).map_err(MethodError::InvalidParams)?;
let canonical = resolve_index_root(¶ms.path)?;
let (key, ws) = ctx
.manager
.find_key_and_workspace_by_path(&canonical)
.ok_or_else(|| {
MethodError::Daemon(DaemonError::WorkspaceNotLoaded {
root: canonical.clone(),
})
})?;
let current_state = ws.load_state();
if !current_state.is_serving() {
return Err(MethodError::Daemon(DaemonError::WorkspaceNotLoaded {
root: canonical.clone(),
}));
}
let changes = if params.force {
ChangeSet {
changed_files: vec![],
git_state_changed: true,
git_change_class: Some(GitChangeClass::TreeDiverged),
}
} else {
ChangeSet {
changed_files: vec![],
git_state_changed: false,
git_change_class: None,
}
};
let started = Instant::now();
ctx.dispatcher
.handle_changes(&key, changes)
.await
.map_err(MethodError::Daemon)?;
const POLL_INTERVAL: Duration = Duration::from_millis(200);
const POLL_TIMEOUT: Duration = Duration::from_secs(600);
while ws.rebuild_in_flight.load(Ordering::Acquire) {
if started.elapsed() > POLL_TIMEOUT {
break;
}
tokio::time::sleep(POLL_INTERVAL).await;
}
let duration_ms = started.elapsed().as_millis() as u64;
let graph = ws.graph.load();
let nodes = graph.node_count() as u64;
let edges = graph.edge_count() as u64;
let files_indexed = graph.files().len() as u64;
let envelope = ResponseEnvelope {
result: RebuildResult {
root: canonical,
duration_ms,
nodes,
edges,
files_indexed,
was_full: params.force,
},
meta: ResponseMeta::fresh_from(WorkspaceState::Loaded, ctx.daemon_version),
};
serde_json::to_value(&envelope)
.map_err(|e| MethodError::Internal(anyhow::anyhow!("serialise daemon/rebuild: {e}")))
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::Arc;
use serde_json::json;
use crate::config::DaemonConfig;
use crate::error::DaemonError;
use crate::ipc::methods::{HandlerContext, MethodError, daemon_rebuild};
use crate::ipc::shim_registry::ShimRegistry;
use crate::workspace::{EmptyGraphBuilder, WorkspaceManager};
use crate::{JSONRPC_WORKSPACE_EVICTED, RebuildDispatcher};
use sqry_core::plugin::PluginManager;
use tokio_util::sync::CancellationToken;
fn make_config() -> Arc<DaemonConfig> {
Arc::new(DaemonConfig::default())
}
fn make_ctx(manager: Arc<WorkspaceManager>) -> HandlerContext {
let config = make_config();
let plugins = Arc::new(PluginManager::default());
let dispatcher = RebuildDispatcher::new(Arc::clone(&manager), Arc::clone(&config), plugins);
let executor = Arc::new(sqry_core::query::executor::QueryExecutor::default());
HandlerContext {
manager,
dispatcher,
workspace_builder: Arc::new(EmptyGraphBuilder),
tool_executor: executor,
shim_registry: ShimRegistry::new(),
shutdown: CancellationToken::new(),
config,
daemon_version: "test",
}
}
#[tokio::test]
async fn unloaded_workspace_returns_workspace_not_loaded() {
let manager = WorkspaceManager::new_without_reaper(make_config());
let ctx = make_ctx(manager);
let params = json!({ "path": "/nonexistent/workspace" });
let result = daemon_rebuild::handle(&ctx, params).await;
match result {
Err(MethodError::Daemon(DaemonError::WorkspaceNotLoaded { root })) => {
let _ = root;
}
Err(MethodError::InvalidParams(_)) => {
}
other => panic!("expected WorkspaceNotLoaded or InvalidParams, got {other:?}"),
}
}
#[test]
fn rebuild_params_force_defaults_to_false() {
let params: daemon_rebuild::RebuildParams =
serde_json::from_value(json!({ "path": "/some/path" })).unwrap();
assert!(!params.force, "force must default to false");
assert_eq!(params.path, PathBuf::from("/some/path"));
}
#[test]
fn rebuild_params_force_true_parses() {
let params: daemon_rebuild::RebuildParams =
serde_json::from_value(json!({ "path": "/some/path", "force": true })).unwrap();
assert!(params.force);
}
#[test]
fn rebuild_params_rejects_unknown_fields() {
let err = serde_json::from_value::<daemon_rebuild::RebuildParams>(
json!({ "path": "/p", "extra": true }),
)
.expect_err("unknown fields must be rejected");
assert!(
err.to_string().contains("unknown field"),
"expected 'unknown field' in error: {err}"
);
}
#[test]
fn workspace_not_loaded_has_code_minus_32004() {
let err = DaemonError::WorkspaceNotLoaded {
root: PathBuf::from("/repo"),
};
assert_eq!(
err.jsonrpc_code(),
Some(JSONRPC_WORKSPACE_EVICTED),
"WorkspaceNotLoaded must map to -32004"
);
let data = err.error_data().expect("must emit structured data");
assert!(
data.get("root").is_some(),
"error_data must include root: {data}"
);
assert!(
data.get("hint").is_some(),
"error_data must include hint: {data}"
);
}
}