use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
use std::io;
use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use regex::Regex;
use crate::index::IndexRecord;
use crate::query::Query;
use crate::store::{Layer, Store, StoreError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Incoming,
Outgoing,
Both,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextNode {
pub path: PathBuf,
pub summary: String,
pub type_: Option<String>,
pub hops: u32,
pub via: Option<(PathBuf, Direction)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextSlice {
pub seed: PathBuf,
pub nodes: Vec<ContextNode>,
}
pub fn backlinks(store: &Store, path: &Path) -> Result<Vec<PathBuf>, StoreError> {
backlinks_filtered(store, path, &[], None)
}
pub fn backlinks_filtered(
store: &Store,
path: &Path,
types: &[String],
layer: Option<Layer>,
) -> Result<Vec<PathBuf>, StoreError> {
let target = normalize_target(path);
if target.is_empty() {
return Ok(Vec::new());
}
if types.is_empty() && layer.is_none() {
let mut hits: BTreeSet<PathBuf> = BTreeSet::new();
for rel in store.find_links_to(path)? {
if !is_content_rel(&rel) {
continue;
}
let linker = normalize_target(&rel);
if linker.is_empty() || linker == target {
continue;
}
hits.insert(PathBuf::from(linker));
}
return Ok(hits.into_iter().collect());
}
let mut hits: BTreeSet<PathBuf> = BTreeSet::new();
for candidate in candidate_records(store, types, layer)? {
let rel = &candidate.path;
let candidate_target = normalize_target(rel);
if candidate_target.is_empty() || candidate_target == target {
continue;
}
if file_links_to(store, rel, &target)? {
hits.insert(PathBuf::from(candidate_target));
}
}
Ok(hits.into_iter().collect())
}
pub fn forwardlinks(store: &Store, path: &Path) -> Result<Vec<PathBuf>, StoreError> {
let self_target = normalize_target(path);
let abs = match resolve_existing(store, path) {
Some(a) => a,
None => return Ok(Vec::new()),
};
let body = match std::fs::read_to_string(&abs) {
Ok(b) => b,
Err(e) if e.kind() == io::ErrorKind::InvalidData => return Ok(Vec::new()),
Err(e) => return Err(StoreError::Io(e)),
};
let mut out: BTreeSet<PathBuf> = BTreeSet::new();
for target in extract_link_targets(&body) {
if target.is_empty() || target == self_target {
continue;
}
out.insert(PathBuf::from(target));
}
Ok(out.into_iter().collect())
}
fn candidate_records(
store: &Store,
types: &[String],
layer: Option<Layer>,
) -> Result<Vec<IndexRecord>, StoreError> {
if types.is_empty() {
return store.sidecar_records(layer);
}
let mut by_path: std::collections::BTreeMap<PathBuf, IndexRecord> =
std::collections::BTreeMap::new();
for type_ in types {
let mut q = Query::new().with_type(type_);
if let Some(layer) = layer {
q = q.with_layer(layer);
}
for rec in q.execute(store)? {
by_path.insert(rec.path.clone(), rec);
}
}
Ok(by_path.into_values().collect())
}
fn file_links_to(store: &Store, rel: &Path, target: &str) -> Result<bool, StoreError> {
let edges = forwardlinks(store, rel)?;
Ok(edges.iter().any(|e| e.as_os_str() == target))
}
pub fn neighborhood(
store: &Store,
seed: &Path,
hops: u32,
types: &[String],
direction: Direction,
) -> Result<ContextSlice, StoreError> {
let seed_rel = PathBuf::from(normalize_target(seed));
let type_filter: HashSet<&str> = types.iter().map(|s| s.as_str()).collect();
let mut discovered: HashSet<PathBuf> = HashSet::new();
discovered.insert(seed_rel.clone());
let mut nodes: Vec<ContextNode> = Vec::new();
let mut frontier: VecDeque<PathBuf> = VecDeque::new();
frontier.push_back(seed_rel.clone());
let mut hop = 0u32;
while hop < hops && !frontier.is_empty() {
hop += 1;
let level_size = frontier.len();
for _ in 0..level_size {
let current = frontier.pop_front().expect("frontier non-empty");
let mut edges: Vec<(PathBuf, Direction)> = Vec::new();
if matches!(direction, Direction::Outgoing | Direction::Both) {
for nbr in forwardlinks(store, ¤t)? {
edges.push((nbr, Direction::Outgoing));
}
}
if matches!(direction, Direction::Incoming | Direction::Both) {
for nbr in backlinks(store, ¤t)? {
edges.push((nbr, Direction::Incoming));
}
}
for (neighbor, dir) in edges {
if !discovered.insert(neighbor.clone()) {
continue;
}
let (summary, type_) = read_summary_and_type(store, &neighbor);
let include = type_filter.is_empty()
|| type_
.as_deref()
.map(|t| type_filter.contains(t))
.unwrap_or(false);
if include {
nodes.push(ContextNode {
path: neighbor.clone(),
summary,
type_,
hops: hop,
via: Some((current.clone(), dir)),
});
}
frontier.push_back(neighbor);
}
}
}
Ok(ContextSlice {
seed: seed_rel,
nodes,
})
}
pub fn orphans(store: &Store, layer: Option<Layer>) -> Result<Vec<PathBuf>, StoreError> {
let all = walk_content_files(store)?;
let mut linked_to: HashSet<PathBuf> = HashSet::new();
let mut has_outgoing: HashMap<PathBuf, bool> = HashMap::new();
for abs in &all {
let rel = match rel_path(store, abs) {
Some(r) => r,
None => continue,
};
let self_target = normalize_target(&rel);
let body = match std::fs::read_to_string(abs) {
Ok(b) => b,
Err(e) if e.kind() == io::ErrorKind::InvalidData => String::new(),
Err(e) => return Err(StoreError::Io(e)),
};
let mut outgoing = false;
for target in extract_link_targets(&body) {
if target.is_empty() || target == self_target {
continue;
}
outgoing = true;
linked_to.insert(PathBuf::from(target));
}
has_outgoing.insert(rel, outgoing);
}
let mut out: BTreeSet<PathBuf> = BTreeSet::new();
for abs in &all {
let rel = match rel_path(store, abs) {
Some(r) => r,
None => continue,
};
if let Some(layer) = layer {
if path_layer(&rel) != Some(layer) {
continue;
}
}
let outgoing = has_outgoing.get(&rel).copied().unwrap_or(false);
let incoming = linked_to.contains(&PathBuf::from(normalize_target(&rel)));
if !outgoing && !incoming {
out.insert(rel);
}
}
Ok(out.into_iter().collect())
}
pub fn rewrite_links_to(text: &str, old: &Path, new: &Path) -> String {
let old_target = normalize_target(old);
let new_target = normalize_target(new);
if old_target.is_empty() {
return text.to_string();
}
let re = rewrite_link_re();
let mut out = String::with_capacity(text.len());
let mut last = 0usize;
for caps in re.captures_iter(text) {
let whole = caps.get(0).expect("group 0 always present");
let raw_target = caps.get(1).map(|m| m.as_str()).unwrap_or("");
if normalize_target(Path::new(raw_target)) != old_target {
continue;
}
out.push_str(&text[last..whole.start()]);
out.push_str("[[");
out.push_str(&new_target);
if let Some(display) = caps.get(2) {
out.push('|');
out.push_str(display.as_str());
}
out.push_str("]]");
last = whole.end();
}
out.push_str(&text[last..]);
out
}
fn rewrite_link_re() -> Regex {
Regex::new(r"\[\[([^\]\|\n]+?)(?:\|([^\]\n]*))?\]\]")
.expect("static wiki-link rewrite regex compiles")
}
fn normalize_target(path: &Path) -> String {
let mut s = path.to_string_lossy().replace('\\', "/");
while let Some(rest) = s.strip_prefix("./") {
s = rest.to_string();
}
let s = s.trim_start_matches('/');
let s = s.strip_suffix(".md").unwrap_or(s);
s.trim().to_string()
}
fn wiki_link_re() -> Regex {
Regex::new(r"\[\[([^\]\|\n]+?)(?:\|[^\]\n]*)?\]\]").expect("static wiki-link regex compiles")
}
fn extract_link_targets(body: &str) -> Vec<String> {
let re = wiki_link_re();
re.captures_iter(body)
.filter_map(|c| c.get(1))
.map(|m| normalize_target(Path::new(m.as_str().trim())))
.filter(|t| !t.is_empty())
.collect()
}
fn resolve_existing(store: &Store, store_relative: &Path) -> Option<PathBuf> {
let direct = store.root.join(store_relative);
if direct.is_file() {
return Some(direct);
}
let normalized = normalize_target(store_relative);
let with_md = store.root.join(format!("{normalized}.md"));
if with_md.is_file() {
return Some(with_md);
}
None
}
fn rel_path(store: &Store, abs: &Path) -> Option<PathBuf> {
abs.strip_prefix(&store.root).ok().map(|p| p.to_path_buf())
}
fn path_layer(rel: &Path) -> Option<Layer> {
let first = rel.components().next()?;
match first.as_os_str().to_str()? {
"sources" => Some(Layer::Sources),
"records" => Some(Layer::Records),
"wiki" => Some(Layer::Wiki),
_ => None,
}
}
fn is_content_rel(rel: &Path) -> bool {
if path_layer(rel).is_none() {
return false;
}
match rel.extension().and_then(|e| e.to_str()) {
Some("md") => {}
_ => return false,
}
rel.file_name().and_then(|n| n.to_str()) != Some("index.md")
}
fn walk_content_files(store: &Store) -> Result<Vec<PathBuf>, StoreError> {
let mut out = Vec::new();
for layer in Layer::all() {
let dir = store.root.join(layer_dir_name(layer));
if !dir.is_dir() {
continue;
}
let walker = WalkBuilder::new(&dir)
.hidden(true)
.git_ignore(true)
.git_global(false)
.require_git(false)
.build();
for result in walker {
let entry = result.map_err(|e| StoreError::Search {
root: store.root.clone(),
message: format!("walk failed: {e}"),
})?;
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let abs = entry.into_path();
if let Some(rel) = rel_path(store, &abs) {
if is_content_rel(&rel) {
out.push(abs);
}
}
}
}
Ok(out)
}
fn layer_dir_name(layer: Layer) -> &'static str {
match layer {
Layer::Sources => "sources",
Layer::Records => "records",
Layer::Wiki => "wiki",
}
}
fn read_summary_and_type(store: &Store, rel: &Path) -> (String, Option<String>) {
let abs = match resolve_existing(store, rel) {
Some(a) => a,
None => return (String::new(), None),
};
let text = match std::fs::read_to_string(&abs) {
Ok(t) => t,
Err(_) => return (String::new(), None),
};
let yaml = match frontmatter_block(&text) {
Some(y) => y,
None => return (String::new(), None),
};
let value: serde_yml::Value = match serde_yml::from_str(yaml) {
Ok(v) => v,
Err(_) => return (String::new(), None),
};
let summary = value
.get("summary")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let type_ = value
.get("type")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
(summary, type_)
}
fn frontmatter_block(text: &str) -> Option<&str> {
let rest = text
.strip_prefix("---\n")
.or_else(|| text.strip_prefix("---\r\n"))?;
let mut idx = 0usize;
for line in rest.split_inclusive('\n') {
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed == "---" {
return Some(&rest[..idx]);
}
idx += line.len();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
use crate::parser::Config;
struct Fixture {
_tmp: TempDir,
store: Store,
}
impl Fixture {
fn new() -> Self {
let tmp = TempDir::new().expect("tempdir");
let root = tmp.path().to_path_buf();
fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").expect("DB.md");
let store = Store {
root,
config: Config::default(),
};
Fixture { _tmp: tmp, store }
}
fn write(&self, rel: &str, type_: &str, summary: &str, body: &str) {
let abs = self.store.root.join(rel);
fs::create_dir_all(abs.parent().unwrap()).expect("mkdir");
let contents = format!(
"---\ntype: {type_}\ncreated: 2026-05-01T00:00:00Z\nupdated: 2026-05-01T00:00:00Z\nsummary: {summary}\n---\n{body}\n"
);
fs::write(&abs, contents).expect("write file");
}
fn write_raw(&self, rel: &str, contents: &str) {
let abs = self.store.root.join(rel);
fs::create_dir_all(abs.parent().unwrap()).expect("mkdir");
fs::write(&abs, contents).expect("write raw");
}
fn reindex(&self) {
crate::index::Index::rebuild_all(&self.store).expect("rebuild sidecars");
}
fn p(&self, rel: &str) -> PathBuf {
PathBuf::from(rel)
}
}
fn paths(v: &[PathBuf]) -> Vec<String> {
v.iter()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.collect()
}
#[test]
fn normalize_strips_md_and_leading_dotslash() {
assert_eq!(
normalize_target(Path::new("records/contacts/sarah.md")),
"records/contacts/sarah"
);
assert_eq!(
normalize_target(Path::new("./wiki/people/elena")),
"wiki/people/elena"
);
assert_eq!(normalize_target(Path::new("/records/x")), "records/x");
assert_eq!(
normalize_target(Path::new("a/b")),
normalize_target(Path::new("a/b.md"))
);
}
#[test]
fn extract_handles_display_text_and_md_suffix() {
let body = "See [[wiki/people/sarah-chen|Sarah]] and [[records/contacts/elena.md]].";
let got = extract_link_targets(body);
assert_eq!(
got,
vec!["wiki/people/sarah-chen", "records/contacts/elena"]
);
}
#[test]
fn extract_ignores_external_markdown_links() {
let body = "[Acme](https://acme.io) but [[records/companies/acme]] is internal.";
let got = extract_link_targets(body);
assert_eq!(got, vec!["records/companies/acme"]);
}
#[test]
fn extract_display_text_is_not_treated_as_a_target() {
let body = "[[records/contacts/sarah|sources/emails/decoy]]";
let got = extract_link_targets(body);
assert_eq!(got, vec!["records/contacts/sarah"]);
}
#[test]
fn rewrite_plain_link_to_canonical_new_target() {
let got = rewrite_links_to(
"See [[records/contacts/sarah-chen]] today.",
Path::new("records/contacts/sarah-chen"),
Path::new("records/contacts/sarah-chen-acme"),
);
assert_eq!(got, "See [[records/contacts/sarah-chen-acme]] today.");
}
#[test]
fn rewrite_preserves_display_override() {
let got = rewrite_links_to(
"With [[records/contacts/sarah-chen|Sarah]].",
Path::new("records/contacts/sarah-chen"),
Path::new("records/contacts/sarah-chen-acme"),
);
assert_eq!(got, "With [[records/contacts/sarah-chen-acme|Sarah]].");
}
#[test]
fn rewrite_matches_md_suffixed_old_and_emits_bare_new() {
let got = rewrite_links_to(
"[[records/contacts/sarah-chen.md]]",
Path::new("records/contacts/sarah-chen"),
Path::new("records/contacts/new.md"),
);
assert_eq!(got, "[[records/contacts/new]]");
}
#[test]
fn rewrite_leaves_prefix_collisions_and_short_form_untouched() {
let input = "[[records/contacts/sarah-chen-jr]] [[sarah-chen]] [[wiki/topics/x]]";
let got = rewrite_links_to(
input,
Path::new("records/contacts/sarah-chen"),
Path::new("records/contacts/new"),
);
assert_eq!(got, input, "no genuine edge to the seed → text unchanged");
}
#[test]
fn rewrite_handles_multiple_occurrences_and_mixed_spellings() {
let got = rewrite_links_to(
"[[records/x]] then [[./records/x]] and [[records/x.md|d]] end",
Path::new("records/x"),
Path::new("records/y"),
);
assert_eq!(
got,
"[[records/y]] then [[records/y]] and [[records/y|d]] end"
);
}
#[test]
fn rewrite_retargets_exactly_the_edges_the_core_parser_sees() {
let fx = Fixture::new();
let body = "Met [[records/contacts/sarah.md|Sarah]] and not [[records/contacts/sarah-2]].";
fx.write("wiki/people/bio.md", "wiki-page", "bio", body);
let edges = forwardlinks(&fx.store, &fx.p("wiki/people/bio.md")).unwrap();
assert_eq!(
paths(&edges),
vec!["records/contacts/sarah", "records/contacts/sarah-2"],
"fixture must contain exactly the two edges this test reasons about"
);
let got = rewrite_links_to(
body,
Path::new("records/contacts/sarah"),
Path::new("records/contacts/sarah-chen"),
);
assert_eq!(
got,
"Met [[records/contacts/sarah-chen|Sarah]] and not [[records/contacts/sarah-2]]."
);
fx.write("wiki/people/bio.md", "wiki-page", "bio", &got);
let after = forwardlinks(&fx.store, &fx.p("wiki/people/bio.md")).unwrap();
assert_eq!(
paths(&after),
vec!["records/contacts/sarah-2", "records/contacts/sarah-chen"],
"after rewrite the parser must see the new target and not the old"
);
}
#[test]
fn rewrite_empty_old_target_is_a_no_op() {
let input = "[[records/x]] [[]] text";
let got = rewrite_links_to(input, Path::new(""), Path::new("records/y"));
assert_eq!(got, input);
}
#[test]
fn rewrite_no_match_returns_input_unchanged() {
let input = "no links, [external](https://x), and [[wiki/topics/y]]";
let got = rewrite_links_to(input, Path::new("records/x"), Path::new("records/z"));
assert_eq!(got, input);
}
#[test]
fn forwardlinks_returns_sorted_deduped_targets_excluding_self() {
let fx = Fixture::new();
fx.write(
"wiki/projects/renewal.md",
"wiki-page",
"Renewal project",
"Links: [[records/contacts/sarah]] [[records/companies/acme]] [[records/contacts/sarah]] and itself [[wiki/projects/renewal]].",
);
let got = forwardlinks(&fx.store, &fx.p("wiki/projects/renewal.md")).unwrap();
assert_eq!(
paths(&got),
vec!["records/companies/acme", "records/contacts/sarah"]
);
}
#[test]
fn forwardlinks_picks_up_wiki_links_in_frontmatter() {
let fx = Fixture::new();
fx.write_raw(
"records/meetings/m1.md",
"---\ntype: meeting\ncreated: 2026-05-01T00:00:00Z\nupdated: 2026-05-01T00:00:00Z\nsummary: Renewal sync\ncompany: [[records/companies/acme]]\nattendees:\n - [[records/contacts/sarah]]\n - [[records/contacts/elena]]\n---\nNotes about [[wiki/projects/renewal]].\n",
);
let got = forwardlinks(&fx.store, &fx.p("records/meetings/m1.md")).unwrap();
assert_eq!(
paths(&got),
vec![
"records/companies/acme",
"records/contacts/elena",
"records/contacts/sarah",
"wiki/projects/renewal",
]
);
}
#[test]
fn forwardlinks_missing_file_is_empty_not_error() {
let fx = Fixture::new();
let got = forwardlinks(&fx.store, &fx.p("wiki/people/ghost.md")).unwrap();
assert!(got.is_empty());
}
#[test]
fn forwardlinks_resolves_seed_given_without_md_extension() {
let fx = Fixture::new();
fx.write(
"wiki/people/sarah.md",
"wiki-page",
"Sarah bio",
"Works at [[records/companies/acme]].",
);
let got = forwardlinks(&fx.store, &fx.p("wiki/people/sarah")).unwrap();
assert_eq!(paths(&got), vec!["records/companies/acme"]);
}
#[test]
fn backlinks_finds_incoming_across_layers_and_link_forms() {
let fx = Fixture::new();
fx.write("records/contacts/sarah.md", "contact", "Sarah Chen", "");
fx.write(
"wiki/people/sarah.md",
"wiki-page",
"bio",
"See [[records/contacts/sarah]].",
);
fx.write(
"records/meetings/m1.md",
"meeting",
"Renewal call",
"Attendee [[records/contacts/sarah|Sarah]].",
);
fx.write(
"sources/emails/e1.md",
"email",
"Hi",
"From [[records/contacts/sarah.md]] today.",
);
fx.write(
"wiki/people/other.md",
"wiki-page",
"x",
"[[records/contacts/sarah-2]]",
);
fx.reindex();
let got = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
assert_eq!(
paths(&got),
vec![
"records/meetings/m1",
"sources/emails/e1",
"wiki/people/sarah",
]
);
}
#[test]
fn backlinks_and_forwardlinks_round_trip_on_same_key() {
let fx = Fixture::new();
fx.write(
"wiki/people/a.md",
"wiki-page",
"A",
"Knows [[wiki/people/b]].",
);
fx.write("wiki/people/b.md", "wiki-page", "B", "");
fx.reindex();
let fwd = forwardlinks(&fx.store, &fx.p("wiki/people/a.md")).unwrap();
let back = backlinks(&fx.store, &fx.p("wiki/people/b.md")).unwrap();
assert_eq!(paths(&fwd), vec!["wiki/people/b"]);
assert_eq!(paths(&back), vec!["wiki/people/a"]);
}
#[test]
fn backlinks_does_not_match_path_prefix_collisions() {
let fx = Fixture::new();
fx.write("records/contacts/sam.md", "contact", "Sam", "");
fx.write(
"wiki/people/x.md",
"wiki-page",
"x",
"[[records/contacts/sam-smith]]",
);
fx.write(
"wiki/people/y.md",
"wiki-page",
"y",
"[[records/contacts/sam]]",
);
fx.reindex();
let got = backlinks(&fx.store, &fx.p("records/contacts/sam")).unwrap();
assert_eq!(paths(&got), vec!["wiki/people/y"]);
}
#[test]
fn backlinks_excludes_self_reference() {
let fx = Fixture::new();
fx.write(
"wiki/synthesis/overview.md",
"wiki-page",
"Overview",
"This page [[wiki/synthesis/overview]] references itself.",
);
fx.reindex();
let got = backlinks(&fx.store, &fx.p("wiki/synthesis/overview.md")).unwrap();
assert!(
got.is_empty(),
"self-link must not appear as a backlink, got {got:?}"
);
}
#[test]
fn backlinks_empty_when_nobody_links() {
let fx = Fixture::new();
fx.write("records/contacts/lonely.md", "contact", "Lonely", "");
fx.write(
"wiki/people/unrelated.md",
"wiki-page",
"x",
"[[records/companies/acme]]",
);
fx.reindex();
let got = backlinks(&fx.store, &fx.p("records/contacts/lonely.md")).unwrap();
assert!(got.is_empty());
}
#[test]
fn backlinks_ignores_index_and_meta_files() {
let fx = Fixture::new();
fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
fx.write_raw(
"records/contacts/index.md",
"---\ntype: index\nscope: folder\nfolder: records/contacts\n---\n- [[records/contacts/sarah]] — Sarah\n",
);
fx.reindex();
let got = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
assert!(got.is_empty(), "index.md must be excluded, got {got:?}");
}
#[test]
fn backlinks_finds_body_only_edge_not_in_frontmatter_links_field() {
let fx = Fixture::new();
fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
fx.write(
"records/meetings/standup.md",
"meeting",
"Standup",
"Discussed renewal with [[records/contacts/sarah]].",
);
fx.reindex();
let rec = fx
.store
.find_by_type("meeting")
.unwrap()
.into_iter()
.find(|r| r.path == fx.p("records/meetings/standup.md"))
.expect("meeting is catalogued in its sidecar");
assert!(
rec.links.is_empty(),
"premise: the body link is NOT projected into the sidecar `links` field; got {:?}",
rec.links
);
let got = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
assert_eq!(
paths(&got),
vec!["records/meetings/standup"],
"a body-only wiki-link must register as a backlink"
);
}
#[test]
fn backlinks_finds_edge_in_typed_frontmatter_field() {
let fx = Fixture::new();
fx.write("records/companies/acme.md", "company", "Acme", "");
fx.write_raw(
"records/contacts/sarah.md",
"---\ntype: contact\ncreated: 2026-05-01T00:00:00Z\nupdated: 2026-05-01T00:00:00Z\nsummary: Sarah\ncompany: [[records/companies/acme]]\n---\nBody with no links.\n",
);
fx.reindex();
let got = backlinks(&fx.store, &fx.p("records/companies/acme.md")).unwrap();
assert_eq!(
paths(&got),
vec!["records/contacts/sarah"],
"a wiki-link in a typed frontmatter field is an incoming edge"
);
}
#[test]
fn backlinks_unscoped_scans_the_tree_not_only_the_sidecar() {
let fx = Fixture::new();
fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
fx.write(
"wiki/people/indexed.md",
"wiki-page",
"Indexed",
"[[records/contacts/sarah]]",
);
fx.reindex();
fx.write(
"wiki/people/unindexed.md",
"wiki-page",
"Unindexed",
"[[records/contacts/sarah]]",
);
let got = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
assert_eq!(
paths(&got),
vec!["wiki/people/indexed", "wiki/people/unindexed"],
"unscoped backlinks ripgrep-scans the tree, so the on-disk-but-unindexed \
linker is found too — not only the sidecar-catalogued one"
);
}
#[test]
fn backlinks_scoped_candidates_come_from_the_sidecar_not_a_tree_walk() {
let fx = Fixture::new();
fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
fx.write(
"wiki/people/indexed.md",
"wiki-page",
"Indexed",
"[[records/contacts/sarah]]",
);
fx.reindex();
fx.write(
"wiki/people/unindexed.md",
"wiki-page",
"Unindexed",
"[[records/contacts/sarah]]",
);
let only_wiki_pages = vec!["wiki-page".to_string()];
let got = backlinks_filtered(
&fx.store,
&fx.p("records/contacts/sarah.md"),
&only_wiki_pages,
None,
)
.unwrap();
assert_eq!(
paths(&got),
vec!["wiki/people/indexed"],
"scoped backlinks reads the sidecar candidate set; the on-disk-but-unindexed \
linker is not tree-walked"
);
}
#[test]
fn backlinks_filtered_type_scopes_the_candidate_set() {
let fx = Fixture::new();
fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
fx.write(
"records/meetings/m1.md",
"meeting",
"Call",
"[[records/contacts/sarah]]",
);
fx.write(
"wiki/people/bio.md",
"wiki-page",
"Bio",
"[[records/contacts/sarah]]",
);
fx.reindex();
let only_meetings = vec!["meeting".to_string()];
let got = backlinks_filtered(
&fx.store,
&fx.p("records/contacts/sarah.md"),
&only_meetings,
None,
)
.unwrap();
assert_eq!(
paths(&got),
vec!["records/meetings/m1"],
"--type meeting must exclude the wiki-page linker"
);
let all = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
assert_eq!(paths(&all), vec!["records/meetings/m1", "wiki/people/bio"]);
}
#[test]
fn backlinks_filtered_layer_scopes_the_candidate_set() {
let fx = Fixture::new();
fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
fx.write(
"records/meetings/m1.md",
"meeting",
"Call",
"[[records/contacts/sarah]]",
);
fx.write(
"wiki/people/bio.md",
"wiki-page",
"Bio",
"[[records/contacts/sarah]]",
);
fx.reindex();
let got = backlinks_filtered(
&fx.store,
&fx.p("records/contacts/sarah.md"),
&[],
Some(Layer::Wiki),
)
.unwrap();
assert_eq!(
paths(&got),
vec!["wiki/people/bio"],
"--in wiki must keep only the wiki-layer linker"
);
let records_only = backlinks_filtered(
&fx.store,
&fx.p("records/contacts/sarah.md"),
&[],
Some(Layer::Records),
)
.unwrap();
assert_eq!(paths(&records_only), vec!["records/meetings/m1"]);
}
#[test]
fn neighborhood_hops_zero_is_empty() {
let fx = Fixture::new();
fx.write("wiki/people/a.md", "wiki-page", "A", "[[wiki/people/b]]");
fx.write("wiki/people/b.md", "wiki-page", "B", "");
let slice = neighborhood(
&fx.store,
&fx.p("wiki/people/a.md"),
0,
&[],
Direction::Both,
)
.unwrap();
assert_eq!(slice.seed, fx.p("wiki/people/a"));
assert!(slice.nodes.is_empty());
}
#[test]
fn neighborhood_outgoing_one_hop_reads_summary_and_type() {
let fx = Fixture::new();
fx.write(
"wiki/people/a.md",
"wiki-page",
"Person A",
"Knows [[records/contacts/b]].",
);
fx.write("records/contacts/b.md", "contact", "Contact B summary", "");
let slice = neighborhood(
&fx.store,
&fx.p("wiki/people/a.md"),
1,
&[],
Direction::Outgoing,
)
.unwrap();
assert_eq!(slice.nodes.len(), 1);
let n = &slice.nodes[0];
assert_eq!(n.path, fx.p("records/contacts/b"));
assert_eq!(n.summary, "Contact B summary");
assert_eq!(n.type_.as_deref(), Some("contact"));
assert_eq!(n.hops, 1);
assert_eq!(n.via, Some((fx.p("wiki/people/a"), Direction::Outgoing)));
}
#[test]
fn neighborhood_incoming_only_walks_backlinks() {
let fx = Fixture::new();
fx.write(
"wiki/people/seed.md",
"wiki-page",
"Seed",
"Out to [[wiki/people/c]].",
);
fx.write(
"wiki/people/a.md",
"wiki-page",
"A",
"In to [[wiki/people/seed]].",
);
fx.write("wiki/people/c.md", "wiki-page", "C", "");
fx.reindex();
let slice = neighborhood(
&fx.store,
&fx.p("wiki/people/seed.md"),
1,
&[],
Direction::Incoming,
)
.unwrap();
assert_eq!(
paths(
&slice
.nodes
.iter()
.map(|n| n.path.clone())
.collect::<Vec<_>>()
),
vec!["wiki/people/a"]
);
assert_eq!(
slice.nodes[0].via,
Some((fx.p("wiki/people/seed"), Direction::Incoming))
);
}
#[test]
fn neighborhood_bounded_bfs_respects_hop_limit_and_min_distance() {
let fx = Fixture::new();
fx.write("wiki/c/a.md", "wiki-page", "A", "[[wiki/c/b]]");
fx.write("wiki/c/b.md", "wiki-page", "B", "[[wiki/c/c]]");
fx.write("wiki/c/c.md", "wiki-page", "C", "[[wiki/c/d]]");
fx.write("wiki/c/d.md", "wiki-page", "D", "");
let slice =
neighborhood(&fx.store, &fx.p("wiki/c/a.md"), 2, &[], Direction::Outgoing).unwrap();
let by_path: HashMap<String, u32> = slice
.nodes
.iter()
.map(|n| (n.path.to_string_lossy().to_string(), n.hops))
.collect();
assert_eq!(by_path.get("wiki/c/b").copied(), Some(1));
assert_eq!(by_path.get("wiki/c/c").copied(), Some(2));
assert_eq!(by_path.get("wiki/c/d"), None);
assert_eq!(slice.nodes.len(), 2);
}
#[test]
fn neighborhood_records_min_hops_on_diamond() {
let fx = Fixture::new();
fx.write("wiki/d/a.md", "wiki-page", "A", "[[wiki/d/b]] [[wiki/d/c]]");
fx.write("wiki/d/b.md", "wiki-page", "B", "[[wiki/d/d]]");
fx.write("wiki/d/c.md", "wiki-page", "C", "[[wiki/d/d]]");
fx.write("wiki/d/d.md", "wiki-page", "D", "");
let slice =
neighborhood(&fx.store, &fx.p("wiki/d/a.md"), 3, &[], Direction::Outgoing).unwrap();
let d_nodes: Vec<&ContextNode> = slice
.nodes
.iter()
.filter(|n| n.path == fx.p("wiki/d/d"))
.collect();
assert_eq!(d_nodes.len(), 1, "d must appear exactly once");
assert_eq!(d_nodes[0].hops, 2, "d's min distance from a is 2");
assert_eq!(slice.nodes.len(), 3);
}
#[test]
fn neighborhood_type_filter_narrows_results_but_not_traversal() {
let fx = Fixture::new();
fx.write(
"wiki/people/seed.md",
"wiki-page",
"Seed",
"[[records/contacts/sarah]]",
);
fx.write(
"records/contacts/sarah.md",
"contact",
"Sarah",
"[[records/meetings/m1]]",
);
fx.write("records/meetings/m1.md", "meeting", "Renewal call", "");
let only_meetings = vec!["meeting".to_string()];
let slice = neighborhood(
&fx.store,
&fx.p("wiki/people/seed.md"),
2,
&only_meetings,
Direction::Outgoing,
)
.unwrap();
assert_eq!(slice.nodes.len(), 1);
assert_eq!(slice.nodes[0].path, fx.p("records/meetings/m1"));
assert_eq!(slice.nodes[0].type_.as_deref(), Some("meeting"));
assert_eq!(slice.nodes[0].hops, 2);
}
#[test]
fn neighborhood_cycle_terminates() {
let fx = Fixture::new();
fx.write("wiki/g/a.md", "wiki-page", "A", "[[wiki/g/b]]");
fx.write("wiki/g/b.md", "wiki-page", "B", "[[wiki/g/a]]");
fx.reindex();
let slice =
neighborhood(&fx.store, &fx.p("wiki/g/a.md"), 10, &[], Direction::Both).unwrap();
assert_eq!(
paths(
&slice
.nodes
.iter()
.map(|n| n.path.clone())
.collect::<Vec<_>>()
),
vec!["wiki/g/b"]
);
}
#[test]
fn orphans_finds_files_with_no_edges_either_direction() {
let fx = Fixture::new();
fx.write("wiki/people/a.md", "wiki-page", "A", "[[wiki/people/b]]");
fx.write("wiki/people/b.md", "wiki-page", "B", "");
fx.write(
"sources/emails/lonely.md",
"email",
"Lonely email",
"Just text, no links.",
);
let got = orphans(&fx.store, None).unwrap();
assert_eq!(paths(&got), vec!["sources/emails/lonely.md"]);
}
#[test]
fn orphans_file_with_only_outgoing_is_not_orphan() {
let fx = Fixture::new();
fx.write(
"wiki/people/a.md",
"wiki-page",
"A",
"[[records/contacts/ghost]]",
);
let got = orphans(&fx.store, None).unwrap();
assert!(
!paths(&got).contains(&"wiki/people/a.md".to_string()),
"outgoing-only is not an orphan: {got:?}"
);
}
#[test]
fn orphans_file_with_only_incoming_is_not_orphan() {
let fx = Fixture::new();
fx.write("records/contacts/target.md", "contact", "Target", "");
fx.write(
"wiki/people/linker.md",
"wiki-page",
"Linker",
"[[records/contacts/target]]",
);
let got = orphans(&fx.store, None).unwrap();
assert!(
!paths(&got).contains(&"records/contacts/target.md".to_string()),
"incoming-only is not an orphan: {got:?}"
);
assert!(!paths(&got).contains(&"wiki/people/linker.md".to_string()));
}
#[test]
fn orphans_incoming_link_from_other_layer_unorphans() {
let fx = Fixture::new();
fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
fx.write(
"wiki/people/sarah.md",
"wiki-page",
"bio",
"[[records/contacts/sarah]]",
);
fx.write("records/contacts/nemo.md", "contact", "Nemo", "");
let got = orphans(&fx.store, Some(Layer::Records)).unwrap();
assert_eq!(paths(&got), vec!["records/contacts/nemo.md"]);
}
#[test]
fn orphans_layer_scope_filters_candidates() {
let fx = Fixture::new();
fx.write("sources/emails/s.md", "email", "S", "no links");
fx.write("records/contacts/r.md", "contact", "R", "");
fx.write("wiki/people/w.md", "wiki-page", "W", "");
let only_wiki = orphans(&fx.store, Some(Layer::Wiki)).unwrap();
assert_eq!(paths(&only_wiki), vec!["wiki/people/w.md"]);
let only_sources = orphans(&fx.store, Some(Layer::Sources)).unwrap();
assert_eq!(paths(&only_sources), vec!["sources/emails/s.md"]);
let all = orphans(&fx.store, None).unwrap();
assert_eq!(
paths(&all),
vec![
"records/contacts/r.md",
"sources/emails/s.md",
"wiki/people/w.md",
]
);
}
#[test]
fn orphans_self_link_does_not_count_as_an_edge() {
let fx = Fixture::new();
fx.write(
"wiki/synthesis/solo.md",
"wiki-page",
"Solo",
"I reference [[wiki/synthesis/solo]] only.",
);
let got = orphans(&fx.store, None).unwrap();
assert_eq!(paths(&got), vec!["wiki/synthesis/solo.md"]);
}
#[test]
fn orphans_excludes_index_and_db_files() {
let fx = Fixture::new();
fx.write_raw(
"wiki/index.md",
"---\ntype: index\nscope: layer\nfolder: wiki\n---\n# wiki\n",
);
fx.write(
"wiki/people/real-orphan.md",
"wiki-page",
"Real",
"no links",
);
let got = orphans(&fx.store, None).unwrap();
assert_eq!(paths(&got), vec!["wiki/people/real-orphan.md"]);
}
#[test]
fn frontmatter_block_extracts_between_fences() {
let text = "---\ntype: contact\nsummary: hi\n---\nbody here\n";
assert_eq!(
frontmatter_block(text),
Some("type: contact\nsummary: hi\n")
);
}
#[test]
fn frontmatter_block_none_without_leading_fence() {
let text = "no frontmatter here\n";
assert_eq!(frontmatter_block(text), None);
}
}