use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use crate::index::IndexRecord;
use crate::store::{
canonical_link_target, ensure_path_within_store, extract_edge_targets, fence_closes,
fence_opens, layer_for_type, link_edge_key, 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());
}
let target_key = edge_key(&target);
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() || edge_key(&linker) == target_key {
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() || edge_key(&candidate_target) == target_key {
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_key = edge_key(&normalize_target(path));
let abs = match resolve_existing(store, path) {
Some(a) => a,
None => return Ok(Vec::new()),
};
let body = match std::fs::read(&abs) {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
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() || edge_key(&target) == self_key {
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 type_layer = layer_for_type(type_);
if let Some(scope) = layer {
if scope != type_layer {
continue;
}
}
for rec in store.sidecar_records(Some(type_layer))? {
if rec.type_ == *type_ {
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)?;
let target_key = edge_key(target);
Ok(edges
.iter()
.any(|e| edge_key(&e.to_string_lossy()) == target_key))
}
pub fn neighborhood(
store: &Store,
seed: &Path,
hops: u32,
types: &[String],
direction: Direction,
) -> Result<ContextSlice, StoreError> {
neighborhood_capped(store, seed, hops, types, direction, None)
}
pub fn neighborhood_capped(
store: &Store,
seed: &Path,
hops: u32,
types: &[String],
direction: Direction,
max_nodes: Option<usize>,
) -> 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 admitted = 0usize;
let cap_reached = |admitted: usize| max_nodes.is_some_and(|cap| admitted >= cap);
let mut hop = 0u32;
while hop < hops && !frontier.is_empty() && !cap_reached(admitted) {
hop += 1;
let level_size = frontier.len();
for _ in 0..level_size {
if cap_reached(admitted) {
break;
}
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 cap_reached(admitted) {
break;
}
if !discovered.insert(neighbor.clone()) {
continue;
}
admitted += 1;
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<String> = 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_key = edge_key(&normalize_target(&rel));
let body = match std::fs::read(abs) {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(e) => return Err(StoreError::Io(e)),
};
let mut outgoing = false;
for target in extract_link_targets(&body) {
if target.is_empty() || edge_key(&target) == self_key {
continue;
}
if resolve_existing(store, Path::new(&target)).is_none() {
continue;
}
outgoing = true;
linked_to.insert(edge_key(&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(&edge_key(&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 old_key = edge_key(&old_target);
let mut out = String::with_capacity(text.len());
let mut fence: Option<(u8, usize)> = None;
for line in text.split_inclusive('\n') {
let content = line.trim_end_matches('\n').trim_end_matches('\r');
if let Some(f) = fence {
if fence_closes(content, f) {
fence = None;
}
out.push_str(line);
continue;
}
if let Some(opened) = fence_opens(content) {
fence = Some(opened);
out.push_str(line);
continue;
}
rewrite_links_in_line(line, &old_key, &new_target, &mut out);
}
out
}
fn rewrite_links_in_line(line: &str, old_key: &str, new_target: &str, out: &mut String) {
let bytes = line.as_bytes();
let mut i = 0usize;
let mut last = 0usize;
while i + 1 < bytes.len() {
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
if let Some(close) = line[i + 2..].find("]]") {
let inner = &line[i + 2..i + 2 + close];
if !inner.contains('\n') {
let (raw_target, display) = match inner.split_once('|') {
Some((t, d)) => (t, Some(d)),
None => (inner, None),
};
let raw_target = raw_target.trim();
if !raw_target.is_empty()
&& !raw_target.starts_with('[')
&& edge_key(&canonical_link_target(raw_target)) == old_key
{
out.push_str(&line[last..i]);
out.push_str("[[");
out.push_str(new_target);
if let Some(display) = display {
out.push('|');
out.push_str(display);
}
out.push_str("]]");
i = i + 2 + close + 2;
last = i;
continue;
}
}
i = i + 2 + close + 2;
continue;
}
}
i += 1;
}
out.push_str(&line[last..]);
}
fn normalize_target(path: &Path) -> String {
canonical_link_target(&path.to_string_lossy())
}
fn edge_key(canonical_target: &str) -> String {
link_edge_key(canonical_target)
}
fn extract_link_targets(body: &str) -> Vec<String> {
extract_edge_targets(body)
.into_iter()
.filter(|t| is_within_store_target(t))
.collect()
}
fn is_within_store_target(target: &str) -> bool {
Path::new(target)
.components()
.all(|c| matches!(c, std::path::Component::Normal(_)))
}
fn resolve_existing(store: &Store, store_relative: &Path) -> Option<PathBuf> {
let direct = store.root.join(store_relative);
if direct.is_file() && resolves_within_store(store, store_relative, &direct) {
return Some(direct);
}
let normalized = normalize_target(store_relative);
let with_md = store.root.join(format!("{normalized}.md"));
if with_md.is_file() && resolves_within_store(store, Path::new(&normalized), &with_md) {
return Some(with_md);
}
None
}
fn resolves_within_store(store: &Store, store_relative: &Path, abs: &Path) -> bool {
let plain_relative = !store_relative.is_absolute()
&& store_relative
.components()
.all(|c| matches!(c, std::path::Component::Normal(_)));
if plain_relative {
return true;
}
ensure_path_within_store(&store.root, abs).is_ok()
}
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),
_ => 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)
.follow_links(true)
.build();
for result in walker {
let entry = result.map_err(|e| StoreError::Search {
root: store.root.clone(),
message: format!("walk failed: {e}"),
})?;
let is_file = match entry.file_type() {
Some(ft) if ft.is_file() => true,
Some(ft) if ft.is_symlink() => std::fs::metadata(entry.path())
.map(|m| m.is_file())
.unwrap_or(false),
_ => false,
};
if !is_file {
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",
}
}
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(&abs) {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(_) => return (String::new(), None),
};
let yaml = match frontmatter_block(&text) {
Some(y) => y,
None => return (String::new(), None),
};
let value: serde_norway::Value = match serde_norway::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 text = text.strip_prefix('\u{feff}').unwrap_or(text);
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("./records/profiles/elena")),
"records/profiles/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 [[records/profiles/sarah-chen|Sarah]] and [[records/contacts/elena.md]].";
let got = extract_link_targets(body);
assert_eq!(
got,
vec!["records/profiles/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]] [[records/concepts/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("records/profiles/bio.md", "profile", "bio", body);
let edges = forwardlinks(&fx.store, &fx.p("records/profiles/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("records/profiles/bio.md", "profile", "bio", &got);
let after = forwardlinks(&fx.store, &fx.p("records/profiles/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 [[records/concepts/y]]";
let got = rewrite_links_to(input, Path::new("records/x"), Path::new("records/z"));
assert_eq!(got, input);
}
#[test]
fn rewrite_does_not_corrupt_links_in_nested_or_long_run_fences() {
let body = "\
Here is how to write a link:
````
```
[[records/contacts/bob]]
```
still fenced [[records/contacts/bob]]
````
Real link: [[records/contacts/bob]].
";
let got = rewrite_links_to(
body,
Path::new("records/contacts/bob"),
Path::new("records/contacts/robert"),
);
let expected = "\
Here is how to write a link:
````
```
[[records/contacts/bob]]
```
still fenced [[records/contacts/bob]]
````
Real link: [[records/contacts/robert]].
";
assert_eq!(
got, expected,
"fenced example links must survive a rename verbatim; only live edges retarget"
);
}
#[test]
fn forwardlinks_returns_sorted_deduped_targets_excluding_self() {
let fx = Fixture::new();
fx.write(
"records/projects/renewal.md",
"synthesis",
"Renewal project",
"Links: [[records/contacts/sarah]] [[records/companies/acme]] [[records/contacts/sarah]] and itself [[records/projects/renewal]].",
);
let got = forwardlinks(&fx.store, &fx.p("records/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 [[records/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",
"records/projects/renewal",
]
);
}
#[test]
fn forwardlinks_missing_file_is_empty_not_error() {
let fx = Fixture::new();
let got = forwardlinks(&fx.store, &fx.p("records/profiles/ghost.md")).unwrap();
assert!(got.is_empty());
}
#[test]
fn forwardlinks_resolves_seed_given_without_md_extension() {
let fx = Fixture::new();
fx.write(
"records/profiles/sarah.md",
"profile",
"Sarah bio",
"Works at [[records/companies/acme]].",
);
let got = forwardlinks(&fx.store, &fx.p("records/profiles/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(
"records/profiles/sarah.md",
"profile",
"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(
"records/profiles/other.md",
"profile",
"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",
"records/profiles/sarah",
"sources/emails/e1",
]
);
}
#[test]
fn backlinks_and_forwardlinks_round_trip_on_same_key() {
let fx = Fixture::new();
fx.write(
"records/profiles/a.md",
"profile",
"A",
"Knows [[records/profiles/b]].",
);
fx.write("records/profiles/b.md", "profile", "B", "");
fx.reindex();
let fwd = forwardlinks(&fx.store, &fx.p("records/profiles/a.md")).unwrap();
let back = backlinks(&fx.store, &fx.p("records/profiles/b.md")).unwrap();
assert_eq!(paths(&fwd), vec!["records/profiles/b"]);
assert_eq!(paths(&back), vec!["records/profiles/a"]);
}
#[test]
fn backlinks_does_not_match_path_prefix_collisions() {
let fx = Fixture::new();
fx.write("records/contacts/sam.md", "contact", "Sam", "");
fx.write(
"records/profiles/x.md",
"profile",
"x",
"[[records/contacts/sam-smith]]",
);
fx.write(
"records/profiles/y.md",
"profile",
"y",
"[[records/contacts/sam]]",
);
fx.reindex();
let got = backlinks(&fx.store, &fx.p("records/contacts/sam")).unwrap();
assert_eq!(paths(&got), vec!["records/profiles/y"]);
}
#[test]
fn backlinks_excludes_self_reference() {
let fx = Fixture::new();
fx.write(
"records/synthesis/overview.md",
"synthesis",
"Overview",
"This page [[records/synthesis/overview]] references itself.",
);
fx.reindex();
let got = backlinks(&fx.store, &fx.p("records/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(
"records/profiles/unrelated.md",
"profile",
"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(
"records/profiles/indexed.md",
"profile",
"Indexed",
"[[records/contacts/sarah]]",
);
fx.reindex();
fx.write(
"records/profiles/unindexed.md",
"profile",
"Unindexed",
"[[records/contacts/sarah]]",
);
let got = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
assert_eq!(
paths(&got),
vec!["records/profiles/indexed", "records/profiles/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(
"records/profiles/indexed.md",
"profile",
"Indexed",
"[[records/contacts/sarah]]",
);
fx.reindex();
fx.write(
"records/profiles/unindexed.md",
"profile",
"Unindexed",
"[[records/contacts/sarah]]",
);
let only_profiles = vec!["profile".to_string()];
let got = backlinks_filtered(
&fx.store,
&fx.p("records/contacts/sarah.md"),
&only_profiles,
None,
)
.unwrap();
assert_eq!(
paths(&got),
vec!["records/profiles/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(
"records/profiles/bio.md",
"profile",
"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 profile linker"
);
let all = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
assert_eq!(
paths(&all),
vec!["records/meetings/m1", "records/profiles/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(
"sources/emails/intro.md",
"email",
"Intro",
"[[records/contacts/sarah]]",
);
fx.reindex();
let got = backlinks_filtered(
&fx.store,
&fx.p("records/contacts/sarah.md"),
&[],
Some(Layer::Sources),
)
.unwrap();
assert_eq!(
paths(&got),
vec!["sources/emails/intro"],
"--in sources must keep only the sources-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 backlinks_scoped_type_spans_all_topic_folders_in_its_layer() {
let fx = Fixture::new();
fx.write("records/contacts/sarah.md", "contact", "Sarah", "");
fx.write(
"records/profiles/glossary.md",
"profile",
"Glossary",
"No link to sarah here.",
);
fx.write(
"records/people/sarah.md",
"profile",
"Sarah bio",
"Profile of [[records/contacts/sarah]].",
);
fx.reindex();
let scoped = backlinks_filtered(
&fx.store,
&fx.p("records/contacts/sarah.md"),
&["profile".to_string()],
None,
)
.unwrap();
assert_eq!(
paths(&scoped),
vec!["records/people/sarah"],
"a profile filed outside records/profiles/ must still be a scoped backlink"
);
let unscoped = backlinks(&fx.store, &fx.p("records/contacts/sarah.md")).unwrap();
assert_eq!(
paths(&unscoped),
vec!["records/people/sarah"],
"scoped and unscoped backlinks must agree on the edge set"
);
}
#[test]
fn neighborhood_hops_zero_is_empty() {
let fx = Fixture::new();
fx.write(
"records/profiles/a.md",
"profile",
"A",
"[[records/profiles/b]]",
);
fx.write("records/profiles/b.md", "profile", "B", "");
let slice = neighborhood(
&fx.store,
&fx.p("records/profiles/a.md"),
0,
&[],
Direction::Both,
)
.unwrap();
assert_eq!(slice.seed, fx.p("records/profiles/a"));
assert!(slice.nodes.is_empty());
}
#[test]
fn neighborhood_outgoing_one_hop_reads_summary_and_type() {
let fx = Fixture::new();
fx.write(
"records/profiles/a.md",
"profile",
"Person A",
"Knows [[records/contacts/b]].",
);
fx.write("records/contacts/b.md", "contact", "Contact B summary", "");
let slice = neighborhood(
&fx.store,
&fx.p("records/profiles/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("records/profiles/a"), Direction::Outgoing))
);
}
#[test]
fn neighborhood_incoming_only_walks_backlinks() {
let fx = Fixture::new();
fx.write(
"records/profiles/seed.md",
"profile",
"Seed",
"Out to [[records/profiles/c]].",
);
fx.write(
"records/profiles/a.md",
"profile",
"A",
"In to [[records/profiles/seed]].",
);
fx.write("records/profiles/c.md", "profile", "C", "");
fx.reindex();
let slice = neighborhood(
&fx.store,
&fx.p("records/profiles/seed.md"),
1,
&[],
Direction::Incoming,
)
.unwrap();
assert_eq!(
paths(
&slice
.nodes
.iter()
.map(|n| n.path.clone())
.collect::<Vec<_>>()
),
vec!["records/profiles/a"]
);
assert_eq!(
slice.nodes[0].via,
Some((fx.p("records/profiles/seed"), Direction::Incoming))
);
}
#[test]
fn neighborhood_bounded_bfs_respects_hop_limit_and_min_distance() {
let fx = Fixture::new();
fx.write("records/c/a.md", "concept", "A", "[[records/c/b]]");
fx.write("records/c/b.md", "concept", "B", "[[records/c/c]]");
fx.write("records/c/c.md", "concept", "C", "[[records/c/d]]");
fx.write("records/c/d.md", "concept", "D", "");
let slice = neighborhood(
&fx.store,
&fx.p("records/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("records/c/b").copied(), Some(1));
assert_eq!(by_path.get("records/c/c").copied(), Some(2));
assert_eq!(by_path.get("records/c/d"), None);
assert_eq!(slice.nodes.len(), 2);
}
#[test]
fn neighborhood_records_min_hops_on_diamond() {
let fx = Fixture::new();
fx.write(
"records/d/a.md",
"concept",
"A",
"[[records/d/b]] [[records/d/c]]",
);
fx.write("records/d/b.md", "concept", "B", "[[records/d/d]]");
fx.write("records/d/c.md", "concept", "C", "[[records/d/d]]");
fx.write("records/d/d.md", "concept", "D", "");
let slice = neighborhood(
&fx.store,
&fx.p("records/d/a.md"),
3,
&[],
Direction::Outgoing,
)
.unwrap();
let d_nodes: Vec<&ContextNode> = slice
.nodes
.iter()
.filter(|n| n.path == fx.p("records/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(
"records/profiles/seed.md",
"profile",
"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("records/profiles/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_capped_bounds_traversal_not_just_output() {
let fx = Fixture::new();
fx.write(
"records/n/seed.md",
"concept",
"Seed",
"[[records/n/a]] [[records/n/b]] [[records/n/c]]",
);
fx.write("records/n/a.md", "concept", "A", "[[records/n/deep]]");
fx.write("records/n/b.md", "concept", "B", "");
fx.write("records/n/c.md", "concept", "C", "");
fx.write("records/n/deep.md", "concept", "Deep", "");
let full = neighborhood(
&fx.store,
&fx.p("records/n/seed.md"),
3,
&[],
Direction::Outgoing,
)
.unwrap();
assert_eq!(
paths(
&full
.nodes
.iter()
.map(|n| n.path.clone())
.collect::<Vec<_>>()
),
vec![
"records/n/a",
"records/n/b",
"records/n/c",
"records/n/deep"
],
"uncapped traversal reaches every node within the hop budget"
);
let capped = neighborhood_capped(
&fx.store,
&fx.p("records/n/seed.md"),
3,
&[],
Direction::Outgoing,
Some(2),
)
.unwrap();
assert_eq!(
paths(
&capped
.nodes
.iter()
.map(|n| n.path.clone())
.collect::<Vec<_>>()
),
vec!["records/n/a", "records/n/b"],
"the cap bounds traversal: only the first 2 nodes are reached, and the \
hop-2 `deep` node (reachable only by expanding a capped-out node) is \
never traversed"
);
let uncapped = neighborhood_capped(
&fx.store,
&fx.p("records/n/seed.md"),
3,
&[],
Direction::Outgoing,
None,
)
.unwrap();
assert_eq!(
uncapped.nodes.len(),
full.nodes.len(),
"None cap matches the unbounded neighborhood result"
);
}
#[test]
fn neighborhood_capped_both_direction_caps_the_node_count() {
let fx = Fixture::new();
fx.write("records/profiles/hub.md", "profile", "Hub", "");
for n in ["a", "b", "c", "d", "e"] {
fx.write(
&format!("records/profiles/{n}.md"),
"profile",
n,
"[[records/profiles/hub]]",
);
}
fx.reindex();
let capped = neighborhood_capped(
&fx.store,
&fx.p("records/profiles/hub.md"),
1,
&[],
Direction::Both,
Some(3),
)
.unwrap();
assert_eq!(
capped.nodes.len(),
3,
"Both-direction neighborhood is bounded to the node cap"
);
let uncapped = neighborhood(
&fx.store,
&fx.p("records/profiles/hub.md"),
1,
&[],
Direction::Both,
)
.unwrap();
assert_eq!(uncapped.nodes.len(), 5);
}
#[test]
fn neighborhood_cycle_terminates() {
let fx = Fixture::new();
fx.write("records/g/a.md", "concept", "A", "[[records/g/b]]");
fx.write("records/g/b.md", "concept", "B", "[[records/g/a]]");
fx.reindex();
let slice =
neighborhood(&fx.store, &fx.p("records/g/a.md"), 10, &[], Direction::Both).unwrap();
assert_eq!(
paths(
&slice
.nodes
.iter()
.map(|n| n.path.clone())
.collect::<Vec<_>>()
),
vec!["records/g/b"]
);
}
#[test]
fn orphans_finds_files_with_no_edges_either_direction() {
let fx = Fixture::new();
fx.write(
"records/profiles/a.md",
"profile",
"A",
"[[records/profiles/b]]",
);
fx.write("records/profiles/b.md", "profile", "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_broken_outgoing_link_is_orphan() {
let fx = Fixture::new();
fx.write(
"records/profiles/a.md",
"profile",
"A",
"[[records/contacts/ghost]]",
);
let got = orphans(&fx.store, None).unwrap();
assert!(
paths(&got).contains(&"records/profiles/a.md".to_string()),
"broken outgoing links must not wire the graph: {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(
"records/profiles/linker.md",
"profile",
"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(&"records/profiles/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(
"sources/emails/sarah.md",
"email",
"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("records/profiles/w.md", "profile", "W", "");
let only_records = orphans(&fx.store, Some(Layer::Records)).unwrap();
assert_eq!(
paths(&only_records),
vec!["records/contacts/r.md", "records/profiles/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",
"records/profiles/w.md",
"sources/emails/s.md",
]
);
}
#[test]
fn orphans_self_link_does_not_count_as_an_edge() {
let fx = Fixture::new();
fx.write(
"records/synthesis/solo.md",
"synthesis",
"Solo",
"I reference [[records/synthesis/solo]] only.",
);
let got = orphans(&fx.store, None).unwrap();
assert_eq!(paths(&got), vec!["records/synthesis/solo.md"]);
}
#[test]
fn orphans_excludes_index_and_db_files() {
let fx = Fixture::new();
fx.write_raw(
"records/index.md",
"---\ntype: index\nscope: layer\nfolder: records\n---\n# records\n",
);
fx.write(
"records/profiles/real-orphan.md",
"profile",
"Real",
"no links",
);
let got = orphans(&fx.store, None).unwrap();
assert_eq!(paths(&got), vec!["records/profiles/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);
}
#[test]
fn frontmatter_block_tolerates_leading_bom() {
let text = "\u{feff}---\ntype: contact\nsummary: hi\n---\nbody here\n";
assert_eq!(
frontmatter_block(text),
Some("type: contact\nsummary: hi\n"),
"a leading BOM must not hide frontmatter from the graph layer"
);
}
#[test]
fn padded_link_is_both_a_forward_and_backward_edge() {
let fx = Fixture::new();
fx.write(
"records/contacts/sarah.md",
"contact",
"Sarah",
"the contact",
);
fx.write(
"records/profiles/a.md",
"profile",
"A",
"See [[ records/contacts/sarah ]] today.",
);
fx.reindex();
assert_eq!(
paths(&forwardlinks(&fx.store, Path::new("records/profiles/a.md")).unwrap()),
vec!["records/contacts/sarah"],
"padded link is a forward edge"
);
assert_eq!(
paths(&backlinks(&fx.store, Path::new("records/contacts/sarah.md")).unwrap()),
vec!["records/profiles/a"],
"padded link is the SAME backward edge (forward and backward agree)"
);
}
#[test]
fn fenced_link_is_not_an_edge_and_page_is_orphan() {
let fx = Fixture::new();
fx.write(
"records/contacts/sarah.md",
"contact",
"Sarah",
"the contact",
);
fx.write(
"records/synthesis/howto.md",
"synthesis",
"Howto",
"```markdown\n[[records/contacts/sarah]] is how you link.\n```",
);
fx.reindex();
assert!(
forwardlinks(&fx.store, Path::new("records/synthesis/howto.md"))
.unwrap()
.is_empty(),
"a fenced example is not a forward edge"
);
assert!(
backlinks(&fx.store, Path::new("records/contacts/sarah.md"))
.unwrap()
.is_empty(),
"a fenced example is not a backward edge"
);
let orphan_set = paths(&orphans(&fx.store, None).unwrap());
assert!(
orphan_set.contains(&"records/synthesis/howto.md".to_string()),
"a page whose only link is fenced has no real edges => orphan: {orphan_set:?}"
);
}
#[test]
fn rewrite_links_to_leaves_fenced_examples_untouched() {
let input = "\
Real [[records/contacts/sarah]] link.
```markdown
Example: [[records/contacts/sarah]] inside a fence.
```
Trailing [[records/contacts/sarah]].
";
let got = rewrite_links_to(
input,
Path::new("records/contacts/sarah"),
Path::new("records/contacts/sarah-chen"),
);
assert!(
got.contains("Real [[records/contacts/sarah-chen]] link."),
"real link before the fence must retarget"
);
assert!(
got.contains("Trailing [[records/contacts/sarah-chen]]."),
"real link after the fence must retarget"
);
assert!(
got.contains("Example: [[records/contacts/sarah]] inside a fence."),
"fenced example must stay verbatim, got:\n{got}"
);
}
#[test]
fn rewrite_links_to_matches_padded_link() {
let got = rewrite_links_to(
"See [[ records/contacts/sarah |Sarah]] today.",
Path::new("records/contacts/sarah"),
Path::new("records/contacts/sarah-chen"),
);
assert_eq!(got, "See [[records/contacts/sarah-chen|Sarah]] today.");
}
#[cfg(unix)]
#[test]
fn case_variant_link_is_one_edge_on_case_insensitive_fs() {
if link_edge_key("A") != link_edge_key("a") {
return;
}
let fx = Fixture::new();
fx.write(
"records/contacts/sarah-chen.md",
"contact",
"Sarah",
"the contact",
);
fx.write(
"records/profiles/bio.md",
"profile",
"Bio",
"See [[records/contacts/Sarah-Chen]].",
);
fx.reindex();
assert_eq!(
paths(&backlinks(&fx.store, Path::new("records/contacts/sarah-chen.md")).unwrap()),
vec!["records/profiles/bio"],
"case-variant incoming link must be a backward edge"
);
let orphan_set = paths(&orphans(&fx.store, None).unwrap());
assert!(
!orphan_set.contains(&"records/contacts/sarah-chen.md".to_string()),
"a target with a live case-variant incoming link must NOT be orphaned: {orphan_set:?}"
);
let rewritten = rewrite_links_to(
"See [[records/contacts/Sarah-Chen]].",
Path::new("records/contacts/sarah-chen"),
Path::new("records/contacts/sarah"),
);
assert_eq!(
rewritten, "See [[records/contacts/sarah]].",
"rename must rewrite the case-variant link on a case-insensitive FS"
);
}
#[cfg(unix)]
#[test]
fn escaping_link_is_not_an_edge_and_neighborhood_does_not_escape() {
let fx = Fixture::new();
let outside_dir = fx.store.root.parent().unwrap().join("outside");
fs::create_dir_all(&outside_dir).unwrap();
fs::write(
outside_dir.join("secret.md"),
"---\ntype: note\nsummary: TOPSECRET\n---\nLinks [[records/contacts/sarah]].\n",
)
.unwrap();
fx.write(
"records/contacts/sarah.md",
"contact",
"Sarah",
"the contact",
);
fx.write(
"records/concepts/traversal.md",
"concept",
"Traversal",
"See [[../outside/secret]].",
);
fx.reindex();
assert!(
forwardlinks(&fx.store, Path::new("records/concepts/traversal.md"))
.unwrap()
.is_empty(),
"an escaping `[[../outside/secret]]` must not be a forward edge"
);
let slice = neighborhood(
&fx.store,
Path::new("records/concepts/traversal.md"),
2,
&[],
Direction::Outgoing,
)
.unwrap();
assert!(
slice
.nodes
.iter()
.all(|n| !n.path.to_string_lossy().contains("outside")),
"neighborhood must not read/traverse the external file: {:?}",
slice.nodes
);
}
#[test]
fn regression_non_utf8_linker_edges_survive_scoped_backlinks_and_orphans() {
let fx = Fixture::new();
fx.write("records/contacts/sarah.md", "contact", "Sarah", "# Sarah");
let mut bytes: Vec<u8> = Vec::new();
bytes.extend_from_slice(
b"---\ntype: profile\nmeta-type: conclusion\ncreated: 2026-05-01T00:00:00Z\nupdated: 2026-05-01T00:00:00Z\nsummary: Bio\n---\n",
);
bytes.extend_from_slice(b"See [[records/contacts/sarah]] caf");
bytes.push(0xE9);
bytes.extend_from_slice(b"\n");
let bio_abs = fx.store.root.join("records/profiles/bio.md");
fs::create_dir_all(bio_abs.parent().unwrap()).unwrap();
fs::write(&bio_abs, &bytes).unwrap();
fx.reindex();
let sarah = fx.p("records/contacts/sarah");
let fwd = paths(&forwardlinks(&fx.store, &fx.p("records/profiles/bio")).unwrap());
assert!(
fwd.iter().any(|p| p.contains("sarah")),
"forwardlinks must extract the edge from a non-UTF8 file: {fwd:?}"
);
let unscoped = paths(&backlinks(&fx.store, &sarah).unwrap());
let scoped =
paths(&backlinks_filtered(&fx.store, &sarah, &["profile".to_string()], None).unwrap());
assert!(
unscoped.iter().any(|p| p.contains("bio")),
"unscoped backlinks must include bio: {unscoped:?}"
);
assert!(
scoped.iter().any(|p| p.contains("bio")),
"scoped backlinks must agree with unscoped on the non-UTF8 linker: {scoped:?}"
);
let orph = paths(&orphans(&fx.store, None).unwrap());
assert!(
!orph
.iter()
.any(|p| p.contains("bio") || p.contains("sarah")),
"neither endpoint of a live edge may be an orphan: {orph:?}"
);
}
}