1use std::collections::BTreeMap;
30use std::path::{Path as FsPath, PathBuf};
31use std::sync::Arc;
32
33use axum::extract::{Extension, Path as UrlPath, Query, Request, State};
34use axum::http::header::CONTENT_TYPE;
35use axum::routing::{get, post};
36use axum::{Json, Router};
37use serde::{Deserialize, Serialize};
38use serde_json::{json, Value};
39
40use codetopo_cli::{
41 default_package_name, describe_schema, index_repo, load_graph_from_db, render_path,
42 require_node, CliError,
43};
44use codetopo_core::graph::{Graph, PathStep};
45use codetopo_core::schema::{EdgeKind, NodeKind};
46use codetopo_core::snapshot::Snapshot;
47use codetopo_store::Store;
48
49use crate::auth::{KeyId, KeyPlan};
50use crate::config::Config;
51use crate::error::ApiError;
52use crate::ingest;
53use crate::keys::now_unix_secs;
54use crate::limits::{plan_limits, Envelope};
55use crate::tenancy::Tenancy;
56
57const DEFAULT_LIMIT: usize = 20;
59
60const MAX_LIMIT: usize = 1000;
65
66#[derive(Debug, Clone)]
80pub struct AppState {
81 config: Arc<Config>,
82 keys: Arc<crate::keys::KeyStore>,
83 tenancy: Arc<Tenancy>,
84 envelope: Arc<Envelope>,
85}
86
87impl AppState {
88 pub fn new(
96 config: Arc<Config>,
97 keys: Arc<crate::keys::KeyStore>,
98 tenancy: Arc<Tenancy>,
99 ) -> Self {
100 AppState {
101 config,
102 keys,
103 tenancy,
104 envelope: Arc::new(Envelope::new()),
105 }
106 }
107
108 fn db_path(&self, index_id: &str) -> PathBuf {
110 self.config.index_db_path(index_id)
111 }
112}
113
114fn enforce_tenancy(state: &AppState, index_id: &str, key: &KeyId) -> Result<(), ApiError> {
131 let Some(record) = state.tenancy.lookup(index_id)? else {
132 if crate::tenancy::ownerless_readable(
133 state.config.deny_ownerless_indexes,
134 state.keys.active_len(),
135 ) {
136 return Ok(());
137 }
138 return Err(ApiError::unknown_index(index_id));
139 };
140 if record.is_expired(now_unix_secs()) {
141 state.tenancy.remove(index_id)?;
142 state.config.remove_index_files(index_id);
145 return Err(ApiError::unknown_index(index_id));
146 }
147 if record.owner_key_id != key.as_str() {
148 return Err(ApiError::unknown_index(index_id));
149 }
150 Ok(())
151}
152
153pub fn router(state: AppState) -> Router {
162 let envelope = state.envelope.clone();
168 let queries = Router::new()
169 .route("/v1/graphs/:id/descendants", get(descendants))
170 .route("/v1/graphs/:id/ancestors", get(ancestors))
171 .route("/v1/graphs/:id/blast-radius", get(blast_radius))
172 .route("/v1/graphs/:id/path", get(path))
173 .route("/v1/graphs/:id/stats", get(stats))
174 .route("/v1/graphs/:id/verify", get(verify))
175 .route("/v1/graphs/:id/snapshot", get(snapshot))
176 .route("/v1/graphs/:id/neighborhood", get(neighborhood))
177 .route("/v1/graphs/:id/symbol", get(symbol))
178 .route("/v1/graphs/:id/search", get(search))
179 .route("/v1/graphs/:id/diagnostics", get(diagnostics))
180 .route("/v1/graphs/:id/rank", get(rank))
181 .route("/v1/graphs/:id/diff", get(diff))
182 .route("/v1/graphs/:id/metrics", get(metrics))
183 .route("/v1/graphs/:id/history", get(history))
184 .route("/v1/schema", get(schema))
185 .route_layer(axum::middleware::from_fn_with_state(
186 envelope,
187 crate::limits::query_envelope,
188 ));
189 Router::new()
190 .route("/v1/index", post(index))
191 .merge(queries)
192 .with_state(state)
193}
194
195#[derive(Debug, Default, Deserialize)]
208struct IndexRequest {
209 repo_path: Option<String>,
210 repo_url: Option<String>,
211 package: Option<String>,
212}
213
214#[derive(Debug, Default, Deserialize)]
218struct IndexQuery {
219 package: Option<String>,
220}
221
222const JSON_BODY_LIMIT: usize = 2 * 1024 * 1024;
233
234fn non_empty(field: Option<String>) -> Option<String> {
237 field
238 .map(|raw| raw.trim().to_string())
239 .filter(|raw| !raw.is_empty())
240}
241
242async fn read_body(request: Request, limit: usize, failure: &str) -> Result<axum::body::Bytes, ApiError> {
245 axum::body::to_bytes(request.into_body(), limit)
246 .await
247 .map_err(|_| ApiError::invalid_body(failure))
248}
249
250async fn index(
294 State(state): State<AppState>,
295 Extension(key): Extension<KeyId>,
296 Extension(plan): Extension<KeyPlan>,
297 Query(query): Query<IndexQuery>,
298 request: Request,
299) -> Result<Json<Value>, ApiError> {
300 let content_type = request
301 .headers()
302 .get(CONTENT_TYPE)
303 .and_then(|value| value.to_str().ok())
304 .map(|value| {
305 value
306 .split(';')
307 .next()
308 .unwrap_or("")
309 .trim()
310 .to_ascii_lowercase()
311 })
312 .filter(|value| !value.is_empty());
313
314 let index_id = uuid::Uuid::new_v4().to_string();
318 let db = state.db_path(&index_id);
319
320 let limits = plan_limits(plan.as_str());
324 let _index_permit = match limits.max_concurrent_index_ops {
325 Some(max_ops) => match state.envelope.try_acquire_index_op(key.as_str(), max_ops) {
326 Some(permit) => Some(permit),
327 None => return Err(ApiError::index_concurrency(plan.as_str(), max_ops)),
328 },
329 None => None,
330 };
331 if let Some(max_active) = limits.max_active_indexes {
332 let current = state
333 .tenancy
334 .count_active_for_owner(key.as_str(), now_unix_secs())?;
335 if current >= max_active {
336 return Err(ApiError::index_quota(plan.as_str(), max_active, current));
337 }
338 }
339 let caps = limits.ingest;
340
341 let (repo, package, workdir) = if content_type.as_deref() == Some("application/json") {
345 let bytes = read_body(
346 request,
347 JSON_BODY_LIMIT,
348 "request body could not be read (2 MiB limit)",
349 )
350 .await?;
351 let body: IndexRequest = serde_json::from_slice(&bytes).map_err(|err| {
352 ApiError::invalid_body(&format!("not valid JSON for this route: {err}"))
353 })?;
354 match (non_empty(body.repo_path), non_empty(body.repo_url)) {
355 (Some(_), Some(_)) => {
356 return Err(ApiError::invalid_body(
357 "repo_path and repo_url are alternatives; send exactly one",
358 ));
359 }
360 (None, None) => {
361 return Err(ApiError::invalid_repo_path(
362 "one of repo_path or repo_url is required",
363 ));
364 }
365 (Some(raw_path), None) => {
366 if !plan.is_self_hosted() {
367 return Err(ApiError::invalid_repo_path(
368 "repo_path names a path on the server host and is available \
369 only to self-hosted keys; use repo_url or an archive upload",
370 ));
371 }
372 let repo = PathBuf::from(raw_path);
373 if !repo.is_dir() {
374 return Err(ApiError::invalid_repo_path(&format!(
375 "not a directory: {}",
376 repo.display()
377 )));
378 }
379 let package =
380 non_empty(body.package).unwrap_or_else(|| default_package_name(&repo));
381 (repo, package, None)
382 }
383 (None, Some(raw_url)) => {
384 let validated = ingest::validate_repo_url(&raw_url).await?;
385 let workdir = state.config.index_workdir_path(&index_id);
386 let clone_dir = workdir.clone();
387 let clone_validated = validated.clone();
388 tokio::task::spawn_blocking(move || {
389 ingest::clone_into(&clone_validated, &clone_dir, caps)
390 })
391 .await
392 .map_err(|err| ApiError::internal(format!("clone task failed: {err}")))??;
393 let package =
394 non_empty(body.package).unwrap_or_else(|| validated.package_name());
395 (workdir.clone(), package, Some(workdir))
396 }
397 }
398 } else {
399 let bytes = read_body(
400 request,
401 caps.max_upload_bytes,
402 "archive body could not be read or exceeds the upload limit",
403 )
404 .await?;
405 let kind = ingest::archive_kind(content_type.as_deref(), &bytes).ok_or_else(|| {
406 ApiError::invalid_body(
407 "unrecognized archive body: send a .zip or .tar.gz archive",
408 )
409 })?;
410 let workdir = state.config.index_workdir_path(&index_id);
411 let extract_dir = workdir.clone();
412 let root = tokio::task::spawn_blocking(move || {
413 ingest::extract_into(kind, &bytes, &extract_dir, caps)
414 })
415 .await
416 .map_err(|err| ApiError::internal(format!("extract task failed: {err}")))??;
417 let package = non_empty(query.package).unwrap_or_else(|| {
418 if root != workdir {
419 default_package_name(&root)
420 } else {
421 "repo".to_string()
422 }
423 });
424 (root, package, Some(workdir))
425 };
426
427 if let Some(max_files) = limits.max_files_per_index {
434 let count_root = repo.clone();
435 let counted = tokio::task::spawn_blocking(move || {
436 ingest::count_tree_files(&count_root, max_files as u64 + 1)
437 })
438 .await
439 .map_err(|err| ApiError::internal(format!("file count task failed: {err}")))?;
440 if counted > max_files as u64 {
441 if let Some(workdir) = &workdir {
442 let _ = std::fs::remove_dir_all(workdir);
443 }
444 return Err(ApiError::file_quota(plan.as_str(), max_files));
445 }
446 }
447
448 let report = tokio::task::spawn_blocking(move || index_repo(&repo, &db, &package, None, None))
449 .await
450 .map_err(|err| ApiError::internal(format!("index task failed: {err}")))?;
451 let report = match report {
452 Ok(report) => report,
453 Err(err) => {
454 let _ = std::fs::remove_file(state.db_path(&index_id));
458 if let Some(workdir) = &workdir {
459 let _ = std::fs::remove_dir_all(workdir);
460 }
461 return Err(ApiError::internal(format!("cannot index repository: {err}")));
462 }
463 };
464
465 let created_at = now_unix_secs();
466 let expires_at = state
467 .config
468 .index_ttl_secs
469 .map(|ttl| created_at + ttl as i64);
470 if let Err(err) = state
471 .tenancy
472 .record(&index_id, key.as_str(), created_at, expires_at)
473 {
474 let _ = std::fs::remove_file(state.db_path(&index_id));
475 if let Some(workdir) = &workdir {
476 let _ = std::fs::remove_dir_all(workdir);
477 }
478 return Err(err);
479 }
480
481 Ok(Json(json!({
482 "index_id": index_id,
483 "files_indexed": report.files_indexed,
484 "files_failed": report.files_failed,
485 "nodes": report.nodes,
486 "edges": report.edges,
487 "diagnostics": report.diagnostics,
488 })))
489}
490
491#[derive(Debug, Default, Deserialize)]
501struct ClosureQuery {
502 node: Option<String>,
503 expand: Option<String>,
504 limit: Option<String>,
505}
506
507#[derive(Debug)]
509struct ClosureParams {
510 node: String,
511 expand: bool,
512 limit: usize,
513}
514
515#[derive(Debug, Clone, Copy)]
517enum Direction {
518 Downstream,
520 Upstream,
522}
523
524fn closure_params(query: &ClosureQuery) -> Result<ClosureParams, ApiError> {
530 let node = query
531 .node
532 .as_deref()
533 .map(str::trim)
534 .filter(|raw| !raw.is_empty())
535 .ok_or_else(|| ApiError::missing_param("node"))?
536 .to_string();
537
538 let expand = match query.expand.as_deref().map(str::trim) {
539 None | Some("") => false,
540 Some("true") => true,
541 Some("false") => false,
542 Some(other) => {
543 return Err(ApiError::invalid_param(
544 "expand",
545 &format!("expected 'true' or 'false', got '{other}'"),
546 ))
547 }
548 };
549
550 let limit = match query.limit.as_deref().map(str::trim) {
551 None | Some("") => DEFAULT_LIMIT,
552 Some(raw) => {
553 let parsed: usize = raw.parse().map_err(|_| {
554 ApiError::invalid_param("limit", &format!("expected an integer, got '{raw}'"))
555 })?;
556 if parsed == 0 || parsed > MAX_LIMIT {
557 return Err(ApiError::invalid_param(
558 "limit",
559 &format!("must be 1..={MAX_LIMIT}, got {parsed}"),
560 ));
561 }
562 parsed
563 }
564 };
565
566 Ok(ClosureParams {
567 node,
568 expand,
569 limit,
570 })
571}
572
573#[derive(Debug, Serialize)]
579struct ClosureResponse {
580 index_id: String,
581 relation: &'static str,
582 node: String,
583 total: usize,
584 expand: bool,
585 limit: usize,
586 truncated: bool,
587 nodes: Vec<String>,
588}
589
590async fn descendants(
595 State(state): State<AppState>,
596 Extension(key): Extension<KeyId>,
597 UrlPath(index_id): UrlPath<String>,
598 Query(query): Query<ClosureQuery>,
599) -> Result<Json<ClosureResponse>, ApiError> {
600 closure(state, &key, index_id, query, "descendants", Direction::Downstream).await
601}
602
603async fn ancestors(
607 State(state): State<AppState>,
608 Extension(key): Extension<KeyId>,
609 UrlPath(index_id): UrlPath<String>,
610 Query(query): Query<ClosureQuery>,
611) -> Result<Json<ClosureResponse>, ApiError> {
612 closure(state, &key, index_id, query, "ancestors", Direction::Upstream).await
613}
614
615async fn blast_radius(
620 State(state): State<AppState>,
621 Extension(key): Extension<KeyId>,
622 UrlPath(index_id): UrlPath<String>,
623 Query(query): Query<ClosureQuery>,
624) -> Result<Json<ClosureResponse>, ApiError> {
625 closure(
626 state,
627 &key,
628 index_id,
629 query,
630 "blast_radius",
631 Direction::Downstream,
632 )
633 .await
634}
635
636async fn closure(
649 state: AppState,
650 key: &KeyId,
651 index_id: String,
652 query: ClosureQuery,
653 relation: &'static str,
654 direction: Direction,
655) -> Result<Json<ClosureResponse>, ApiError> {
656 enforce_tenancy(&state, &index_id, key)?;
657 let ClosureParams {
658 node,
659 expand,
660 limit,
661 } = closure_params(&query)?;
662 let db = state.db_path(&index_id);
663 let graph = load_graph_blocking(&db, &index_id)?;
664
665 let walk_node = node.clone();
670 let ids = spawn_blocking(move || {
671 Ok(match direction {
672 Direction::Downstream if relation == "blast_radius" => graph.blast_radius(&walk_node),
673 Direction::Downstream => graph.descendants(&walk_node),
674 Direction::Upstream => graph.ancestors(&walk_node),
675 })
676 })
677 .await?;
678
679 let total = ids.len();
680 let truncated = !expand && total > limit;
681 let shown = if expand {
682 ids
683 } else {
684 ids[..total.min(limit)].to_vec()
685 };
686
687 Ok(Json(ClosureResponse {
688 index_id,
689 relation,
690 node,
691 total,
692 expand,
693 limit,
694 truncated,
695 nodes: shown,
696 }))
697}
698
699#[derive(Debug, Default, Deserialize)]
707struct PathQuery {
708 from: Option<String>,
709 to: Option<String>,
710}
711
712#[derive(Debug, Serialize)]
719struct PathResponse {
720 index_id: String,
721 from: String,
722 to: String,
723 steps: Vec<PathStep>,
724 rendered: String,
725}
726
727async fn path(
728 State(state): State<AppState>,
729 Extension(key): Extension<KeyId>,
730 UrlPath(index_id): UrlPath<String>,
731 Query(query): Query<PathQuery>,
732) -> Result<Json<PathResponse>, ApiError> {
733 enforce_tenancy(&state, &index_id, &key)?;
734 let from = required_param(query.from.as_deref(), "from")?;
735 let to = required_param(query.to.as_deref(), "to")?;
736 let db = state.db_path(&index_id);
737 let graph = load_graph_blocking(&db, &index_id)?;
738
739 let steps = graph
740 .path(&from, &to)
741 .ok_or_else(|| ApiError::no_path(&from, &to))?;
742 let rendered = render_path(&steps);
743
744 Ok(Json(PathResponse {
745 index_id,
746 from,
747 to,
748 steps,
749 rendered,
750 }))
751}
752
753fn required_param(raw: Option<&str>, name: &str) -> Result<String, ApiError> {
754 raw.map(str::trim)
755 .filter(|value| !value.is_empty())
756 .map(str::to_string)
757 .ok_or_else(|| ApiError::missing_param(name))
758}
759
760#[derive(Debug, Serialize)]
770struct StatsResponse {
771 index_id: String,
772 nodes: KindCounts,
773 edges: KindCounts,
774}
775
776#[derive(Debug, Serialize)]
777struct KindCounts {
778 total: usize,
779 by_kind: BTreeMap<String, usize>,
780}
781
782async fn stats(
783 State(state): State<AppState>,
784 Extension(key): Extension<KeyId>,
785 UrlPath(index_id): UrlPath<String>,
786) -> Result<Json<StatsResponse>, ApiError> {
787 enforce_tenancy(&state, &index_id, &key)?;
788 let db = state.db_path(&index_id);
789 let graph = load_graph_blocking(&db, &index_id)?;
790
791 let mut node_kinds = BTreeMap::new();
792 for kind in ALL_NODE_KINDS {
793 let count = graph.nodes().filter(|node| node.kind == kind).count();
794 if count > 0 {
795 node_kinds.insert(node_kind_name(kind).to_string(), count);
796 }
797 }
798
799 let mut edge_kinds = BTreeMap::new();
800 for kind in ALL_EDGE_KINDS {
801 let count = graph.edge_count_by_kind(kind);
802 if count > 0 {
803 edge_kinds.insert(kind.as_str().to_string(), count);
804 }
805 }
806
807 Ok(Json(StatsResponse {
808 index_id,
809 nodes: KindCounts {
810 total: graph.node_count(),
811 by_kind: node_kinds,
812 },
813 edges: KindCounts {
814 total: graph.edge_count(),
815 by_kind: edge_kinds,
816 },
817 }))
818}
819
820const ALL_NODE_KINDS: [NodeKind; 10] = [
822 NodeKind::Package,
823 NodeKind::Module,
824 NodeKind::File,
825 NodeKind::Class,
826 NodeKind::Interface,
827 NodeKind::Function,
828 NodeKind::Method,
829 NodeKind::Variable,
830 NodeKind::External,
831 NodeKind::Doc,
832];
833
834const ALL_EDGE_KINDS: [EdgeKind; 9] = [
836 EdgeKind::Imports,
837 EdgeKind::Calls,
838 EdgeKind::Inherits,
839 EdgeKind::Implements,
840 EdgeKind::Defines,
841 EdgeKind::References,
842 EdgeKind::Contains,
843 EdgeKind::Reads,
844 EdgeKind::Writes,
845];
846
847fn node_kind_name(kind: NodeKind) -> &'static str {
853 match kind {
854 NodeKind::Package => "package",
855 NodeKind::Module => "module",
856 NodeKind::File => "file",
857 NodeKind::Class => "class",
858 NodeKind::Interface => "interface",
859 NodeKind::Function => "function",
860 NodeKind::Method => "method",
861 NodeKind::Variable => "variable",
862 NodeKind::External => "external",
863 NodeKind::Doc => "doc",
864 }
865}
866
867#[derive(Debug, Serialize)]
876struct VerifyResponse {
877 index_id: String,
878 entries: usize,
879 chain_valid: bool,
880 head: String,
881}
882
883async fn verify(
884 State(state): State<AppState>,
885 Extension(key): Extension<KeyId>,
886 UrlPath(index_id): UrlPath<String>,
887) -> Result<Json<VerifyResponse>, ApiError> {
888 enforce_tenancy(&state, &index_id, &key)?;
889 let db = state.db_path(&index_id);
890 require_index(&db, &index_id)?;
891
892 let (entries, chain_valid, head) = spawn_blocking(move || {
893 let store = Store::open(&db).map_err(|err| ApiError::internal(err.to_string()))?;
894 let audit = store
895 .load_audit()
896 .map_err(|err| ApiError::internal(err.to_string()))?;
897 Ok::<_, ApiError>((audit.len(), audit.verify(), audit.head().to_string()))
898 })
899 .await?;
900
901 Ok(Json(VerifyResponse {
902 index_id,
903 entries,
904 chain_valid,
905 head,
906 }))
907}
908
909async fn snapshot(
921 State(state): State<AppState>,
922 Extension(key): Extension<KeyId>,
923 UrlPath(index_id): UrlPath<String>,
924) -> Result<Json<Snapshot>, ApiError> {
925 enforce_tenancy(&state, &index_id, &key)?;
926 let db = state.db_path(&index_id);
927 require_index(&db, &index_id)?;
928
929 spawn_blocking(move || {
930 let store = Store::open(&db).map_err(|err| ApiError::internal(err.to_string()))?;
931 let graph = store
932 .load_graph()
933 .map_err(|err| ApiError::internal(err.to_string()))?;
934 let audit = store
935 .load_audit()
936 .map_err(|err| ApiError::internal(err.to_string()))?;
937 Ok::<_, ApiError>(Snapshot::from_graph(&graph, &audit.snapshot_entries()))
938 })
939 .await
940 .map(Json)
941}
942
943#[derive(Debug, Default, Deserialize)]
954struct NeighborhoodQuery {
955 node: Option<String>,
956 depth: Option<String>,
957}
958
959async fn neighborhood(
963 State(state): State<AppState>,
964 Extension(key): Extension<KeyId>,
965 UrlPath(index_id): UrlPath<String>,
966 Query(query): Query<NeighborhoodQuery>,
967) -> Result<Json<Value>, ApiError> {
968 enforce_tenancy(&state, &index_id, &key)?;
969 let node = query
970 .node
971 .as_deref()
972 .map(str::trim)
973 .filter(|raw| !raw.is_empty())
974 .ok_or_else(|| ApiError::missing_param("node"))?
975 .to_string();
976 let depth: u32 = match query.depth.as_deref().map(str::trim) {
977 None | Some("") => 1,
978 Some(raw) => raw.parse().map_err(|_| {
979 ApiError::invalid_param("depth", &format!("expected a non-negative integer, got '{raw}'"))
980 })?,
981 };
982 let db = state.db_path(&index_id);
983 let graph = load_graph_blocking(&db, &index_id)?;
984 require_node(&graph, &node).map_err(|e| map_cli_error(&index_id, e))?;
985 let sub = graph.neighborhood(&node, depth);
986 Ok(Json(json!({
987 "index_id": index_id,
988 "node": node,
989 "depth": depth,
990 "nodes": sub.nodes,
991 "edges": sub.edges,
992 })))
993}
994
995async fn symbol(
1000 State(state): State<AppState>,
1001 Extension(key): Extension<KeyId>,
1002 UrlPath(index_id): UrlPath<String>,
1003 Query(query): Query<ClosureQuery>,
1004) -> Result<Json<Value>, ApiError> {
1005 enforce_tenancy(&state, &index_id, &key)?;
1006 let node_id = query
1007 .node
1008 .as_deref()
1009 .map(str::trim)
1010 .filter(|raw| !raw.is_empty())
1011 .ok_or_else(|| ApiError::missing_param("node"))?
1012 .to_string();
1013 let db = state.db_path(&index_id);
1014 let graph = load_graph_blocking(&db, &index_id)?;
1015 let dossier = graph
1016 .dossier(&node_id)
1017 .ok_or_else(|| ApiError::unknown_node(&node_id))?;
1018 let mut value = serde_json::to_value(&dossier)
1019 .map_err(|err| ApiError::internal(format!("dossier does not serialize: {err}")))?;
1020 value["index_id"] = json!(index_id);
1021 Ok(Json(value))
1022}
1023
1024async fn diagnostics(
1029 State(state): State<AppState>,
1030 Extension(key): Extension<KeyId>,
1031 UrlPath(index_id): UrlPath<String>,
1032) -> Result<Json<Value>, ApiError> {
1033 enforce_tenancy(&state, &index_id, &key)?;
1034 let db = state.db_path(&index_id);
1035 require_index(&db, &index_id)?;
1036 let walk_index = index_id.clone();
1040 let mut value = spawn_blocking(move || {
1041 codetopo_cli::diagnostics_value(&db).map_err(|err| map_cli_error(&walk_index, err))
1042 })
1043 .await?;
1044 value["index_id"] = json!(index_id);
1045 Ok(Json(value))
1046}
1047
1048#[derive(Debug, Default, Deserialize)]
1052struct RankQuery {
1053 by: Option<String>,
1054 limit: Option<String>,
1055}
1056
1057async fn rank(
1065 State(state): State<AppState>,
1066 Extension(key): Extension<KeyId>,
1067 UrlPath(index_id): UrlPath<String>,
1068 Query(query): Query<RankQuery>,
1069) -> Result<Json<Value>, ApiError> {
1070 enforce_tenancy(&state, &index_id, &key)?;
1071 let by = query
1072 .by
1073 .as_deref()
1074 .map(str::trim)
1075 .filter(|raw| !raw.is_empty())
1076 .unwrap_or("blast_radius");
1077 let metric = codetopo_core::RankMetric::parse(by).ok_or_else(|| {
1078 ApiError::invalid_param(
1079 "by",
1080 &format!("expected fan_in, fan_out, blast_radius, or complexity, got '{by}'"),
1081 )
1082 })?;
1083 let limit: usize = match query.limit.as_deref().map(str::trim) {
1084 None | Some("") => DEFAULT_LIMIT,
1085 Some(raw) => {
1086 let parsed: usize = raw.parse().map_err(|_| {
1087 ApiError::invalid_param("limit", &format!("expected an integer, got '{raw}'"))
1088 })?;
1089 if parsed == 0 || parsed > MAX_LIMIT {
1090 return Err(ApiError::invalid_param(
1091 "limit",
1092 &format!("must be 1..={MAX_LIMIT}, got {parsed}"),
1093 ));
1094 }
1095 parsed
1096 }
1097 };
1098 let db = state.db_path(&index_id);
1099 let graph = load_graph_blocking(&db, &index_id)?;
1100 let mut value = codetopo_cli::rank_value(&graph, metric, limit);
1101 value["index_id"] = json!(index_id);
1102 Ok(Json(value))
1103}
1104
1105async fn metrics(
1112 State(state): State<AppState>,
1113 Extension(key): Extension<KeyId>,
1114 UrlPath(index_id): UrlPath<String>,
1115) -> Result<Json<Value>, ApiError> {
1116 enforce_tenancy(&state, &index_id, &key)?;
1117 let db = state.db_path(&index_id);
1118 let graph = load_graph_blocking(&db, &index_id)?;
1119 let mut value = codetopo_cli::metrics_value(&graph);
1120 value["index_id"] = json!(index_id);
1121 Ok(Json(value))
1122}
1123
1124const HOSTED_HISTORY_REASON: &str = "hosted indexes carry no repository history; \
1127 history is measured on local surfaces only (the CLI's history command, \
1128 or the MCP tool with a local working tree)";
1129
1130async fn history(
1136 State(state): State<AppState>,
1137 Extension(key): Extension<KeyId>,
1138 UrlPath(index_id): UrlPath<String>,
1139) -> Result<Json<Value>, ApiError> {
1140 enforce_tenancy(&state, &index_id, &key)?;
1141 let db = state.db_path(&index_id);
1142 let graph = load_graph_blocking(&db, &index_id)?;
1143 let mut value = codetopo_cli::history_value(&graph, None, 2, HOSTED_HISTORY_REASON);
1144 value["index_id"] = json!(index_id);
1145 Ok(Json(value))
1146}
1147
1148#[derive(Debug, Default, Deserialize)]
1150struct DiffQuery {
1151 other: Option<String>,
1152}
1153
1154async fn diff(
1162 State(state): State<AppState>,
1163 Extension(key): Extension<KeyId>,
1164 UrlPath(index_id): UrlPath<String>,
1165 Query(query): Query<DiffQuery>,
1166) -> Result<Json<Value>, ApiError> {
1167 enforce_tenancy(&state, &index_id, &key)?;
1168 let other_id = query
1169 .other
1170 .as_deref()
1171 .map(str::trim)
1172 .filter(|raw| !raw.is_empty())
1173 .ok_or_else(|| ApiError::missing_param("other"))?
1174 .to_string();
1175 enforce_tenancy(&state, &other_id, &key)?;
1176 let before = load_graph_blocking(&state.db_path(&index_id), &index_id)?;
1177 let after = load_graph_blocking(&state.db_path(&other_id), &other_id)?;
1178 let mut value = codetopo_cli::diff_value(&before, &after);
1179 value["index_id"] = json!(index_id);
1180 value["other_index_id"] = json!(other_id);
1181 Ok(Json(value))
1182}
1183
1184#[derive(Debug, Default, Deserialize)]
1186struct SearchQuery {
1187 q: Option<String>,
1188 limit: Option<String>,
1189}
1190
1191async fn search(
1194 State(state): State<AppState>,
1195 Extension(key): Extension<KeyId>,
1196 UrlPath(index_id): UrlPath<String>,
1197 Query(query): Query<SearchQuery>,
1198) -> Result<Json<Value>, ApiError> {
1199 enforce_tenancy(&state, &index_id, &key)?;
1200 let q = query
1201 .q
1202 .as_deref()
1203 .map(str::trim)
1204 .filter(|raw| !raw.is_empty())
1205 .ok_or_else(|| ApiError::missing_param("q"))?
1206 .to_string();
1207 let limit: usize = match query.limit.as_deref().map(str::trim) {
1208 None | Some("") => 20,
1209 Some(raw) => raw.parse().map_err(|_| {
1210 ApiError::invalid_param("limit", &format!("expected a positive integer, got '{raw}'"))
1211 })?,
1212 };
1213 let db = state.db_path(&index_id);
1214 let graph = load_graph_blocking(&db, &index_id)?;
1215 let hits = graph.search_symbols(&q, limit);
1216 Ok(Json(json!({
1217 "index_id": index_id,
1218 "query": q,
1219 "total": hits.len(),
1220 "results": hits,
1221 })))
1222}
1223
1224async fn schema() -> Json<Value> {
1228 Json(describe_schema())
1229}
1230
1231fn require_index(db: &FsPath, index_id: &str) -> Result<(), ApiError> {
1233 if !db.is_file() {
1234 return Err(ApiError::unknown_index(index_id));
1235 }
1236 Ok(())
1237}
1238
1239fn load_graph_blocking(db: &FsPath, index_id: &str) -> Result<Graph, ApiError> {
1241 require_index(db, index_id)?;
1242 load_graph_from_db(db).map_err(|err| map_cli_error(index_id, err))
1243}
1244
1245fn map_cli_error(index_id: &str, err: CliError) -> ApiError {
1252 match err {
1253 CliError::UnknownId(id) => ApiError::unknown_node(&id),
1254 other => ApiError::internal(format!("index {index_id}: {other}")),
1255 }
1256}
1257
1258async fn spawn_blocking<T, F>(work: F) -> Result<T, ApiError>
1264where
1265 F: FnOnce() -> Result<T, ApiError> + Send + 'static,
1266 T: Send + 'static,
1267{
1268 tokio::task::spawn_blocking(work)
1269 .await
1270 .map_err(|err| ApiError::internal(format!("worker task failed: {err}")))?
1271}