1use crate::config::Config;
2use crate::db::Db;
3use crate::embedder::Embedder;
4use anyhow::Result;
5use axum::extract::State;
6use axum::http::{StatusCode, header};
7use axum::response::{Html, IntoResponse, Response};
8use axum::routing::{get, post};
9use axum::{Json, Router};
10use serde::{Deserialize, Serialize};
11use std::sync::Arc;
12use tower_http::cors::{Any, CorsLayer};
13
14#[derive(Clone)]
15pub struct AppState {
16 pub embedder: Arc<Embedder>,
17}
18
19#[derive(Debug, Serialize)]
20pub struct RepoRow {
21 pub path: String,
22 pub db_path: String,
23 pub file_count: usize,
24 pub chunk_count: usize,
25 pub db_bytes: u64,
26}
27
28#[derive(Debug, Deserialize)]
29pub struct SearchRequest {
30 pub query: String,
31 pub limit: Option<usize>,
32 pub repo: Option<String>,
33}
34
35#[derive(Debug, Serialize)]
36pub struct SearchRow {
37 pub repo: String,
38 pub file_path: String,
39 pub line_start: usize,
40 pub score: f32,
41 pub snippet: String,
42}
43
44#[derive(Debug, Serialize)]
45pub struct SearchResponse {
46 pub query: String,
47 pub results: Vec<SearchRow>,
48}
49
50pub async fn run(port: u16, embedder: Arc<Embedder>) -> Result<()> {
51 let app = Router::new()
52 .route("/", get(index))
53 .route("/assets/dissolve-shared.css", get(shared_css))
54 .route("/api/health", get(health))
55 .route("/api/repos", get(repos))
56 .route("/api/search", post(search))
57 .with_state(AppState { embedder })
58 .layer(
59 CorsLayer::new()
60 .allow_origin(Any)
61 .allow_methods(Any)
62 .allow_headers(Any),
63 );
64
65 let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
66 axum::serve(listener, app).await?;
67 Ok(())
68}
69
70async fn index() -> Html<&'static str> {
71 Html(include_str!("ui.html"))
72}
73
74async fn shared_css() -> impl IntoResponse {
75 (
76 [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
77 include_str!("../docs/src/styles/dissolve-shared.css"),
78 )
79}
80
81async fn health() -> Json<serde_json::Value> {
82 Json(serde_json::json!({ "ok": true }))
83}
84
85async fn repos() -> Result<Json<Vec<RepoRow>>, ApiError> {
86 let cfg = Config::load().map_err(ApiError::from)?;
87 let mut rows = Vec::new();
88
89 for repo in cfg.dirs {
90 let db = Db::open(&repo.db_path, cfg.embedding.dim).map_err(ApiError::from)?;
91 let stats = db.stats(&repo.db_path).map_err(ApiError::from)?;
92 rows.push(RepoRow {
93 path: repo.path.display().to_string(),
94 db_path: repo.db_path.display().to_string(),
95 file_count: stats.file_count,
96 chunk_count: stats.chunk_count,
97 db_bytes: stats.db_bytes,
98 });
99 }
100
101 Ok(Json(rows))
102}
103
104async fn search(
105 State(state): State<AppState>,
106 Json(payload): Json<SearchRequest>,
107) -> Result<Json<SearchResponse>, ApiError> {
108 let cfg = Config::load().map_err(ApiError::from)?;
109 let limit = payload.limit.unwrap_or(10).clamp(1, 100);
110 let query_vec = state
111 .embedder
112 .embed(payload.query.trim())
113 .await
114 .map_err(ApiError::from)?;
115
116 let mut rows = Vec::new();
117 for repo in cfg.dirs {
118 if let Some(filter) = payload.repo.as_ref() {
119 let path = repo.path.display().to_string();
120 if !path.contains(filter) {
121 continue;
122 }
123 }
124
125 let db = Db::open(&repo.db_path, cfg.embedding.dim).map_err(ApiError::from)?;
126 let repo_hits = db
127 .search(&query_vec, limit, None)
128 .map_err(ApiError::from)?
129 .into_iter()
130 .map(|hit| SearchRow {
131 repo: repo.path.display().to_string(),
132 file_path: hit.file_path,
133 line_start: hit.line_start,
134 score: hit.score,
135 snippet: hit.snippet,
136 });
137
138 rows.extend(repo_hits);
139 }
140
141 rows.sort_by(|a, b| b.score.total_cmp(&a.score));
142 rows.truncate(limit);
143
144 Ok(Json(SearchResponse {
145 query: payload.query,
146 results: rows,
147 }))
148}
149
150#[derive(Debug)]
151struct ApiError(anyhow::Error);
152
153impl From<anyhow::Error> for ApiError {
154 fn from(value: anyhow::Error) -> Self {
155 Self(value)
156 }
157}
158
159impl IntoResponse for ApiError {
160 fn into_response(self) -> Response {
161 let body = Json(serde_json::json!({ "error": self.0.to_string() }));
162 (StatusCode::INTERNAL_SERVER_ERROR, body).into_response()
163 }
164}