use std::sync::Arc;
use axum::{
Json, Router,
body::Bytes,
extract::{DefaultBodyLimit, Path, State},
http::header,
response::IntoResponse,
routing::{get, post},
};
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use crate::concept_map;
use crate::map_server::assets;
use crate::map_server::error::MapServerError;
use crate::map_server::markdown;
use crate::map_server::shell::DOT_BODY_LIMIT;
use crate::map_server::state::AppState;
#[derive(Debug, Deserialize)]
#[serde(tag = "action")]
enum MutationAction {
#[serde(rename = "add_edge")]
AddEdge {
source: String,
rel: String,
target: String,
},
#[serde(rename = "remove_edge")]
RemoveEdge {
source: String,
rel: String,
target: String,
},
#[serde(rename = "rename_node")]
RenameNode {
#[serde(alias = "old")]
old_label: String,
#[serde(alias = "new")]
new_label: String,
},
}
#[derive(Debug, Deserialize)]
struct ConceptMapMutation {
#[serde(flatten)]
action: MutationAction,
#[serde(default)]
base_hash: Option<String>,
}
pub(crate) fn router(state: AppState) -> Router {
Router::new()
.route("/", get(index))
.route("/assets/{*path}", get(asset))
.route("/vendor/{*path}", get(vendor_asset))
.route("/api/health", get(health))
.route("/api/graph", get(graph))
.route("/api/refresh", post(refresh))
.route(
"/api/dot/svg",
post(dot_svg).layer(DefaultBodyLimit::max(DOT_BODY_LIMIT)),
)
.route("/api/entity/{id}/markdown", get(entity_markdown))
.route(
"/api/concept-map/{id}",
get(get_concept_map).post(mutate_concept_map),
)
.with_state(Arc::new(state))
}
async fn index() -> impl IntoResponse {
#[expect(clippy::expect_used, reason = "index.html is embedded at build time")]
let asset = assets::Assets::get("index.html").expect("index.html is embedded");
(
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
asset.data.to_vec(),
)
}
async fn asset(Path(path): Path<String>) -> Result<impl IntoResponse, MapServerError> {
assets::serve_embedded(&path)
}
async fn vendor_asset(Path(path): Path<String>) -> Result<impl IntoResponse, MapServerError> {
let full_path = format!("vendor/{path}");
assets::serve_embedded(&full_path)
}
async fn health(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let dot_result = dot_version().await;
let dot_ok = dot_result.is_ok();
let dot_version = dot_result.ok();
let graph_ok = !state.graph.read().await.nodes.is_empty();
Json(json!({
"ok": true,
"dot": { "ok": dot_ok, "version": dot_version },
"graph": { "ok": graph_ok }
}))
}
async fn dot_version() -> Result<String, MapServerError> {
use std::process::Stdio;
let child = tokio::process::Command::new("dot")
.arg("-V")
.stdout(Stdio::null())
.stderr(Stdio::piped()) .kill_on_drop(true)
.spawn()
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => MapServerError::ToolUnavailable { tool: "dot" },
_ => MapServerError::Other(e.into()),
})?;
let output = tokio::time::timeout(std::time::Duration::from_secs(2), child.wait_with_output())
.await
.map_err(|_elapsed| MapServerError::Timeout { command: "dot" })?
.map_err(|e| MapServerError::Other(e.into()))?;
if !output.status.success() {
return Err(MapServerError::CommandFailed {
command: "dot",
status: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
String::from_utf8(output.stderr)
.map(|s| s.trim().to_owned())
.map_err(|e| MapServerError::Other(e.into()))
}
async fn graph(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let snapshot = state.graph.read().await.clone();
Json(snapshot)
}
async fn refresh(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, MapServerError> {
let catalog =
crate::catalog::hydrate::scan_catalog(&state.root).map_err(MapServerError::Other)?;
let g = crate::catalog::graph::CatalogGraph::from_catalog(&catalog);
*state.graph.write().await = g;
Ok(Json(json!({"ok": true})))
}
async fn dot_svg(
State(state): State<Arc<AppState>>,
body: Bytes,
) -> Result<impl IntoResponse, MapServerError> {
if body.len() > DOT_BODY_LIMIT {
return Err(MapServerError::BodyTooLarge);
}
let svg = state.dot_renderer.render_svg(&body).await?;
Ok((
[(header::CONTENT_TYPE, "image/svg+xml; charset=utf-8")],
svg,
))
}
async fn entity_markdown(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<impl IntoResponse, MapServerError> {
let (kind_ref, num) = crate::integrity::parse_canonical_ref(&id)
.map_err(|_e| MapServerError::BadEntityId(id.clone()))?;
let key = crate::catalog::scan::EntityKey {
prefix: kind_ref.kind.prefix,
id: num,
};
let graph = state.graph.read().await;
let node_exists = graph
.nodes
.contains_key(&crate::catalog::graph::NodeKey::Entity(key));
drop(graph);
if !node_exists {
return Err(MapServerError::EntityNotFound(id));
}
let body = markdown::read_entity_markdown(&state.root, &key).await?;
Ok((
[(header::CONTENT_TYPE, "text/markdown; charset=utf-8")],
body,
))
}
async fn get_concept_map(
State(state): State<Arc<AppState>>,
Path(id_str): Path<String>,
) -> Result<impl IntoResponse, MapServerError> {
let id = concept_map::parse_ref(&id_str)
.map_err(|_e| MapServerError::BadConceptMapId(id_str.clone()))?;
let cm_root = state.root.join(concept_map::CONCEPT_MAP_DIR);
let (doc, toml_text, _body) = concept_map::read_concept_map(&cm_root, id)
.map_err(|_e| MapServerError::ConceptMapNotFound(id))?;
let (parsed, diagnostics, dsl_hash) = match concept_map::get_dsl(&toml_text) {
Ok(dsl) => {
let hash = hex::encode(Sha256::digest(dsl.as_bytes()));
let parsed = concept_map::parse_dsl(&dsl);
let diagnostics = concept_map::check(&parsed);
(parsed, diagnostics, hash)
}
Err(_) => {
return Ok(Json(json!({
"id": format!("CM-{id:03}"),
"title": doc.title,
"status": doc.status,
"description": doc.description,
"dsl_hash": "",
"nodes": [],
"edges": [],
"diagnostics": []
})));
}
};
let nodes: Vec<serde_json::Value> = parsed
.nodes
.iter()
.map(|n| json!({"key": n.key, "label": n.label}))
.collect();
let edges: Vec<serde_json::Value> = parsed
.edges
.iter()
.map(|e| {
json!({
"from_key": e.from_key,
"from_label": e.from_label,
"rel": e.rel,
"to_key": e.to_key,
"to_label": e.to_label,
"line": e.line,
})
})
.collect();
let diag_list: Vec<serde_json::Value> = diagnostics
.iter()
.map(|d| serde_json::to_value(d).unwrap_or(json!({})))
.collect();
Ok(Json(json!({
"id": format!("CM-{id:03}"),
"title": doc.title,
"status": doc.status,
"description": doc.description,
"dsl_hash": dsl_hash,
"nodes": nodes,
"edges": edges,
"diagnostics": diag_list,
})))
}
async fn mutate_concept_map(
State(state): State<Arc<AppState>>,
Path(id_str): Path<String>,
Json(body): Json<ConceptMapMutation>,
) -> Result<impl IntoResponse, MapServerError> {
let id = concept_map::parse_ref(&id_str)
.map_err(|_e| MapServerError::BadConceptMapId(id_str.clone()))?;
let cm_root = state.root.join(concept_map::CONCEPT_MAP_DIR);
let (_doc, toml_text, _body) = concept_map::read_concept_map(&cm_root, id)
.map_err(|_e| MapServerError::ConceptMapNotFound(id))?;
let old_dsl = concept_map::get_dsl(&toml_text)
.map_err(|_e| MapServerError::ConceptMapParseError("TOML is missing a `dsl` key".into()))?;
if let Some(ref base_hash) = body.base_hash {
let current_hash = hex::encode(Sha256::digest(old_dsl.as_bytes()));
if current_hash != *base_hash {
return Err(MapServerError::StaleConceptMap);
}
}
let (new_dsl_text, rename_occurrences) = match &body.action {
MutationAction::AddEdge {
source,
rel,
target,
} => {
let dsl = concept_map::add_edge_to_dsl(&old_dsl, source, rel, target)
.map_err(MapServerError::from)?;
(dsl, None)
}
MutationAction::RemoveEdge {
source,
rel,
target,
} => {
let dsl = concept_map::remove_edge_from_dsl(&old_dsl, source, rel, target)
.map_err(MapServerError::from)?;
(dsl, None)
}
MutationAction::RenameNode {
old_label,
new_label,
} => {
let (dsl, count) = concept_map::rename_node_in_dsl(&old_dsl, old_label, new_label)
.map_err(MapServerError::from)?;
(dsl, Some(count))
}
};
let updated_toml = concept_map::set_dsl(&toml_text, &new_dsl_text)
.map_err(|e| MapServerError::ConceptMapParseError(e.to_string()))?;
let name = format!("{id:03}");
let stem = format!("concept-map-{name}");
let toml_path = cm_root.join(&name).join(format!("{stem}.toml"));
std::fs::write(&toml_path, &updated_toml)
.map_err(|e| MapServerError::ConceptMapIoError(e.to_string()))?;
let fresh_hash = hex::encode(Sha256::digest(new_dsl_text.as_bytes()));
let parsed = concept_map::parse_dsl(&new_dsl_text);
let nodes: Vec<serde_json::Value> = parsed
.nodes
.iter()
.map(|n| json!({"key": n.key, "label": n.label}))
.collect();
let edges: Vec<serde_json::Value> = parsed
.edges
.iter()
.map(|e| {
json!({
"from_key": e.from_key,
"from_label": e.from_label,
"rel": e.rel,
"to_key": e.to_key,
"to_label": e.to_label,
"line": e.line,
})
})
.collect();
let mut resp = json!({
"ok": true,
"nodes": nodes,
"edges": edges,
"dsl_hash": fresh_hash,
});
if let Some(occurrences) = rename_occurrences
&& let Some(obj) = resp.as_object_mut()
{
obj.insert("occurrences".into(), json!(occurrences));
}
Ok(Json(resp))
}
#[cfg(test)]
#[expect(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
mod tests {
use super::*;
use axum::body::Body;
use http_body_util::BodyExt;
use tower::ServiceExt;
async fn send(
app: &Router,
req: axum::http::Request<Body>,
) -> (axum::http::StatusCode, axum::http::HeaderMap, String) {
let resp = app.clone().oneshot(req).await.unwrap();
let status = resp.status();
let headers = resp.headers().clone();
let body = resp.into_body().collect().await.unwrap().to_bytes();
(status, headers, String::from_utf8_lossy(&body).to_string())
}
fn json_req(method: &str, uri: &str, body: Option<Body>) -> axum::http::Request<Body> {
let mut builder = axum::http::Request::builder().method(method).uri(uri);
if let Some(b) = body {
builder = builder.header("content-type", "application/json");
builder.body(b).unwrap()
} else {
builder.body(Body::empty()).unwrap()
}
}
async fn fixture_app(root_path: &std::path::Path) -> Router {
crate::catalog::test_helpers::seed_slice(root_path, 1, &[]);
crate::catalog::test_helpers::seed_adr(root_path, 1, &[]);
crate::catalog::test_helpers::seed_requirement(root_path, 1);
crate::catalog::test_helpers::seed_knowledge(
root_path,
"ASM",
1,
"Test Assumption",
"active",
);
super::super::tests::test_app(root_path).await
}
async fn seeded_app() -> (tempfile::TempDir, Router) {
let root = crate::catalog::test_helpers::tmp();
let app = fixture_app(root.path()).await;
(root, app)
}
#[tokio::test]
async fn index_returns_200_html() {
let (status, headers, _body) =
send(&seeded_app().await.1, json_req("GET", "/", None)).await;
assert_eq!(status, 200);
assert!(
headers["content-type"]
.to_str()
.unwrap()
.starts_with("text/html")
);
}
#[tokio::test]
async fn missing_asset_returns_404() {
let (status, _headers, body) = send(
&seeded_app().await.1,
json_req("GET", "/assets/nonexistent.js", None),
)
.await;
assert_eq!(status, 404);
assert!(body.contains("asset_not_found"));
}
#[tokio::test]
async fn graph_returns_200_valid_json() {
let (status, headers, body) =
send(&seeded_app().await.1, json_req("GET", "/api/graph", None)).await;
assert_eq!(status, 200);
assert!(
headers["content-type"]
.to_str()
.unwrap()
.starts_with("application/json")
);
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(parsed.get("nodes").is_some(), "missing nodes key");
assert!(parsed.get("edges").is_some(), "missing edges key");
}
#[tokio::test]
async fn refresh_returns_200_ok() {
let app = seeded_app().await.1;
let (status, _headers, body) =
send(&app, json_req("POST", "/api/refresh", Some(Body::empty()))).await;
assert_eq!(status, 200);
assert!(body.contains("\"ok\":true"));
let (status2, _, _) = send(&app, json_req("GET", "/api/graph", None)).await;
assert_eq!(status2, 200);
}
#[tokio::test]
async fn entity_markdown_sl001_returns_200() {
let (status, headers, body) = send(
&seeded_app().await.1,
json_req("GET", "/api/entity/SL-001/markdown", None),
)
.await;
assert_eq!(status, 200);
assert!(
headers["content-type"]
.to_str()
.unwrap()
.starts_with("text/markdown")
);
assert_eq!(body, "scope\n");
}
#[tokio::test]
async fn entity_markdown_not_in_graph_returns_404() {
let (status, _headers, body) = send(
&seeded_app().await.1,
json_req("GET", "/api/entity/SL-999/markdown", None),
)
.await;
assert_eq!(status, 404);
assert!(body.contains("entity_not_found"));
assert!(body.contains("SL-999"));
}
#[tokio::test]
async fn entity_markdown_lowercase_prefix_returns_400() {
let (status, _headers, body) = send(
&seeded_app().await.1,
json_req("GET", "/api/entity/sl-001/markdown", None),
)
.await;
assert_eq!(status, 400);
assert!(body.contains("bad_entity_id"));
}
#[tokio::test]
async fn entity_markdown_bogus_prefix_returns_400() {
let (status, _headers, body) = send(
&seeded_app().await.1,
json_req("GET", "/api/entity/BOGUS-001/markdown", None),
)
.await;
assert_eq!(status, 400);
assert!(body.contains("bad_entity_id"));
}
#[tokio::test]
async fn entity_markdown_req001_returns_501() {
let (status, _headers, body) = send(
&seeded_app().await.1,
json_req("GET", "/api/entity/REQ-001/markdown", None),
)
.await;
assert_eq!(status, 501);
assert!(body.contains("markdown_not_implemented"));
}
#[tokio::test]
async fn entity_markdown_asm001_returns_200() {
let (status, headers, body) = send(
&seeded_app().await.1,
json_req("GET", "/api/entity/ASM-001/markdown", None),
)
.await;
assert_eq!(status, 200);
assert!(
headers["content-type"]
.to_str()
.unwrap()
.starts_with("text/markdown")
);
assert_eq!(body, "body\n");
}
#[tokio::test]
async fn health_returns_200() {
let (status, headers, body) =
send(&seeded_app().await.1, json_req("GET", "/api/health", None)).await;
assert_eq!(status, 200);
assert!(
headers["content-type"]
.to_str()
.unwrap()
.starts_with("application/json")
);
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["ok"], json!(true));
assert!(parsed.get("dot").is_some());
assert!(parsed.get("graph").is_some());
}
#[tokio::test]
async fn dot_svg_valid_input_returns_200() {
let body = Body::from("digraph { a -> b }");
let (status, headers, body_str) = send(
&seeded_app().await.1,
json_req("POST", "/api/dot/svg", Some(body)),
)
.await;
assert_eq!(status, 200);
assert!(
headers["content-type"]
.to_str()
.unwrap()
.starts_with("image/svg+xml")
);
assert_eq!(body_str, "<svg></svg>");
}
#[tokio::test]
async fn dot_svg_body_too_large_returns_413() {
let big = vec![b'x'; DOT_BODY_LIMIT + 1];
let body = Body::from(big);
let (status, _headers, _body_str) = send(
&seeded_app().await.1,
json_req("POST", "/api/dot/svg", Some(body)),
)
.await;
assert_eq!(status, 413);
}
#[tokio::test]
async fn dot_svg_tool_unavailable_returns_503() {
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::map_server::shell::{FakeDotMode, FakeDotRenderer};
let root = crate::catalog::test_helpers::tmp();
let root_path = root.path().to_path_buf();
crate::catalog::test_helpers::seed_slice(&root_path, 1, &[]);
let catalog = crate::catalog::hydrate::scan_catalog(&root_path).expect("scan");
let graph = crate::catalog::graph::CatalogGraph::from_catalog(&catalog);
let state = AppState {
root: root_path,
graph: Arc::new(RwLock::new(graph)),
dot_renderer: Arc::new(FakeDotRenderer {
mode: FakeDotMode::ToolUnavailable,
}),
};
let app = router(state);
let body = Body::from("digraph { a -> b }");
let (status, _headers, body_str) =
send(&app, json_req("POST", "/api/dot/svg", Some(body))).await;
assert_eq!(status, 503);
assert!(body_str.contains("tool_unavailable"));
}
#[tokio::test]
async fn entity_in_graph_but_md_missing_returns_404() {
let root = crate::catalog::test_helpers::tmp();
let root_path = root.path().to_path_buf();
crate::catalog::test_helpers::seed_slice(&root_path, 1, &[]);
let app = super::super::tests::test_app(&root_path).await;
std::fs::remove_file(root_path.join(".doctrine/slice/001/slice-001.md")).unwrap();
let (status, _headers, body) =
send(&app, json_req("GET", "/api/entity/SL-001/markdown", None)).await;
assert_eq!(status, 404);
assert!(body.contains("entity_not_found"));
assert!(body.contains("SL-001"));
}
fn seed_concept_map(root: &std::path::Path, id: u32, dsl: &str) {
use crate::catalog::test_helpers::write;
let name = format!("{id:03}");
let stem = format!("concept-map-{name}");
let toml = format!(
"id = {id}\nslug = \"cm{id}\"\ntitle = \"Test Map {id}\"\nstatus = \"draft\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\ndescription = \"\"\ndsl = '''\n{dsl}
'''\n",
);
let cm_root = std::path::Path::new(".doctrine/concept-map");
write(
root,
&format!("{}/{}/{}.toml", cm_root.display(), name, stem),
&toml,
);
write(
root,
&format!("{}/{}/{}.md", cm_root.display(), name, stem),
"# Concept Map\n",
);
let link = root.join(cm_root).join(format!("{name}-cm{id}"));
let _ = std::os::unix::fs::symlink(&name, &link);
}
async fn seeded_cm_app(dsl: &str) -> (tempfile::TempDir, Router) {
let root = crate::catalog::test_helpers::tmp();
let root_path = root.path().to_path_buf();
crate::catalog::test_helpers::seed_slice(&root_path, 1, &[]);
crate::catalog::test_helpers::seed_adr(&root_path, 1, &[]);
crate::catalog::test_helpers::seed_requirement(&root_path, 1);
seed_concept_map(&root_path, 1, dsl);
let app = super::super::tests::test_app(&root_path).await;
(root, app)
}
#[tokio::test]
async fn get_concept_map_existing_returns_200() {
let (_root, app) = seeded_cm_app("User > creates > Document").await;
let (status, headers, body) =
send(&app, json_req("GET", "/api/concept-map/CM-001", None)).await;
assert_eq!(status, 200);
assert!(
headers["content-type"]
.to_str()
.unwrap()
.starts_with("application/json")
);
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["id"], "CM-001");
assert_eq!(parsed["title"], "Test Map 1");
assert_eq!(parsed["status"], "draft");
assert!(!parsed["dsl_hash"].as_str().unwrap().is_empty());
let nodes = parsed["nodes"].as_array().unwrap();
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0]["key"], "user");
assert_eq!(nodes[1]["key"], "document");
let edges = parsed["edges"].as_array().unwrap();
assert_eq!(edges.len(), 1);
assert_eq!(edges[0]["from_key"], "user");
assert_eq!(edges[0]["rel"], "creates");
assert_eq!(edges[0]["to_key"], "document");
let diags = parsed["diagnostics"].as_array().unwrap();
assert!(
diags.is_empty(),
"clean map should have no diagnostics, got: {diags:?}"
);
}
#[tokio::test]
async fn get_concept_map_nonexistent_returns_404() {
let (_root, app) = seeded_cm_app("User > creates > Document").await;
let (status, _headers, body) =
send(&app, json_req("GET", "/api/concept-map/CM-999", None)).await;
assert_eq!(status, 404);
assert!(body.contains("not_found"));
assert!(body.contains("CM-999"));
}
#[tokio::test]
async fn get_concept_map_bad_id_returns_400() {
let (_root, app) = seeded_cm_app("User > creates > Document").await;
let (status, _headers, body) =
send(&app, json_req("GET", "/api/concept-map/garbage", None)).await;
assert_eq!(status, 400);
assert!(body.contains("bad_concept_map_id"));
}
#[tokio::test]
async fn get_concept_map_no_dsl_returns_200_empty() {
let root = crate::catalog::test_helpers::tmp();
let root_path = root.path().to_path_buf();
crate::catalog::test_helpers::seed_slice(&root_path, 1, &[]);
{
let cm_dir = root_path.join(".doctrine/concept-map/001");
std::fs::create_dir_all(&cm_dir).unwrap();
std::fs::write(
cm_dir.join("concept-map-001.toml"),
"id = 1\nslug = \"cm1\"\ntitle = \"Empty Map\"\nstatus = \"draft\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\ndescription = \"\"\n",
)
.unwrap();
std::fs::write(cm_dir.join("concept-map-001.md"), "# Empty\n").unwrap();
let _ =
std::os::unix::fs::symlink("001", root_path.join(".doctrine/concept-map/001-cm1"));
}
let app = super::super::tests::test_app(&root_path).await;
let (status, _headers, body) =
send(&app, json_req("GET", "/api/concept-map/CM-001", None)).await;
assert_eq!(status, 200);
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["dsl_hash"], "");
assert!(parsed["nodes"].as_array().unwrap().is_empty());
assert!(parsed["edges"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn mutate_add_edge_returns_200() {
let (_root, app) = seeded_cm_app("User > creates > Document").await;
let body = Body::from(
r#"{"action":"add_edge","source":"Document","rel":"belongs to","target":"Workspace"}"#,
);
let (status, _headers, body_str) = send(
&app,
json_req("POST", "/api/concept-map/CM-001", Some(body)),
)
.await;
assert_eq!(status, 200, "body: {body_str}");
let parsed: serde_json::Value = serde_json::from_str(&body_str).unwrap();
assert_eq!(parsed["ok"], true);
let edges = parsed["edges"].as_array().unwrap();
assert_eq!(edges.len(), 2);
}
#[tokio::test]
async fn mutate_add_edge_duplicate_returns_409() {
let (_root, app) = seeded_cm_app("User > creates > Document").await;
let body = Body::from(
r#"{"action":"add_edge","source":"User","rel":"creates","target":"Document"}"#,
);
let (status, _headers, body_str) = send(
&app,
json_req("POST", "/api/concept-map/CM-001", Some(body)),
)
.await;
assert_eq!(status, 409, "body: {body_str}");
assert!(body_str.contains("duplicate_edge"));
}
#[tokio::test]
async fn mutate_add_edge_empty_field_returns_400() {
let (_root, app) = seeded_cm_app("User > creates > Document").await;
let body =
Body::from(r#"{"action":"add_edge","source":"","rel":"creates","target":"Document"}"#);
let (status, _headers, body_str) = send(
&app,
json_req("POST", "/api/concept-map/CM-001", Some(body)),
)
.await;
assert_eq!(status, 400, "body: {body_str}");
assert!(body_str.contains("empty_field"));
}
#[tokio::test]
async fn mutate_remove_edge_returns_200() {
let (_root, app) = seeded_cm_app("User > creates > Document\nDoc > relates > Note").await;
let body = Body::from(
r#"{"action":"remove_edge","source":"Doc","rel":"relates","target":"Note"}"#,
);
let (status, _headers, body_str) = send(
&app,
json_req("POST", "/api/concept-map/CM-001", Some(body)),
)
.await;
assert_eq!(status, 200, "body: {body_str}");
let parsed: serde_json::Value = serde_json::from_str(&body_str).unwrap();
assert_eq!(parsed["ok"], true);
assert_eq!(parsed["edges"].as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn mutate_remove_edge_not_found_returns_404() {
let (_root, app) = seeded_cm_app("User > creates > Document").await;
let body = Body::from(
r#"{"action":"remove_edge","source":"Ghost","rel":"haunts","target":"House"}"#,
);
let (status, _headers, body_str) = send(
&app,
json_req("POST", "/api/concept-map/CM-001", Some(body)),
)
.await;
assert_eq!(status, 404, "body: {body_str}");
assert!(body_str.contains("edge_not_found"));
}
#[tokio::test]
async fn mutate_rename_node_returns_200() {
let (_root, app) = seeded_cm_app("User > creates > Document").await;
let body = Body::from(r#"{"action":"rename_node","old_label":"User","new_label":"Actor"}"#);
let (status, _headers, body_str) = send(
&app,
json_req("POST", "/api/concept-map/CM-001", Some(body)),
)
.await;
assert_eq!(status, 200, "body: {body_str}");
let parsed: serde_json::Value = serde_json::from_str(&body_str).unwrap();
assert_eq!(parsed["ok"], true);
let nodes = parsed["nodes"].as_array().unwrap();
assert!(nodes.iter().any(|n| n["label"] == "Actor"));
assert!(!nodes.iter().any(|n| n["label"] == "User"));
let edges = parsed["edges"].as_array().unwrap();
assert!(edges.iter().any(|e| e["from_label"] == "Actor"));
}
#[tokio::test]
async fn mutate_rename_node_persists_to_disk() {
let (root, app) = seeded_cm_app("User > creates > Document").await;
let body = Body::from(r#"{"action":"rename_node","old_label":"User","new_label":"Actor"}"#);
let (status, _headers, _body_str) = send(
&app,
json_req("POST", "/api/concept-map/CM-001", Some(body)),
)
.await;
assert_eq!(status, 200);
let toml_content = std::fs::read_to_string(
root.path()
.join(".doctrine/concept-map/001/concept-map-001.toml"),
)
.unwrap();
assert!(
toml_content.contains("Actor > creates > Document"),
"TOML should contain renamed node, got:\n{toml_content}"
);
}
#[tokio::test]
async fn mutate_rename_node_collision_returns_409() {
let (_root, app) = seeded_cm_app("User > creates > Document").await;
let body =
Body::from(r#"{"action":"rename_node","old_label":"User","new_label":"Document"}"#);
let (status, _headers, body_str) = send(
&app,
json_req("POST", "/api/concept-map/CM-001", Some(body)),
)
.await;
assert_eq!(status, 409, "body: {body_str}");
assert!(body_str.contains("node_collision"));
}
#[tokio::test]
async fn mutate_stale_write_returns_409() {
let (_root, app) = seeded_cm_app("User > creates > Document").await;
let body = Body::from(
r#"{"action":"add_edge","source":"Doc","rel":"uses","target":"Note","base_hash":"deadbeef"}"#,
);
let (status, _headers, body_str) = send(
&app,
json_req("POST", "/api/concept-map/CM-001", Some(body)),
)
.await;
assert_eq!(status, 409, "body: {body_str}");
assert!(body_str.contains("stale_concept_map"));
}
#[tokio::test]
async fn mutate_stale_write_correct_hash_succeeds() {
let (_root, app) = seeded_cm_app("User > creates > Document").await;
let (_, _, get_body) = send(&app, json_req("GET", "/api/concept-map/CM-001", None)).await;
let parsed: serde_json::Value = serde_json::from_str(&get_body).unwrap();
let current_hash = parsed["dsl_hash"].as_str().unwrap();
let body = Body::from(format!(
r#"{{"action":"add_edge","source":"Doc","rel":"uses","target":"Note","base_hash":"{}"}}"#,
current_hash
));
let (status, _headers, body_str) = send(
&app,
json_req("POST", "/api/concept-map/CM-001", Some(body)),
)
.await;
assert_eq!(status, 200, "body: {body_str}");
let p: serde_json::Value = serde_json::from_str(&body_str).unwrap();
assert_eq!(p["ok"], true);
assert_eq!(p["edges"].as_array().unwrap().len(), 2);
}
#[tokio::test]
async fn mutate_unknown_action_returns_422() {
let (_root, app) = seeded_cm_app("User > creates > Document").await;
let body = Body::from(r#"{"action":"fly_to_moon"}"#);
let (status, _headers, body_str) = send(
&app,
json_req("POST", "/api/concept-map/CM-001", Some(body)),
)
.await;
assert_eq!(status, 422, "body: {body_str}");
assert!(body_str.contains("fly_to_moon"));
}
#[tokio::test]
async fn entity_markdown_cm001_returns_200() {
let (_root, app) = seeded_cm_app("User > creates > Document").await;
let (status, headers, body) =
send(&app, json_req("GET", "/api/entity/CM-001/markdown", None)).await;
assert_eq!(status, 200);
assert!(
headers["content-type"]
.to_str()
.unwrap()
.starts_with("text/markdown")
);
assert_eq!(body, "# Concept Map\n");
}
}