use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::Json,
};
use serde_json::Value;
use crate::{
knowledge::{parse_frontmatter, KnowledgeManager},
server::{
errors::{bad_request, internal_error, not_found},
state::AppState,
types::{
ApiError, CreateKnowledgePageRequest, KnowledgePage, KnowledgePageSummary,
KnowledgeSearchMatch, KnowledgeSearchQuery, KnowledgeSource,
},
},
};
fn yaml_escape(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
fn knowledge_manager(state: &AppState) -> Result<KnowledgeManager, (StatusCode, Json<ApiError>)> {
KnowledgeManager::new(&state.crosslink_dir)
.map_err(|e| internal_error("Failed to initialize knowledge manager", e))
}
pub async fn list_knowledge_pages(
State(state): State<AppState>,
) -> Result<Json<Value>, (StatusCode, Json<ApiError>)> {
let km = knowledge_manager(&state)?;
if !km.is_initialized() {
return Ok(Json(serde_json::json!({ "items": [], "total": 0 })));
}
let pages = km
.list_pages()
.map_err(|e| internal_error("Failed to list knowledge pages", e))?;
let items: Vec<KnowledgePageSummary> = pages
.into_iter()
.map(|p| KnowledgePageSummary {
slug: p.slug,
title: p.frontmatter.title,
tags: p.frontmatter.tags,
updated: p.frontmatter.updated,
})
.collect();
let total = items.len();
Ok(Json(serde_json::json!({ "items": items, "total": total })))
}
pub async fn create_knowledge_page(
State(state): State<AppState>,
Json(body): Json<CreateKnowledgePageRequest>,
) -> Result<(StatusCode, Json<KnowledgePage>), (StatusCode, Json<ApiError>)> {
if body.slug.is_empty() {
return Err(bad_request("slug cannot be empty"));
}
if body.title.is_empty() {
return Err(bad_request("title cannot be empty"));
}
if body.slug.contains('/')
|| body.slug.contains('\\')
|| body.slug.contains("..")
|| body.slug.contains('\0')
{
return Err(bad_request(
"slug must not contain '/', '\\', '..', or null bytes",
));
}
let km = knowledge_manager(&state)?;
if !km.is_initialized() {
km.init_cache()
.map_err(|e| internal_error("Failed to initialize knowledge cache", e))?;
}
if km.page_exists(&body.slug) {
return Err(bad_request(format!("Page '{}' already exists", body.slug)));
}
let now = chrono::Utc::now().format("%Y-%m-%d").to_string();
let sources_yaml = if body.sources.is_empty() {
"[]".to_string()
} else {
let entries: Vec<String> = body
.sources
.iter()
.map(|s| {
let mut entry = format!(
" - url: \"{}\"\n title: \"{}\"",
yaml_escape(&s.url),
yaml_escape(&s.title)
);
if let Some(ref at) = s.accessed_at {
use std::fmt::Write;
let _ = write!(entry, "\n accessed_at: \"{}\"", yaml_escape(at));
}
entry
})
.collect();
format!("\n{}", entries.join("\n"))
};
let tags_yaml = if body.tags.is_empty() {
"[]".to_string()
} else {
format!(
"[{}]",
body.tags
.iter()
.map(|t| format!("\"{}\"", yaml_escape(t)))
.collect::<Vec<_>>()
.join(", ")
)
};
let page_content = format!(
"---\ntitle: \"{}\"\ntags: {}\nsources: {}\ncontributors: []\ncreated: \"{}\"\nupdated: \"{}\"\n---\n\n{}",
yaml_escape(&body.title), tags_yaml, sources_yaml, now, now, body.content
);
km.write_page(&body.slug, &page_content)
.map_err(|e| internal_error("Failed to write knowledge page", e))?;
let commit_msg = format!("Add knowledge page: {}", body.slug);
if let Err(e) = km.commit(&commit_msg) {
tracing::warn!(
"could not commit knowledge page '{commit_msg}': {e} — will be committed on next sync"
);
}
let response = KnowledgePage {
slug: body.slug,
title: body.title,
tags: body.tags,
sources: body.sources,
contributors: vec![],
created: now.clone(),
updated: now,
content: body.content,
};
Ok((StatusCode::CREATED, Json(response)))
}
pub async fn search_knowledge(
State(state): State<AppState>,
Query(params): Query<KnowledgeSearchQuery>,
) -> Result<Json<Value>, (StatusCode, Json<ApiError>)> {
if params.q.trim().is_empty() {
return Err(bad_request("Search query 'q' cannot be empty"));
}
let km = knowledge_manager(&state)?;
if !km.is_initialized() {
return Ok(Json(serde_json::json!({ "items": [], "total": 0 })));
}
let matches = km
.search_content(¶ms.q, 2)
.map_err(|e| internal_error("Knowledge search failed", e))?;
let title_map: std::collections::HashMap<String, String> = if matches.is_empty() {
std::collections::HashMap::new()
} else {
km.list_pages()
.map_err(|e| internal_error("Failed to list pages for title lookup", e))?
.into_iter()
.map(|p| (p.slug.clone(), p.frontmatter.title))
.collect()
};
let items: Vec<KnowledgeSearchMatch> = matches
.into_iter()
.map(|m| KnowledgeSearchMatch {
title: title_map
.get(&m.slug)
.cloned()
.unwrap_or_else(|| m.slug.clone()),
slug: m.slug,
line_number: m.line_number,
context_lines: m.context_lines,
})
.collect();
let total = items.len();
Ok(Json(serde_json::json!({ "items": items, "total": total })))
}
pub async fn get_knowledge_page(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Json<KnowledgePage>, (StatusCode, Json<ApiError>)> {
let km = knowledge_manager(&state)?;
if !km.is_initialized() {
return Err(not_found(format!("Page '{slug}' not found")));
}
let raw = km.read_page(&slug).map_err(|e| {
let msg = e.to_string();
if msg.contains("not found") {
not_found(format!("Page '{slug}' not found"))
} else {
internal_error("Failed to read knowledge page", e)
}
})?;
let frontmatter = parse_frontmatter(&raw);
let (title, tags, sources, contributors, created, updated) = match frontmatter {
Some(fm) => {
let sources: Vec<KnowledgeSource> = fm
.sources
.into_iter()
.map(|s| KnowledgeSource {
url: s.url,
title: s.title,
accessed_at: s.accessed_at,
})
.collect();
(
fm.title,
fm.tags,
sources,
fm.contributors,
fm.created,
fm.updated,
)
}
None => (
slug.clone(),
vec![],
vec![],
vec![],
String::new(),
String::new(),
),
};
let content = strip_frontmatter(&raw);
Ok(Json(KnowledgePage {
slug,
title,
tags,
sources,
contributors,
created,
updated,
content,
}))
}
fn strip_frontmatter(raw: &str) -> String {
let trimmed = raw.trim_start();
if !trimmed.starts_with("---") {
return raw.to_string();
}
trimmed[3..].find("\n---").map_or_else(
|| raw.to_string(),
|end| {
let after = &trimmed[3 + end + 4..]; after.trim_start_matches('\n').to_string()
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{body::Body, http::Request, Router};
use serde_json::Value;
use tower::ServiceExt;
fn test_state(tmp_dir: &std::path::Path) -> AppState {
let db_path = tmp_dir.join("test.db");
let db = crate::db::Database::open(&db_path).unwrap();
let crosslink_dir = tmp_dir.join(".crosslink");
std::fs::create_dir_all(&crosslink_dir).unwrap();
AppState::new(db, crosslink_dir)
}
fn build_router(state: AppState) -> Router {
use axum::routing::get;
Router::new()
.route("/knowledge/search", get(search_knowledge))
.route(
"/knowledge",
get(list_knowledge_pages).post(create_knowledge_page),
)
.route("/knowledge/{slug}", get(get_knowledge_page))
.with_state(state)
}
#[tokio::test]
async fn test_list_empty_knowledge() {
let tmp = tempfile::tempdir().unwrap();
let state = test_state(tmp.path());
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/knowledge")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["items"].as_array().unwrap().len(), 0);
assert_eq!(body["total"], 0);
}
#[tokio::test]
async fn test_create_and_get_knowledge_page() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().join(".crosslink").join(".knowledge-cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let state = test_state(tmp.path());
let app = build_router(state);
let create_body = serde_json::json!({
"slug": "test-page",
"title": "Test Page",
"content": "Hello, world!",
"tags": ["test", "example"],
"sources": [{"url": "https://example.com", "title": "Example"}]
});
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/knowledge")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&create_body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["slug"], "test-page");
assert_eq!(body["title"], "Test Page");
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/knowledge/test-page")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["slug"], "test-page");
assert_eq!(body["title"], "Test Page");
assert!(body["content"].as_str().unwrap().contains("Hello, world!"));
}
#[tokio::test]
async fn test_get_nonexistent_page_returns_404() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().join(".crosslink").join(".knowledge-cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let state = test_state(tmp.path());
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/knowledge/nonexistent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_create_duplicate_page_returns_400() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().join(".crosslink").join(".knowledge-cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let state = test_state(tmp.path());
let app = build_router(state);
let create_body = serde_json::json!({
"slug": "dup",
"title": "Duplicate",
"content": "First"
});
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/knowledge")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&create_body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let resp = app
.oneshot(
Request::builder()
.uri("/knowledge")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&create_body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_search_knowledge_pages() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().join(".crosslink").join(".knowledge-cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let page = "---\ntitle: \"Rust Notes\"\ntags: [rust]\nsources: []\ncontributors: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nRust is a systems programming language.\nIt provides memory safety without garbage collection.\n";
std::fs::write(cache_dir.join("rust-notes.md"), page).unwrap();
let state = test_state(tmp.path());
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/knowledge/search?q=memory+safety")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body: Value = serde_json::from_slice(&bytes).unwrap();
assert!(body["total"].as_u64().unwrap() > 0);
assert_eq!(body["items"][0]["slug"], "rust-notes");
assert_eq!(body["items"][0]["title"], "Rust Notes");
}
#[tokio::test]
async fn test_search_empty_query_returns_400() {
let tmp = tempfile::tempdir().unwrap();
let state = test_state(tmp.path());
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/knowledge/search?q=")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_list_knowledge_pages_after_create() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().join(".crosslink").join(".knowledge-cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let page_a = "---\ntitle: \"Alpha\"\ntags: []\nsources: []\ncontributors: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nAlpha page.\n";
let page_b = "---\ntitle: \"Beta\"\ntags: [test]\nsources: []\ncontributors: []\ncreated: \"2026-01-02\"\nupdated: \"2026-01-02\"\n---\n\nBeta page.\n";
std::fs::write(cache_dir.join("alpha.md"), page_a).unwrap();
std::fs::write(cache_dir.join("beta.md"), page_b).unwrap();
let state = test_state(tmp.path());
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/knowledge")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["total"], 2);
let items = body["items"].as_array().unwrap();
assert_eq!(items[0]["slug"], "alpha");
assert_eq!(items[1]["slug"], "beta");
}
#[test]
fn test_strip_frontmatter() {
let raw = "---\ntitle: Test\ntags: []\n---\n\nBody text here.";
let stripped = strip_frontmatter(raw);
assert_eq!(stripped, "Body text here.");
}
#[test]
fn test_strip_frontmatter_no_frontmatter() {
let raw = "Just plain text.";
let stripped = strip_frontmatter(raw);
assert_eq!(stripped, "Just plain text.");
}
#[test]
fn test_strip_frontmatter_unclosed_returns_original() {
let raw = "---\ntitle: Test\ntags: []\nno closing delimiter";
let stripped = strip_frontmatter(raw);
assert_eq!(stripped, raw);
}
#[tokio::test]
async fn test_create_page_empty_slug_returns_400() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().join(".crosslink").join(".knowledge-cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let state = test_state(tmp.path());
let app = build_router(state);
let body = serde_json::json!({"slug": "", "title": "Title", "content": "body"});
let resp = app
.oneshot(
Request::builder()
.uri("/knowledge")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_create_page_empty_title_returns_400() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().join(".crosslink").join(".knowledge-cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let state = test_state(tmp.path());
let app = build_router(state);
let body = serde_json::json!({"slug": "some-slug", "title": "", "content": "body"});
let resp = app
.oneshot(
Request::builder()
.uri("/knowledge")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_create_page_when_cache_missing_attempts_init() {
let tmp = tempfile::tempdir().unwrap();
let crosslink_dir = tmp.path().join(".crosslink");
std::fs::create_dir_all(&crosslink_dir).unwrap();
let state = test_state(tmp.path());
let app = build_router(state);
let body = serde_json::json!({
"slug": "auto-init-page",
"title": "Auto Init",
"content": "Created when cache was absent."
});
let resp = app
.oneshot(
Request::builder()
.uri("/knowledge")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn test_create_page_with_tags_and_sources() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().join(".crosslink").join(".knowledge-cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let state = test_state(tmp.path());
let app = build_router(state);
let body = serde_json::json!({
"slug": "tagged-page",
"title": "Tagged Page",
"content": "Content here.",
"tags": ["rust", "systems"],
"sources": [
{"url": "https://doc.rust-lang.org", "title": "Rust Docs", "accessed_at": "2026-01-01"}
]
});
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/knowledge")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(parsed["slug"], "tagged-page");
assert!(parsed["tags"]
.as_array()
.unwrap()
.contains(&serde_json::json!("rust")));
let get_resp = app
.oneshot(
Request::builder()
.uri("/knowledge/tagged-page")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(get_resp.status(), StatusCode::OK);
let bytes2 = axum::body::to_bytes(get_resp.into_body(), usize::MAX)
.await
.unwrap();
let page: serde_json::Value = serde_json::from_slice(&bytes2).unwrap();
assert!(page["tags"]
.as_array()
.unwrap()
.contains(&serde_json::json!("rust")));
assert!(!page["sources"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_get_page_when_km_not_initialized() {
let tmp = tempfile::tempdir().unwrap();
let crosslink_dir = tmp.path().join(".crosslink");
std::fs::create_dir_all(&crosslink_dir).unwrap();
let state = test_state(tmp.path());
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/knowledge/anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_search_knowledge_when_not_initialized() {
let tmp = tempfile::tempdir().unwrap();
let crosslink_dir = tmp.path().join(".crosslink");
std::fs::create_dir_all(&crosslink_dir).unwrap();
let state = test_state(tmp.path());
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/knowledge/search?q=anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["total"], 0);
assert_eq!(body["items"].as_array().unwrap().len(), 0);
}
#[test]
fn test_helper_functions_directly() {
let (status, json) = crate::server::errors::internal_error("ctx", "detail");
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(json.error, "ctx");
assert_eq!(json.detail.as_deref(), Some("detail"));
let (status, json) = crate::server::errors::not_found("not there");
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(json.error, "not found");
assert_eq!(json.detail.as_deref(), Some("not there"));
let (status, json) = crate::server::errors::bad_request("invalid");
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(json.error, "bad request");
assert_eq!(json.detail.as_deref(), Some("invalid"));
}
#[tokio::test]
async fn test_create_page_with_source_accessed_at() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().join(".crosslink").join(".knowledge-cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let state = test_state(tmp.path());
let app = build_router(state);
let body = serde_json::json!({
"slug": "sourced-page",
"title": "Sourced Page",
"content": "This page has a source with accessed_at.",
"sources": [
{
"url": "https://example.com/doc",
"title": "Example Doc",
"accessed_at": "2026-03-01"
}
]
});
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/knowledge")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(parsed["slug"], "sourced-page");
let sources = parsed["sources"].as_array().unwrap();
assert!(!sources.is_empty());
assert_eq!(sources[0]["accessed_at"], "2026-03-01");
}
#[tokio::test]
async fn test_get_page_without_frontmatter() {
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().join(".crosslink").join(".knowledge-cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let page = "No frontmatter at all. Just raw content.\n";
std::fs::write(cache_dir.join("raw-page.md"), page).unwrap();
let state = test_state(tmp.path());
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/knowledge/raw-page")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["slug"], "raw-page");
assert_eq!(body["title"], "raw-page");
assert!(body["content"].as_str().unwrap().contains("raw content"));
}
}