1use 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
49const DEFAULT_LIMIT: usize = 20;
51
52const MAX_LIMIT: usize = 1000;
57
58#[derive(Debug, Clone)]
64pub struct AppState {
65 config: Arc<Config>,
66}
67
68impl AppState {
69 pub fn new(config: Arc<Config>) -> Self {
76 AppState { config }
77 }
78
79 fn db_path(&self, index_id: &str) -> PathBuf {
81 self.config.index_db_path(index_id)
82 }
83}
84
85pub 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#[derive(Debug, Default, Deserialize)]
117struct IndexRequest {
118 repo_path: Option<String>,
119 package: Option<String>,
120}
121
122async 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 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#[derive(Debug, Default, Deserialize)]
190struct ClosureQuery {
191 node: Option<String>,
192 expand: Option<String>,
193 limit: Option<String>,
194}
195
196#[derive(Debug)]
198struct ClosureParams {
199 node: String,
200 expand: bool,
201 limit: usize,
202}
203
204#[derive(Debug, Clone, Copy)]
206enum Direction {
207 Downstream,
209 Upstream,
211}
212
213fn 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#[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
279async 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
291async 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
302async 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
321async 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 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#[derive(Debug, Default, Deserialize)]
390struct PathQuery {
391 from: Option<String>,
392 to: Option<String>,
393}
394
395#[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#[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
499const 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
513const 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
526fn 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#[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
586async 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#[derive(Debug, Default, Deserialize)]
628struct NeighborhoodQuery {
629 node: Option<String>,
630 depth: Option<String>,
631}
632
633async 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
667async 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#[derive(Debug, Default, Deserialize)]
688struct SearchQuery {
689 q: Option<String>,
690 limit: Option<String>,
691}
692
693async 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
724async fn schema() -> Json<Value> {
728 Json(describe_schema())
729}
730
731fn 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
739fn 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
745fn 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
758async 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}