Skip to main content

codetopo_server/
handlers.rs

1// Copyright (C) 2026 Nrupal Akolkar
2// SPDX-License-Identifier: AGPL-3.0-or-later
3// Part of codetopo — Owned by Nrupal Akolkar · Built with Muse by Meta.
4
5//! The eight request handlers of the Phase 2 API (design §3).
6//!
7//! This module owns no graph logic. Every route is a transport rename of a
8//! command the CLI already runs: the same `codetopo_core` query answers it, and
9//! the same `codetopo_store` read backs it. That is deliberate — §3.2 makes
10//! CLI/API parity an exit criterion, and shared code is the only way to make
11//! parity true by construction rather than by two implementations that happen
12//! to agree today.
13//!
14//! Three rules shape the code below:
15//!
16//! * **Aggregate first.** A closure response always carries `total`, the full
17//!   size of the answer, before any list. An agent learns the shape of the
18//!   result from one integer and decides whether it can afford the list; a
19//!   response that is only a list forces it to page before it knows the size.
20//! * **Blocking work leaves the async runtime.** `rusqlite` and tree walking
21//!   are synchronous, and §6 requires every db-touching handler to run inside
22//!   `spawn_blocking` so a slow query cannot stall the accept loop.
23//! * **Errors are flat and coded.** Every failure path returns [`ApiError`],
24//!   whose body is `{"error": "<code>", "message": "…"}` (see [`crate::error`]
25//!   for why the body is flat rather than nested).
26
27use std::collections::BTreeMap;
28use std::path::{Path as FsPath, PathBuf};
29use std::sync::Arc;
30
31use axum::extract::{Path as UrlPath, Query, State};
32use axum::routing::{get, post};
33use axum::{Json, Router};
34use serde::{Deserialize, Serialize};
35use serde_json::{json, Value};
36
37use codetopo_cli::{
38    default_package_name, describe_schema, index_repo, load_graph_from_db, render_path,
39    require_node, CliError,
40};
41use codetopo_core::graph::{Graph, PathStep};
42use codetopo_core::schema::{EdgeKind, NodeKind};
43use codetopo_core::snapshot::Snapshot;
44use codetopo_store::Store;
45
46use crate::config::Config;
47use crate::error::ApiError;
48
49/// Default `limit` for closure queries, matching the CLI's `--limit` default.
50const DEFAULT_LIMIT: usize = 20;
51
52/// Upper bound on `limit` (§3.1). A closure over a whole repository can hold
53/// hundreds of thousands of ids, and an unbounded `limit` would let one request
54/// allocate the whole graph's id set — the id list is the response, so the cap
55/// is the only thing between a query and an out-of-memory.
56const MAX_LIMIT: usize = 1000;
57
58/// Shared, immutable server state.
59///
60/// Only the resolved configuration: keys and the metering log are owned by the
61/// auth layer, which is applied around the router rather than inside it, so a
62/// handler physically cannot reach a secret.
63#[derive(Debug, Clone)]
64pub struct AppState {
65    config: Arc<Config>,
66}
67
68impl AppState {
69    /// Wraps a resolved [`Config`] for use as axum state.
70    ///
71    /// Takes the `Arc` the process already holds rather than a `Config` by
72    /// value, so the state and the configuration `main` logs at startup are
73    /// provably the same value and building a router in a test does not require
74    /// cloning one.
75    pub fn new(config: Arc<Config>) -> Self {
76        AppState { config }
77    }
78
79    /// Database path for one index id (§6: `<data_dir>/<id>.db`).
80    fn db_path(&self, index_id: &str) -> PathBuf {
81        self.config.index_db_path(index_id)
82    }
83}
84
85/// Build the unauthenticated route table.
86///
87/// [`crate::auth::metered`] wraps the result; this function only decides which
88/// paths exist, so that auth coverage is a property of the wrapper rather than
89/// something each route has to opt into.
90pub fn router(state: AppState) -> Router {
91    Router::new()
92        .route("/v1/index", post(index))
93        .route("/v1/graphs/:id/descendants", get(descendants))
94        .route("/v1/graphs/:id/ancestors", get(ancestors))
95        .route("/v1/graphs/:id/blast-radius", get(blast_radius))
96        .route("/v1/graphs/:id/path", get(path))
97        .route("/v1/graphs/:id/stats", get(stats))
98        .route("/v1/graphs/:id/verify", get(verify))
99        .route("/v1/graphs/:id/snapshot", get(snapshot))
100        .route("/v1/graphs/:id/neighborhood", get(neighborhood))
101        .route("/v1/graphs/:id/symbol", get(symbol))
102        .route("/v1/graphs/:id/search", get(search))
103        .route("/v1/schema", get(schema))
104        .with_state(state)
105}
106
107// ---------------------------------------------------------------------------
108// POST /v1/index
109// ---------------------------------------------------------------------------
110
111/// Body of `POST /v1/index`.
112///
113/// Every field is optional at the type level so that a *missing* field is
114/// reported as `invalid_repo_path` with a message naming it, rather than as
115/// axum's generic JSON rejection, which arrives before any handler code runs.
116#[derive(Debug, Default, Deserialize)]
117struct IndexRequest {
118    repo_path: Option<String>,
119    package: Option<String>,
120}
121
122// Package name recorded for the index when the request omits one:
123// the shared default every surface uses
124// (`codetopo_cli::default_package_name` — the repository directory's
125// file stem). Before this was unified, the same fixture indexed as
126// `fixture-repo::…` via CLI/MCP but `unknown::…` here, so node ids —
127// and every query keyed on them — disagreed by surface.
128
129/// `POST /v1/index` — index a repository tree into a new index id.
130///
131/// Walks the tree, writes `<data_dir>/<index_id>.db`, and returns the CLI's own
132/// `IndexReport` fields, so API stats and CLI stats come from one computation.
133async fn index(
134    State(state): State<AppState>,
135    Json(request): Json<IndexRequest>,
136) -> Result<Json<Value>, ApiError> {
137    let repo = request
138        .repo_path
139        .as_deref()
140        .map(str::trim)
141        .filter(|raw| !raw.is_empty())
142        .ok_or_else(|| ApiError::invalid_repo_path("repo_path is required"))?;
143    let repo = PathBuf::from(repo);
144    if !repo.is_dir() {
145        return Err(ApiError::invalid_repo_path(&format!(
146            "not a directory: {}",
147            repo.display()
148        )));
149    }
150
151    let package = request
152        .package
153        .as_deref()
154        .map(str::trim)
155        .filter(|raw| !raw.is_empty())
156        .map(str::to_string)
157        .unwrap_or_else(|| default_package_name(&repo));
158
159    // §6: a UUID v4 rather than a repo name, so re-indexing the same tree does
160    // not clobber the previous index and no customer directory layout leaks
161    // into a URL or a log line.
162    let index_id = uuid::Uuid::new_v4().to_string();
163    let db = state.db_path(&index_id);
164
165    let report = tokio::task::spawn_blocking(move || index_repo(&repo, &db, &package, None, None))
166        .await
167        .map_err(|err| ApiError::internal(format!("index task failed: {err}")))?
168        .map_err(|err| ApiError::internal(format!("cannot index repository: {err}")))?;
169
170    Ok(Json(json!({
171        "index_id": index_id,
172        "files_indexed": report.files_indexed,
173        "files_failed": report.files_failed,
174        "nodes": report.nodes,
175        "edges": report.edges,
176        "diagnostics": report.diagnostics,
177    })))
178}
179
180// ---------------------------------------------------------------------------
181// Closure routes: descendants / ancestors / blast-radius
182// ---------------------------------------------------------------------------
183
184/// Raw query string for a closure route.
185///
186/// `expand` and `limit` are read as strings so a bad value is reported as
187/// `invalid_param` naming the parameter, instead of being turned into a
188/// deserialization error before a handler sees it.
189#[derive(Debug, Default, Deserialize)]
190struct ClosureQuery {
191    node: Option<String>,
192    expand: Option<String>,
193    limit: Option<String>,
194}
195
196/// Validated closure parameters.
197#[derive(Debug)]
198struct ClosureParams {
199    node: String,
200    expand: bool,
201    limit: usize,
202}
203
204/// Which direction a closure walks.
205#[derive(Debug, Clone, Copy)]
206enum Direction {
207    /// Outgoing edges: what this node affects.
208    Downstream,
209    /// Incoming edges: what depends on this node.
210    Upstream,
211}
212
213/// Parse `node`, `expand`, and `limit` per §3.1.
214///
215/// Absent and empty are treated alike, so `?limit=` means "use the default"
216/// rather than "reject", which keeps a client that templates optional query
217/// parameters from failing on the empty string.
218fn closure_params(query: &ClosureQuery) -> Result<ClosureParams, ApiError> {
219    let node = query
220        .node
221        .as_deref()
222        .map(str::trim)
223        .filter(|raw| !raw.is_empty())
224        .ok_or_else(|| ApiError::missing_param("node"))?
225        .to_string();
226
227    let expand = match query.expand.as_deref().map(str::trim) {
228        None | Some("") => false,
229        Some("true") => true,
230        Some("false") => false,
231        Some(other) => {
232            return Err(ApiError::invalid_param(
233                "expand",
234                &format!("expected 'true' or 'false', got '{other}'"),
235            ))
236        }
237    };
238
239    let limit = match query.limit.as_deref().map(str::trim) {
240        None | Some("") => DEFAULT_LIMIT,
241        Some(raw) => {
242            let parsed: usize = raw.parse().map_err(|_| {
243                ApiError::invalid_param("limit", &format!("expected an integer, got '{raw}'"))
244            })?;
245            if parsed == 0 || parsed > MAX_LIMIT {
246                return Err(ApiError::invalid_param(
247                    "limit",
248                    &format!("must be 1..={MAX_LIMIT}, got {parsed}"),
249                ));
250            }
251            parsed
252        }
253    };
254
255    Ok(ClosureParams {
256        node,
257        expand,
258        limit,
259    })
260}
261
262/// Aggregate-first closure response.
263///
264/// `total` is the size of the *whole* closure; `nodes` is a possibly-truncated
265/// view of it. `truncated` says which of the two the client got, so a client
266/// never has to infer it by comparing two numbers.
267#[derive(Debug, Serialize)]
268struct ClosureResponse {
269    index_id: String,
270    relation: &'static str,
271    node: String,
272    total: usize,
273    expand: bool,
274    limit: usize,
275    truncated: bool,
276    nodes: Vec<String>,
277}
278
279/// `GET /v1/graphs/{id}/descendants?node=&expand=&limit=`.
280///
281/// What this node affects: the transitive closure of its outgoing call and
282/// import edges.
283async fn descendants(
284    State(state): State<AppState>,
285    UrlPath(index_id): UrlPath<String>,
286    Query(query): Query<ClosureQuery>,
287) -> Result<Json<ClosureResponse>, ApiError> {
288    closure(state, index_id, query, "descendants", Direction::Downstream).await
289}
290
291/// `GET /v1/graphs/{id}/ancestors?node=&expand=&limit=`.
292///
293/// The reverse closure: everything that transitively reaches this node.
294async fn ancestors(
295    State(state): State<AppState>,
296    UrlPath(index_id): UrlPath<String>,
297    Query(query): Query<ClosureQuery>,
298) -> Result<Json<ClosureResponse>, ApiError> {
299    closure(state, index_id, query, "ancestors", Direction::Upstream).await
300}
301
302/// `GET /v1/graphs/{id}/blast-radius?node=&expand=&limit=`.
303///
304/// Breakage propagation: the dependency-side closure a failure of this node
305/// would take with it.
306async fn blast_radius(
307    State(state): State<AppState>,
308    UrlPath(index_id): UrlPath<String>,
309    Query(query): Query<ClosureQuery>,
310) -> Result<Json<ClosureResponse>, ApiError> {
311    closure(
312        state,
313        index_id,
314        query,
315        "blast_radius",
316        Direction::Downstream,
317    )
318    .await
319}
320
321/// Shared body of the three closure routes.
322///
323/// ## Deviation from §3.1
324///
325/// §3.1 says `expand=true` returns "the full id list" and, in the same table,
326/// that `limit` is the "max ids returned when `expand=true`". Those two
327/// statements cannot both hold, and the design gives no tie-breaker. CLI
328/// parity (§3.2) is the tie-breaker used here: `print_id_list` caps the list at
329/// `limit` by default and returns everything under `--expand`. So `limit` caps
330/// the default list and `expand=true` lifts the cap. Nothing is lost either way,
331/// because `total` is always the full size — which is the reason the aggregate
332/// comes first.
333async fn closure(
334    state: AppState,
335    index_id: String,
336    query: ClosureQuery,
337    relation: &'static str,
338    direction: Direction,
339) -> Result<Json<ClosureResponse>, ApiError> {
340    let ClosureParams {
341        node,
342        expand,
343        limit,
344    } = closure_params(&query)?;
345    let db = state.db_path(&index_id);
346    let graph = load_graph_blocking(&db, &index_id)?;
347
348    // The walk owns the graph and a copy of the id: `spawn_blocking` needs a
349    // `'static` closure, so neither can be borrowed from this frame. The
350    // response still reports the caller's `node`, so the copy is the only
351    // duplicate of the id that crosses the boundary.
352    let walk_node = node.clone();
353    let ids = spawn_blocking(move || {
354        Ok(match direction {
355            Direction::Downstream if relation == "blast_radius" => graph.blast_radius(&walk_node),
356            Direction::Downstream => graph.descendants(&walk_node),
357            Direction::Upstream => graph.ancestors(&walk_node),
358        })
359    })
360    .await?;
361
362    let total = ids.len();
363    let truncated = !expand && total > limit;
364    let shown = if expand {
365        ids
366    } else {
367        ids[..total.min(limit)].to_vec()
368    };
369
370    Ok(Json(ClosureResponse {
371        index_id,
372        relation,
373        node,
374        total,
375        expand,
376        limit,
377        truncated,
378        nodes: shown,
379    }))
380}
381
382// ---------------------------------------------------------------------------
383// GET /v1/graphs/{id}/path
384// ---------------------------------------------------------------------------
385
386/// Raw query string for `path`. Read as strings for the same reason as
387/// [`ClosureQuery`]: a missing `from` must be `missing_param`, not a serde
388/// error from the extractor.
389#[derive(Debug, Default, Deserialize)]
390struct PathQuery {
391    from: Option<String>,
392    to: Option<String>,
393}
394
395/// `GET /v1/graphs/{id}/path?from=&to=` — the shortest typed path between two
396/// nodes.
397///
398/// The response is deliberately the *whole* path rather than a capped prefix:
399/// a path is a sequence whose meaning depends on every step, so truncating it
400/// would produce a well-formed but false answer.
401#[derive(Debug, Serialize)]
402struct PathResponse {
403    index_id: String,
404    from: String,
405    to: String,
406    steps: Vec<PathStep>,
407    rendered: String,
408}
409
410async fn path(
411    State(state): State<AppState>,
412    UrlPath(index_id): UrlPath<String>,
413    Query(query): Query<PathQuery>,
414) -> Result<Json<PathResponse>, ApiError> {
415    let from = required_param(query.from.as_deref(), "from")?;
416    let to = required_param(query.to.as_deref(), "to")?;
417    let db = state.db_path(&index_id);
418    let graph = load_graph_blocking(&db, &index_id)?;
419
420    let steps = graph
421        .path(&from, &to)
422        .ok_or_else(|| ApiError::no_path(&from, &to))?;
423    let rendered = render_path(&steps);
424
425    Ok(Json(PathResponse {
426        index_id,
427        from,
428        to,
429        steps,
430        rendered,
431    }))
432}
433
434fn required_param(raw: Option<&str>, name: &str) -> Result<String, ApiError> {
435    raw.map(str::trim)
436        .filter(|value| !value.is_empty())
437        .map(str::to_string)
438        .ok_or_else(|| ApiError::missing_param(name))
439}
440
441// ---------------------------------------------------------------------------
442// GET /v1/graphs/{id}/stats
443// ---------------------------------------------------------------------------
444
445/// Aggregate counts: totals plus per-kind breakdowns.
446///
447/// The breakdown omits zero kinds, matching `print_stats` (§3.2 parity), and
448/// `by_kind` is a map rather than an array so a client can read a single kind
449/// without walking a list to find it.
450#[derive(Debug, Serialize)]
451struct StatsResponse {
452    index_id: String,
453    nodes: KindCounts,
454    edges: KindCounts,
455}
456
457#[derive(Debug, Serialize)]
458struct KindCounts {
459    total: usize,
460    by_kind: BTreeMap<String, usize>,
461}
462
463async fn stats(
464    State(state): State<AppState>,
465    UrlPath(index_id): UrlPath<String>,
466) -> Result<Json<StatsResponse>, ApiError> {
467    let db = state.db_path(&index_id);
468    let graph = load_graph_blocking(&db, &index_id)?;
469
470    let mut node_kinds = BTreeMap::new();
471    for kind in ALL_NODE_KINDS {
472        let count = graph.nodes().filter(|node| node.kind == kind).count();
473        if count > 0 {
474            node_kinds.insert(node_kind_name(kind).to_string(), count);
475        }
476    }
477
478    let mut edge_kinds = BTreeMap::new();
479    for kind in ALL_EDGE_KINDS {
480        let count = graph.edge_count_by_kind(kind);
481        if count > 0 {
482            edge_kinds.insert(kind.as_str().to_string(), count);
483        }
484    }
485
486    Ok(Json(StatsResponse {
487        index_id,
488        nodes: KindCounts {
489            total: graph.node_count(),
490            by_kind: node_kinds,
491        },
492        edges: KindCounts {
493            total: graph.edge_count(),
494            by_kind: edge_kinds,
495        },
496    }))
497}
498
499/// Every node kind, in schema declaration order.
500const ALL_NODE_KINDS: [NodeKind; 10] = [
501    NodeKind::Package,
502    NodeKind::Module,
503    NodeKind::File,
504    NodeKind::Class,
505    NodeKind::Interface,
506    NodeKind::Function,
507    NodeKind::Method,
508    NodeKind::Variable,
509    NodeKind::External,
510    NodeKind::Doc,
511];
512
513/// Every edge kind, in schema declaration order.
514const ALL_EDGE_KINDS: [EdgeKind; 9] = [
515    EdgeKind::Imports,
516    EdgeKind::Calls,
517    EdgeKind::Inherits,
518    EdgeKind::Implements,
519    EdgeKind::Defines,
520    EdgeKind::References,
521    EdgeKind::Contains,
522    EdgeKind::Reads,
523    EdgeKind::Writes,
524];
525
526/// Snake_case wire name of a node kind.
527///
528/// `NodeKind` has no `as_str` of its own (unlike `EdgeKind`), so the names
529/// live here; they are kept in step with the schema's `rename_all =
530/// "snake_case"` serialization, which is what clients see.
531fn node_kind_name(kind: NodeKind) -> &'static str {
532    match kind {
533        NodeKind::Package => "package",
534        NodeKind::Module => "module",
535        NodeKind::File => "file",
536        NodeKind::Class => "class",
537        NodeKind::Interface => "interface",
538        NodeKind::Function => "function",
539        NodeKind::Method => "method",
540        NodeKind::Variable => "variable",
541        NodeKind::External => "external",
542        NodeKind::Doc => "doc",
543    }
544}
545
546// ---------------------------------------------------------------------------
547// GET /v1/graphs/{id}/verify
548// ---------------------------------------------------------------------------
549
550/// Audit-chain verification: entry count, chain validity, and head hash.
551///
552/// The same three facts `verify_db` prints. This is the route an agent calls
553/// before trusting a snapshot it was handed.
554#[derive(Debug, Serialize)]
555struct VerifyResponse {
556    index_id: String,
557    entries: usize,
558    chain_valid: bool,
559    head: String,
560}
561
562async fn verify(
563    State(state): State<AppState>,
564    UrlPath(index_id): UrlPath<String>,
565) -> Result<Json<VerifyResponse>, ApiError> {
566    let db = state.db_path(&index_id);
567    require_index(&db, &index_id)?;
568
569    let (entries, chain_valid, head) = spawn_blocking(move || {
570        let store = Store::open(&db).map_err(|err| ApiError::internal(err.to_string()))?;
571        let audit = store
572            .load_audit()
573            .map_err(|err| ApiError::internal(err.to_string()))?;
574        Ok::<_, ApiError>((audit.len(), audit.verify(), audit.head().to_string()))
575    })
576    .await?;
577
578    Ok(Json(VerifyResponse {
579        index_id,
580        entries,
581        chain_valid,
582        head,
583    }))
584}
585
586// ---------------------------------------------------------------------------
587// GET /v1/graphs/{id}/snapshot
588// ---------------------------------------------------------------------------
589
590/// `GET /v1/graphs/{id}/snapshot` — portable frozen export of the whole graph.
591///
592/// Serialized as the `Snapshot` struct itself rather than wrapped, so the
593/// response is byte-compatible with what `codetopo restore` accepts: a client
594/// can pipe this body straight to a file and restore it with no translation
595/// step. The snapshot binds `audit_head`, so a consumer can tell which audit
596/// chain it came from.
597async fn snapshot(
598    State(state): State<AppState>,
599    UrlPath(index_id): UrlPath<String>,
600) -> Result<Json<Snapshot>, ApiError> {
601    let db = state.db_path(&index_id);
602    require_index(&db, &index_id)?;
603
604    spawn_blocking(move || {
605        let store = Store::open(&db).map_err(|err| ApiError::internal(err.to_string()))?;
606        let graph = store
607            .load_graph()
608            .map_err(|err| ApiError::internal(err.to_string()))?;
609        let audit = store
610            .load_audit()
611            .map_err(|err| ApiError::internal(err.to_string()))?;
612        Ok::<_, ApiError>(Snapshot::from_graph(&graph, &audit.snapshot_entries()))
613    })
614    .await
615    .map(Json)
616}
617
618// ---------------------------------------------------------------------------
619// shared helpers
620// ---------------------------------------------------------------------------
621
622// ---------------------------------------------------------------------------
623// GET /v1/graphs/{id}/neighborhood, /symbol, /search; GET /v1/schema
624// ---------------------------------------------------------------------------
625
626/// Query for `neighborhood`: the shared closure params plus `depth`.
627#[derive(Debug, Default, Deserialize)]
628struct NeighborhoodQuery {
629    node: Option<String>,
630    depth: Option<String>,
631}
632
633/// `GET /v1/graphs/{id}/neighborhood?node=&depth=` — the subgraph
634/// within `depth` hops of a node, both directions (CODE_SCHEMA §6's
635/// neighborhood query; the CLI and MCP serve the same computation).
636async fn neighborhood(
637    State(state): State<AppState>,
638    UrlPath(index_id): UrlPath<String>,
639    Query(query): Query<NeighborhoodQuery>,
640) -> Result<Json<Value>, ApiError> {
641    let node = query
642        .node
643        .as_deref()
644        .map(str::trim)
645        .filter(|raw| !raw.is_empty())
646        .ok_or_else(|| ApiError::missing_param("node"))?
647        .to_string();
648    let depth: u32 = match query.depth.as_deref().map(str::trim) {
649        None | Some("") => 1,
650        Some(raw) => raw.parse().map_err(|_| {
651            ApiError::invalid_param("depth", &format!("expected a non-negative integer, got '{raw}'"))
652        })?,
653    };
654    let db = state.db_path(&index_id);
655    let graph = load_graph_blocking(&db, &index_id)?;
656    require_node(&graph, &node).map_err(|e| map_cli_error(&index_id, e))?;
657    let sub = graph.neighborhood(&node, depth);
658    Ok(Json(json!({
659        "index_id": index_id,
660        "node": node,
661        "depth": depth,
662        "nodes": sub.nodes,
663        "edges": sub.edges,
664    })))
665}
666
667/// `GET /v1/graphs/{id}/symbol?node=` — one node in full.
668async fn symbol(
669    State(state): State<AppState>,
670    UrlPath(index_id): UrlPath<String>,
671    Query(query): Query<ClosureQuery>,
672) -> Result<Json<Value>, ApiError> {
673    let node_id = query
674        .node
675        .as_deref()
676        .map(str::trim)
677        .filter(|raw| !raw.is_empty())
678        .ok_or_else(|| ApiError::missing_param("node"))?
679        .to_string();
680    let db = state.db_path(&index_id);
681    let graph = load_graph_blocking(&db, &index_id)?;
682    let node = require_node(&graph, &node_id).map_err(|e| map_cli_error(&index_id, e))?;
683    Ok(Json(json!({"index_id": index_id, "node": node})))
684}
685
686/// Query for `search`.
687#[derive(Debug, Default, Deserialize)]
688struct SearchQuery {
689    q: Option<String>,
690    limit: Option<String>,
691}
692
693/// `GET /v1/graphs/{id}/search?q=&limit=` — name discovery: the step
694/// the exact-id queries always assumed a caller had already done.
695async fn search(
696    State(state): State<AppState>,
697    UrlPath(index_id): UrlPath<String>,
698    Query(query): Query<SearchQuery>,
699) -> Result<Json<Value>, ApiError> {
700    let q = query
701        .q
702        .as_deref()
703        .map(str::trim)
704        .filter(|raw| !raw.is_empty())
705        .ok_or_else(|| ApiError::missing_param("q"))?
706        .to_string();
707    let limit: usize = match query.limit.as_deref().map(str::trim) {
708        None | Some("") => 20,
709        Some(raw) => raw.parse().map_err(|_| {
710            ApiError::invalid_param("limit", &format!("expected a positive integer, got '{raw}'"))
711        })?,
712    };
713    let db = state.db_path(&index_id);
714    let graph = load_graph_blocking(&db, &index_id)?;
715    let hits = graph.search_symbols(&q, limit);
716    Ok(Json(json!({
717        "index_id": index_id,
718        "query": q,
719        "total": hits.len(),
720        "results": hits,
721    })))
722}
723
724/// `GET /v1/schema` — the schema description, identical to the CLI's
725/// `schema` command and the MCP `codetopo_describe_schema` tool
726/// (one generator in the CLI library serves all three).
727async fn schema() -> Json<Value> {
728    Json(describe_schema())
729}
730
731/// Open an index database, mapping "no such file" to `unknown_index`.
732fn require_index(db: &FsPath, index_id: &str) -> Result<(), ApiError> {
733    if !db.is_file() {
734        return Err(ApiError::unknown_index(index_id));
735    }
736    Ok(())
737}
738
739/// Load a graph off the runtime, mapping store and id failures to API errors.
740fn load_graph_blocking(db: &FsPath, index_id: &str) -> Result<Graph, ApiError> {
741    require_index(db, index_id)?;
742    load_graph_from_db(db).map_err(|err| map_cli_error(index_id, err))
743}
744
745/// Translate a CLI-layer error into the API's error vocabulary.
746///
747/// Only `UnknownId` is a client mistake: the queried node is not in the graph,
748/// which is the same `404 unknown_node` the CLI signals with exit code 2.
749/// Everything else is the server's problem — a store or io failure behind a
750/// request that was otherwise well-formed.
751fn map_cli_error(index_id: &str, err: CliError) -> ApiError {
752    match err {
753        CliError::UnknownId(id) => ApiError::unknown_node(&id),
754        other => ApiError::internal(format!("index {index_id}: {other}")),
755    }
756}
757
758/// Run synchronous work on the blocking pool and propagate a join failure.
759///
760/// A `JoinError` means the worker panicked or was cancelled; there is no
761/// request left to answer, so it is reported as `internal` rather than
762/// silently swallowed.
763async fn spawn_blocking<T, F>(work: F) -> Result<T, ApiError>
764where
765    F: FnOnce() -> Result<T, ApiError> + Send + 'static,
766    T: Send + 'static,
767{
768    tokio::task::spawn_blocking(work)
769        .await
770        .map_err(|err| ApiError::internal(format!("worker task failed: {err}")))?
771}