Skip to main content

dissolution/
server.rs

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}