#![forbid(unsafe_code)]
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use anyhow::Result;
use axum::{
Router,
extract::{Query, State},
http::StatusCode,
response::{Html, IntoResponse, Json, Response},
routing::{get, post},
};
use glyphtrail_core::{Edge, NodeId};
use glyphtrail_store::GraphStore;
use serde::Deserialize;
use serde_json::{Value, json};
#[derive(Clone)]
struct AppState {
store: Arc<Mutex<Box<dyn GraphStore + Send>>>,
mcp_db: PathBuf,
}
#[derive(Deserialize)]
struct SearchParams {
q: String,
#[serde(default = "default_limit")]
limit: usize,
}
fn default_limit() -> usize {
50
}
#[derive(Deserialize)]
struct GraphParams {
kinds: Option<String>,
edges: Option<String>,
limit: Option<usize>,
}
#[derive(Deserialize)]
struct NeighborParams {
id: String,
}
fn csv_vec(value: &Option<String>) -> Option<Vec<String>> {
value.as_ref().map(|v| {
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
})
}
async fn index() -> Html<&'static str> {
Html(glyphtrail_viz::TEMPLATE)
}
async fn api_graph(State(state): State<AppState>, Query(p): Query<GraphParams>) -> Json<Value> {
let store = state.store.lock().unwrap_or_else(|e| e.into_inner());
let node_kinds = csv_vec(&p.kinds);
let edge_kinds = csv_vec(&p.edges);
let limit = p.limit.unwrap_or(2000);
let (nodes, edges) = store
.export_filtered(node_kinds.as_deref(), edge_kinds.as_deref(), limit)
.unwrap_or_default();
let ops = store.all_operations().unwrap_or_default();
let (nodes, edges) = glyphtrail_viz::select_graph(
&nodes,
&edges,
&glyphtrail_viz::Selection {
kinds: None,
edge_kinds: None,
limit,
},
);
Json(glyphtrail_viz::to_elements(&nodes, &edges, &ops, None))
}
async fn api_neighbors(
State(state): State<AppState>,
Query(p): Query<NeighborParams>,
) -> Json<Value> {
let store = state.store.lock().unwrap_or_else(|e| e.into_inner());
let ops = store.all_operations().unwrap_or_default();
let mut nodes = Vec::new();
let mut edges = Vec::new();
if let Ok(Some(center)) = store.get_node(&p.id) {
nodes.push(center);
}
let center = NodeId(p.id.clone());
for (n, kind, confidence) in store.neighbors(&p.id, None, true).unwrap_or_default() {
edges.push(Edge {
src: center.clone(),
dst: n.id.clone(),
kind,
confidence,
});
nodes.push(n);
}
for (n, kind, confidence) in store.neighbors(&p.id, None, false).unwrap_or_default() {
edges.push(Edge {
src: n.id.clone(),
dst: center.clone(),
kind,
confidence,
});
nodes.push(n);
}
Json(glyphtrail_viz::to_elements(&nodes, &edges, &ops, None))
}
async fn api_search(
State(state): State<AppState>,
Query(params): Query<SearchParams>,
) -> Json<Value> {
let store = state.store.lock().unwrap_or_else(|e| e.into_inner());
let nodes = store.search(¶ms.q, params.limit).unwrap_or_default();
Json(json!(nodes))
}
async fn mcp(State(state): State<AppState>, Json(msg): Json<Value>) -> Response {
match glyphtrail_mcp::handle_request(&state.mcp_db, &msg) {
Some(resp) => Json(resp).into_response(),
None => StatusCode::NO_CONTENT.into_response(),
}
}
pub async fn serve(store: Box<dyn GraphStore + Send>, mcp_db: PathBuf, port: u16) -> Result<()> {
let state = AppState {
store: Arc::new(Mutex::new(store)),
mcp_db,
};
let app = Router::new()
.route("/", get(index))
.route("/api/graph", get(api_graph))
.route("/api/neighbors", get(api_neighbors))
.route("/api/search", get(api_search))
.route("/mcp", post(mcp))
.with_state(state);
let addr = format!("127.0.0.1:{port}");
let listener = tokio::net::TcpListener::bind(&addr).await?;
println!("glyphtrail serving at http://{addr}");
axum::serve(listener, app).await?;
Ok(())
}
pub async fn serve_wiki(dir: PathBuf, port: u16) -> Result<()> {
let app = Router::new()
.route("/", get(wiki_page))
.route("/{slug}", get(wiki_page))
.with_state(dir);
let addr = format!("127.0.0.1:{port}");
let listener = tokio::net::TcpListener::bind(&addr).await?;
println!("glyphtrail wiki serving at http://{addr}");
axum::serve(listener, app).await?;
Ok(())
}
async fn wiki_page(
State(dir): State<PathBuf>,
slug: Option<axum::extract::Path<String>>,
) -> Response {
let raw = slug.map(|s| s.0).unwrap_or_default();
let slug = if raw.is_empty() {
"index"
} else {
raw.trim_end_matches(".md")
};
if slug.is_empty() || slug.contains('/') || slug.contains("..") {
return (StatusCode::BAD_REQUEST, "invalid page").into_response();
}
match std::fs::read_to_string(dir.join(format!("{slug}.md"))) {
Ok(md) => Html(render_markdown(slug, &md)).into_response(),
Err(_) => (StatusCode::NOT_FOUND, format!("no wiki page '{slug}'")).into_response(),
}
}
fn render_markdown(title: &str, markdown: &str) -> String {
let mut body = String::new();
pulldown_cmark::html::push_html(&mut body, pulldown_cmark::Parser::new(markdown));
format!(
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">\
<title>{title}</title></head><body>{body}</body></html>"
)
}
#[cfg(test)]
mod tests {
use super::render_markdown;
use assert2::check;
#[test]
fn renders_markdown_to_html_document() {
let html = render_markdown("Page", "# Title\n\nA **bold** word.\n");
check!(html.contains("<title>Page</title>"));
check!(html.contains("<h1>Title</h1>"));
check!(html.contains("<strong>bold</strong>"));
check!(html.starts_with("<!DOCTYPE html>"));
}
}