#![allow(private_interfaces)]
mod admin;
mod analysis;
mod archive;
mod commands;
mod context;
mod feedback;
mod grep;
mod review;
mod search;
mod search_exec;
mod similar;
mod tags;
mod webhook;
use std::sync::Arc;
use axum::http::StatusCode;
use axum::response::Html;
use axum::Json;
use serde::Serialize;
use crate::access::RepoFilter;
use crate::config::Config;
use crate::storage::{MetadataStore, VectorStore};
use crate::tags::TagsConfig;
use crate::types::MatchType;
use super::AppState;
pub(super) use self::webhook::resolve_sources;
pub(super) fn router(state: Arc<AppState>) -> axum::Router {
use axum::routing::{get, post};
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
let app = axum::Router::new()
.route("/", get(ui_page))
.route("/search", get(search::search))
.route("/grep", get(grep::grep))
.route("/context", get(context::context))
.route("/chunk/{id}", get(search::get_chunk))
.route("/read", get(context::read_chunk))
.route("/related", get(analysis::related))
.route("/refs", get(analysis::find_refs))
.route("/symbols", get(analysis::list_symbols))
.route("/hotspots", get(analysis::hotspots))
.route("/impact", get(analysis::impact))
.route("/deps", get(analysis::deps))
.route("/history", get(analysis::history))
.route("/review", get(review::review))
.route("/similar", get(similar::similar))
.route("/prime", get(admin::prime))
.route("/beads", get(archive::search_beads))
.route("/archive/search", get(archive::archive_search))
.route("/archive/entry/{id}", get(archive::archive_entry))
.route("/archive/recent", get(archive::archive_recent))
.route("/status", get(admin::status))
.route("/version", get(admin::version))
.route("/healthz", get(admin::healthz))
.route("/repos", get(admin::list_repos))
.route("/groups", get(admin::list_groups))
.route("/tags", get(tags::tags))
.route("/bundles", get(tags::bundles_list))
.route("/bundles/{name}", get(tags::bundles_show))
.route("/ontology", get(tags::ontology_list))
.route("/ontology/{tag}", get(tags::ontology_show))
.route("/suggest", get(admin::suggest))
.route("/repos/{name}/files", get(admin::list_repo_files))
.route("/commands", get(commands::list_commands))
.route("/metrics", get(admin::metrics))
.route("/webhook/push", post(webhook::webhook_push))
.route("/injections", post(feedback::injection_store))
.route("/injections/{id}", get(feedback::injection_detail))
.route("/feedback", post(feedback::feedback_submit))
.route("/feedback", get(feedback::feedback_list))
.route("/feedback/stats", get(feedback::feedback_stats))
.route(
"/feedback/lineage",
post(feedback::lineage_store).get(feedback::lineage_list),
)
.route(
"/cmd",
get(commands::list_http_commands).post(commands::register_http_command),
)
.route(
"/cmd/{name}",
get(commands::invoke_http_command).delete(commands::delete_http_command),
)
.with_state(state.clone())
.layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http());
let _ = state.inner_router.set(app.clone());
app
}
async fn ui_page() -> Html<&'static str> {
Html(include_str!("../ui.html"))
}
#[derive(Serialize)]
struct ErrorBody {
error: String,
}
fn internal_error(err: anyhow::Error) -> (StatusCode, Json<ErrorBody>) {
tracing::error!("Internal error: {:#}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorBody {
error: err.to_string(),
}),
)
}
fn bad_request(msg: String) -> (StatusCode, Json<ErrorBody>) {
(StatusCode::BAD_REQUEST, Json(ErrorBody { error: msg }))
}
fn not_found(msg: String) -> (StatusCode, Json<ErrorBody>) {
(StatusCode::NOT_FOUND, Json(ErrorBody { error: msg }))
}
fn resolve_filter(state: &AppState, role: Option<&str>) -> RepoFilter {
let resolved = RepoFilter::resolve_role(role);
RepoFilter::from_config(&state.config.access, &resolved)
}
fn resolve_group_filter(state: &AppState, group: Option<&str>) -> Result<Option<String>, String> {
match group {
None => Ok(None),
Some(name) => state.config.group_filter(name).map(Some).ok_or_else(|| {
let available: Vec<&str> = state
.config
.groups
.iter()
.map(|g| g.name.as_str())
.collect();
if available.is_empty() {
format!("Unknown group '{}'. No groups configured.", name)
} else {
format!(
"Unknown group '{}'. Available: {}",
name,
available.join(", ")
)
}
}),
}
}
async fn open_vector_store(state: &AppState) -> anyhow::Result<VectorStore> {
let lance_path = Config::lance_path(&state.repo_root);
VectorStore::open(&lance_path).await
}
fn open_metadata_store(state: &AppState) -> anyhow::Result<MetadataStore> {
let db_path = Config::db_path(&state.repo_root);
MetadataStore::open(&db_path)
}
fn detect_language(file: &str) -> String {
let ext = file.rsplit('.').next().unwrap_or("");
match ext {
"rs" => "rust",
"ts" | "tsx" => "typescript",
"js" | "jsx" => "javascript",
"py" => "python",
"go" => "go",
"java" => "java",
"cpp" | "cc" | "cxx" | "hpp" | "h" => "cpp",
"c" => "c",
"md" => "markdown",
"json" => "json",
"yaml" | "yml" => "yaml",
"toml" => "toml",
_ => "unknown",
}
.to_string()
}
fn parse_chunk_type(s: &str) -> anyhow::Result<crate::types::ChunkType> {
use crate::types::ChunkType;
match s.to_lowercase().as_str() {
"function" | "func" | "fn" => Ok(ChunkType::Function),
"method" => Ok(ChunkType::Method),
"class" => Ok(ChunkType::Class),
"struct" => Ok(ChunkType::Struct),
"enum" => Ok(ChunkType::Enum),
"interface" => Ok(ChunkType::Interface),
"module" | "mod" => Ok(ChunkType::Module),
"impl" => Ok(ChunkType::Impl),
"trait" => Ok(ChunkType::Trait),
"doc" | "documentation" => Ok(ChunkType::Doc),
"section" => Ok(ChunkType::Section),
"table" => Ok(ChunkType::Table),
"code_block" | "codeblock" => Ok(ChunkType::CodeBlock),
"commit" => Ok(ChunkType::Commit),
"issue" | "bead" => Ok(ChunkType::Issue),
"other" => Ok(ChunkType::Other),
_ => anyhow::bail!("Unknown chunk type: {}", s),
}
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let t: String = s.chars().take(max).collect();
format!("{}...", t.trim_end())
}
}
#[derive(Serialize)]
struct BundleMatchOutput {
name: String,
slug: String,
description: String,
#[serde(rename = "match")]
match_reason: String,
file_count: usize,
drill: String,
}
fn find_matching_bundles(tags_config: &TagsConfig, query: &str) -> Vec<BundleMatchOutput> {
tags_config
.match_bundle_keywords(query)
.into_iter()
.map(|(bundle, match_reason)| BundleMatchOutput {
name: bundle.name.clone(),
slug: bundle.slug(),
description: bundle.description.clone(),
match_reason,
file_count: bundle.member_files().len(),
drill: format!("bobbin bundle show {}", bundle.name),
})
.collect()
}
#[derive(Serialize)]
struct ContextBudgetInfo {
max_lines: usize,
used_lines: usize,
}
#[derive(Serialize)]
struct ContextFileOutput {
path: String,
language: String,
relevance: String,
score: f32,
#[serde(skip_serializing_if = "Vec::is_empty")]
coupled_to: Vec<String>,
chunks: Vec<ContextChunkOutput>,
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<String>,
}
#[derive(Serialize)]
struct ContextChunkOutput {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
chunk_type: String,
start_line: u32,
end_line: u32,
score: f32,
#[serde(skip_serializing_if = "Option::is_none")]
match_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>,
}
#[derive(Serialize)]
struct ContextSummaryOutput {
total_files: usize,
total_chunks: usize,
direct_hits: usize,
coupled_additions: usize,
bridged_additions: usize,
source_files: usize,
doc_files: usize,
top_semantic_score: f32,
#[serde(skip_serializing_if = "Option::is_none")]
intent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
gate_boost: Option<f32>,
}
fn to_context_file(f: &crate::search::context::ContextFile) -> ContextFileOutput {
use crate::search::context::FileRelevance;
ContextFileOutput {
path: f.path.clone(),
language: f.language.clone(),
relevance: match f.relevance {
FileRelevance::Direct => "direct".to_string(),
FileRelevance::Coupled => "coupled".to_string(),
FileRelevance::Bridged => "bridged".to_string(),
FileRelevance::Pinned => "pinned".to_string(),
FileRelevance::Knowledge => "knowledge".to_string(),
FileRelevance::Structural => "structural".to_string(),
},
score: f.score,
coupled_to: f.coupled_to.clone(),
chunks: f
.chunks
.iter()
.map(|c| ContextChunkOutput {
name: c.name.clone(),
chunk_type: c.chunk_type.to_string(),
start_line: c.start_line,
end_line: c.end_line,
score: c.score,
match_type: c.match_type.map(|mt| match mt {
MatchType::Semantic => "semantic".to_string(),
MatchType::Keyword => "keyword".to_string(),
MatchType::Hybrid => "hybrid".to_string(),
}),
content: c.content.clone(),
})
.collect(),
repo: f.repo.clone(),
}
}
fn to_context_summary(s: &crate::search::context::ContextSummary) -> ContextSummaryOutput {
ContextSummaryOutput {
total_files: s.total_files,
total_chunks: s.total_chunks,
direct_hits: s.direct_hits,
coupled_additions: s.coupled_additions,
bridged_additions: s.bridged_additions,
source_files: s.source_files,
doc_files: s.doc_files,
top_semantic_score: s.top_semantic_score,
intent: None,
gate_boost: None,
}
}
pub(crate) fn query_sha(text: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(text.as_bytes());
digest.iter().take(6).map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod query_log_tests;