use std::path::{Path, PathBuf};
use crate::entity;
use crate::fsutil::safe_join;
use crate::integrity;
use crate::map_server::error::MapServerError;
use crate::memory::{MEMORY_ITEMS_DIR, MEMORY_SHIPPED_DIR};
pub(crate) async fn read_entity_markdown(
root: &Path,
key: &crate::catalog::scan::EntityKey,
) -> Result<String, MapServerError> {
let path = entity_md_path(root, key)?;
tokio::fs::read_to_string(&path)
.await
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => MapServerError::EntityNotFound(key.canonical()),
_ => MapServerError::Other(e.into()),
})
}
pub(crate) async fn read_memory_markdown(root: &Path, uid: &str) -> Result<String, MapServerError> {
for dir in [MEMORY_ITEMS_DIR, MEMORY_SHIPPED_DIR] {
let dir_path = safe_join(root, Path::new(dir)).map_err(MapServerError::Other)?;
let md_path = safe_join(&dir_path, Path::new(uid))
.map_err(MapServerError::Other)?
.join("memory.md");
match tokio::fs::read_to_string(&md_path).await {
Ok(body) => return Ok(body),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(MapServerError::Other(e.into())),
}
}
Err(MapServerError::EntityNotFound(uid.to_string()))
}
pub(crate) async fn read_memory_markdown_linked(
root: &Path,
uid: &str,
) -> Result<String, MapServerError> {
let body = read_memory_markdown(root, uid).await?;
let Ok(all) = crate::memory::collect_all(root) else {
return Ok(body);
};
let (known_uids, key_to_uid) = crate::memory::known_link_maps(&all);
let title_by_uid: std::collections::BTreeMap<String, String> = all
.iter()
.map(|m| (m.uid.clone(), m.title.clone()))
.collect();
Ok(crate::links::linkify_wikilinks(
&body,
&known_uids,
&key_to_uid,
&title_by_uid,
))
}
fn entity_md_path(
root: &Path,
key: &crate::catalog::scan::EntityKey,
) -> Result<PathBuf, MapServerError> {
if key.prefix == "REQ" {
return Err(MapServerError::MarkdownNotImplemented("REQ"));
}
let kind_ref = integrity::kind_by_prefix(key.prefix)
.ok_or_else(|| MapServerError::BadEntityId(key.canonical()))?;
Ok(entity::id_path(
root,
kind_ref.kind,
key.id,
entity::Ext::Md,
))
}
#[cfg(test)]
#[expect(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
mod tests {
use super::*;
use crate::catalog::scan::EntityKey;
fn key(prefix: &'static str, id: u32) -> EntityKey {
EntityKey { prefix, id }
}
fn root() -> &'static Path {
Path::new("/corpus")
}
#[test]
fn path_for_slice() {
let p = entity_md_path(root(), &key("SL", 1)).unwrap();
assert_eq!(p, PathBuf::from("/corpus/.doctrine/slice/001/slice-001.md"));
}
#[test]
fn path_for_adr() {
let p = entity_md_path(root(), &key("ADR", 12)).unwrap();
assert_eq!(p, PathBuf::from("/corpus/.doctrine/adr/012/adr-012.md"));
}
#[test]
fn path_for_memory_kind() {
let p = entity_md_path(root(), &key("ASM", 1)).unwrap();
assert_eq!(
p,
PathBuf::from("/corpus/.doctrine/knowledge/assumption/001/record-001.md")
);
}
#[test]
fn req_returns_not_implemented() {
let err = entity_md_path(root(), &key("REQ", 1)).unwrap_err();
match err {
MapServerError::MarkdownNotImplemented(prefix) => {
assert_eq!(prefix, "REQ");
}
other => panic!("expected MarkdownNotImplemented, got {:?}", other),
}
}
#[test]
fn path_for_concept_map() {
let p = entity_md_path(root(), &key("CM", 1)).unwrap();
assert_eq!(
p,
PathBuf::from("/corpus/.doctrine/concept-map/001/concept-map-001.md")
);
}
#[test]
fn unknown_prefix_returns_bad_entity_id() {
let err = entity_md_path(root(), &key("BOGUS", 1)).unwrap_err();
match err {
MapServerError::BadEntityId(ref id) => {
assert_eq!(id, "BOGUS-001");
}
other => panic!("expected BadEntityId, got {:?}", other),
}
}
#[tokio::test]
async fn read_returns_file_content() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let dir = root.join(".doctrine/slice/001");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("slice-001.md"), "# hello\n").unwrap();
let content = read_entity_markdown(root, &key("SL", 1)).await.unwrap();
assert_eq!(content, "# hello\n");
}
#[tokio::test]
async fn read_missing_file_returns_entity_not_found() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let dir = root.join(".doctrine/slice/001");
std::fs::create_dir_all(&dir).unwrap();
let err = read_entity_markdown(root, &key("SL", 1)).await.unwrap_err();
match err {
MapServerError::EntityNotFound(ref id) => {
assert_eq!(id, "SL-001");
}
other => panic!("expected EntityNotFound, got {:?}", other),
}
}
fn memory_paths(root: &Path, uid: &str) -> (PathBuf, PathBuf) {
(
root.join(format!(".doctrine/memory/items/{uid}/memory.md")),
root.join(format!(".doctrine/memory/shipped/{uid}/memory.md")),
)
}
#[tokio::test]
async fn read_memory_returns_items_content_when_present() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let uid = "test-memory-item";
let (items_md, _) = memory_paths(root, uid);
std::fs::create_dir_all(items_md.parent().unwrap()).unwrap();
std::fs::write(&items_md, "# local override\n").unwrap();
let content = read_memory_markdown(root, uid).await.unwrap();
assert_eq!(content, "# local override\n");
}
#[tokio::test]
async fn read_memory_falls_back_to_shipped_when_items_missing() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let uid = "test-memory-item";
let (_, shipped_md) = memory_paths(root, uid);
std::fs::create_dir_all(shipped_md.parent().unwrap()).unwrap();
std::fs::write(&shipped_md, "# shipped body\n").unwrap();
let content = read_memory_markdown(root, uid).await.unwrap();
assert_eq!(content, "# shipped body\n");
}
#[tokio::test]
async fn read_memory_missing_in_both_locations_returns_not_found() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let uid = "missing-memory-item";
let err = read_memory_markdown(root, uid).await.unwrap_err();
match err {
MapServerError::EntityNotFound(ref missing_uid) => {
assert_eq!(missing_uid, uid);
}
other => panic!("expected EntityNotFound, got {:?}", other),
}
}
fn seed_memory(root: &Path, uid: &str, key: Option<&str>, title: &str, body: &str) {
let key_line = key.map_or(String::new(), |k| format!("memory_key = \"{k}\"\n"));
let dir = root.join(format!(".doctrine/memory/items/{uid}"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("memory.toml"),
format!(
"memory_uid = \"{uid}\"\n\
{key_line}\
schema_version = 1\n\
memory_type = \"pattern\"\n\
status = \"active\"\n\
title = \"{title}\"\n\
summary = \"summary\"\n\
created = \"2026-01-01\"\n\
updated = \"2026-01-01\"\n\
[scope]\n\
workspace = \"default\"\n\
[git]\n\
repo = \"repo\"\n\
[trust]\n\
level = \"medium\"\n\
[ranking]\n\
severity = \"none\"\n\
weight = 0\n"
),
)
.unwrap();
std::fs::write(dir.join("memory.md"), body).unwrap();
}
#[tokio::test]
async fn linked_reader_resolves_wikilink_to_focus_anchor() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let target_uid = "mem_00000000000000000000000000000001";
let source_uid = "mem_00000000000000000000000000000002";
seed_memory(
root,
target_uid,
Some("mem.pattern.target"),
"Target Mem",
"target body\n",
);
seed_memory(
root,
source_uid,
Some("mem.pattern.source"),
"Source Mem",
"see [[mem.pattern.target]] and [[mem.pattern.missing]]\n",
);
let out = read_memory_markdown_linked(root, source_uid).await.unwrap();
assert!(
out.contains(&format!("[Target Mem](#/focus/{target_uid})")),
"expected focus anchor, got: {out}"
);
assert!(
out.contains("[[mem.pattern.missing]]"),
"unresolved link mangled: {out}"
);
}
#[tokio::test]
async fn read_memory_propagates_non_not_found_io_errors() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let uid = "broken-memory-item";
let items_dir = root.join(format!(".doctrine/memory/items/{uid}"));
std::fs::create_dir_all(items_dir.parent().unwrap()).unwrap();
std::fs::write(&items_dir, "not a directory").unwrap();
let err = read_memory_markdown(root, uid).await.unwrap_err();
match err {
MapServerError::Other(other) => {
let io = other.downcast_ref::<std::io::Error>().unwrap();
assert_eq!(io.kind(), std::io::ErrorKind::NotADirectory);
}
other => panic!("expected Other, got {:?}", other),
}
}
}