use std::collections::BTreeMap;
use std::path::Path;
use crate::model::Node;
use crate::model::{Edge, RawEdge, ResolvedTarget};
pub fn resolve_edges(
source_id: &str,
raw_edges: Vec<RawEdge>,
source_path: &Path,
path_index: &BTreeMap<String, String>,
id_set: &BTreeMap<String, ()>,
) -> Vec<Edge> {
raw_edges
.into_iter()
.map(|raw| {
let target = resolve_target(
&raw.target_path,
&raw.relation,
source_path,
path_index,
id_set,
);
Edge {
source: source_id.to_string(),
target,
relation: raw.relation,
confidence: raw.confidence,
location: raw.location,
}
})
.collect()
}
fn resolve_target(
target: &str,
relation: &str,
source_path: &Path,
path_index: &BTreeMap<String, String>,
id_set: &BTreeMap<String, ()>,
) -> ResolvedTarget {
match relation {
"supersedes" | "implements" | "related" => {
if id_set.contains_key(target) {
return ResolvedTarget::resolved(target);
}
return ResolvedTarget::unresolved(target, "node id not found in graph");
}
_ => {}
}
let normalized = target.replace('\\', "/");
let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
if let Some(id) = path_index.get(normalized) {
return ResolvedTarget::resolved(id);
}
if let Some(parent) = source_path.parent() {
let resolved = normalize_path_segments(&parent.join(normalized));
if let Some(id) = path_index.get(&resolved) {
return ResolvedTarget::resolved(id);
}
}
ResolvedTarget::unresolved(target, "path not found in scope")
}
fn normalize_path_segments(path: &Path) -> String {
let normalized = path.to_string_lossy().replace('\\', "/");
let mut parts: Vec<&str> = Vec::new();
for component in normalized.split('/') {
match component {
"." | "" => {}
".." => {
parts.pop();
}
other => parts.push(other),
}
}
parts.join("/")
}
pub fn build_path_index(nodes: &[(String, Node)]) -> BTreeMap<String, String> {
let mut index = BTreeMap::new();
for (id, node) in nodes {
let path_str = node.path.to_string_lossy().replace('\\', "/");
index.insert(path_str, id.clone());
}
index
}
pub fn build_id_set(nodes: &[(String, Node)]) -> BTreeMap<String, ()> {
nodes.iter().map(|(id, _)| (id.clone(), ())).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Confidence, Kind, RawEdge, Status};
use std::path::PathBuf;
fn make_node(id: &str, path: &str) -> (String, Node) {
(
id.to_string(),
Node {
id: id.to_string(),
path: PathBuf::from(path),
title: "Test".to_string(),
kind: Kind::new("generic"),
status: Status::default(),
created: None,
updated: None,
reviewed: None,
owner: None,
supersedes: vec![],
superseded_by: None,
implements: vec![],
related: vec![],
tags: vec![],
orphan_ok: false,
attrs: BTreeMap::new(),
},
)
}
#[test]
fn resolve_direct_path() {
let nodes = vec![make_node("guide-auth", "docs/guides/auth.md")];
let path_index = build_path_index(&nodes);
let id_set = build_id_set(&nodes);
let edges = resolve_edges(
"adr-001",
vec![RawEdge {
target_path: "docs/guides/auth.md".to_string(),
relation: "references".to_string(),
confidence: Confidence::Extracted,
location: "L5".to_string(),
}],
Path::new("docs/decisions/0001-auth.md"),
&path_index,
&id_set,
);
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].target.id(), Some("guide-auth"));
}
#[test]
fn resolve_relative_path() {
let nodes = vec![make_node("guide-auth", "docs/guides/auth.md")];
let path_index = build_path_index(&nodes);
let id_set = build_id_set(&nodes);
let edges = resolve_edges(
"guide-index",
vec![RawEdge {
target_path: "auth.md".to_string(),
relation: "references".to_string(),
confidence: Confidence::Extracted,
location: "L3".to_string(),
}],
Path::new("docs/guides/index.md"),
&path_index,
&id_set,
);
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].target.id(), Some("guide-auth"));
}
#[test]
fn resolve_frontmatter_relation_by_id() {
let nodes = vec![
make_node("adr-001", "docs/decisions/0001.md"),
make_node("adr-002", "docs/decisions/0002.md"),
];
let path_index = build_path_index(&nodes);
let id_set = build_id_set(&nodes);
let edges = resolve_edges(
"adr-002",
vec![RawEdge {
target_path: "adr-001".to_string(),
relation: "supersedes".to_string(),
confidence: Confidence::Extracted,
location: "frontmatter:supersedes".to_string(),
}],
Path::new("docs/decisions/0002.md"),
&path_index,
&id_set,
);
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].target.id(), Some("adr-001"));
}
#[test]
fn unresolved_target() {
let nodes: Vec<(String, Node)> = vec![];
let path_index = build_path_index(&nodes);
let id_set = build_id_set(&nodes);
let edges = resolve_edges(
"test",
vec![RawEdge {
target_path: "nonexistent.md".to_string(),
relation: "references".to_string(),
confidence: Confidence::Extracted,
location: "L1".to_string(),
}],
Path::new("test.md"),
&path_index,
&id_set,
);
assert_eq!(edges.len(), 1);
assert!(matches!(
edges[0].target,
crate::model::ResolvedTarget::Unresolved { .. }
));
}
#[test]
fn resolve_relative_path_with_dotdot() {
let nodes = vec![make_node("guide-setup", "docs/guides/setup.md")];
let path_index = build_path_index(&nodes);
let id_set = build_id_set(&nodes);
let edges = resolve_edges(
"adr-001",
vec![RawEdge {
target_path: "../guides/setup.md".to_string(),
relation: "references".to_string(),
confidence: Confidence::Extracted,
location: "L5".to_string(),
}],
Path::new("docs/decisions/0001-auth.md"),
&path_index,
&id_set,
);
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].target.id(), Some("guide-setup"));
}
#[test]
fn normalize_dotdot_segments() {
assert_eq!(
normalize_path_segments(Path::new("docs/decisions/../guides/setup.md")),
"docs/guides/setup.md"
);
assert_eq!(
normalize_path_segments(Path::new("a/b/c/../../d.md")),
"a/d.md"
);
}
}