/// Wiki-Link Graph: Extract and manage [[links]] between documents /// /// Phase 1: Wiki-Link Graph Indexing /// /// Used to scope queries to project namespaces and enable graph traversal. /// For example: poimen/tools/kubectl.md [[debugging.md]] creates an edge /// from tools/kubectl to debugging (within same project). use anyhow::{anyhow, Result}; use regex::Regex; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; #[derive(Debug, Clone, PartialEq)] pub enum LinkType { Memory, // [[debugging.md]] Skill, // [[SKILL-kubernetes-debugging]] Shared, // [[../../shared/concepts/design-patterns.md]] Unknown, } #[derive(Debug, Clone)] pub struct WikiLink { pub project: String, pub source_path: String, // e.g., "tools/kubectl.md" pub target_path: String, // e.g., "debugging.md" pub link_type: LinkType, pub resolved_path: Option, // fully qualified } /// Parser: Extract [[links]] from markdown pub struct WikiLinkParser; impl WikiLinkParser { /// Extract all wiki-links from markdown content pub fn parse_links(content: &str) -> Result> { let regex = Regex::new(r"\[\[([^\]]+)\]\]")?; let links = regex .captures_iter(content) .map(|cap| cap[1].trim().to_string()) .collect(); Ok(links) } /// Infer link type from target path pub fn infer_link_type(target: &str) -> LinkType { if target.contains("SKILL-") { LinkType::Skill } else if target.contains("shared:") || target.starts_with("../../") { LinkType::Shared } else if target.ends_with(".md") || !target.contains("/") { LinkType::Memory } else { LinkType::Unknown } } /// Resolve relative path to fully qualified path /// /// Examples: /// - "debugging.md" from "tools/kubectl.md" -> "tools/debugging.md" /// - "../concepts/design.md" from "tools/kubectl.md" -> "concepts/design.md" /// - "../../shared/skills/SKILL-*" from "tools/kubectl.md" -> "shared/skills/SKILL-*" pub fn resolve_path(target: &str, source_dir: &Path) -> Result { // If target starts with shared: prefix, it's absolute if target.starts_with("shared:") { return Ok(PathBuf::from(target.replace("shared:", "shared/"))); } // If target is just a filename, it's in the same dir as source if !target.contains("/") && !target.contains("..") { return Ok(source_dir.join(target)); } // Otherwise resolve relative path let resolved = source_dir.parent().unwrap_or_else(|| Path::new("")).join(target); Ok(resolved) } } /// Graph Index: Stores and queries wiki-link relationships pub struct WikiLinkGraph { /// Forward links: source -> [targets] forward_links: HashMap>, /// Reverse links: target -> [sources] (for backlinks) backward_links: HashMap>, /// Project scoping project: String, } impl WikiLinkGraph { pub fn new(project: &str) -> Self { Self { forward_links: HashMap::new(), backward_links: HashMap::new(), project: project.to_string(), } } /// Add a wiki-link edge pub fn add_link(&mut self, source: &str, target: &str) { self.forward_links.entry(source.to_string()) .or_insert_with(Vec::new) .push(target.to_string()); self.backward_links.entry(target.to_string()) .or_insert_with(Vec::new) .push(source.to_string()); } /// Get all reachable documents from a starting point (BFS) pub fn reachable_docs(&self, start: &str) -> HashSet { let mut visited = HashSet::new(); let mut queue = vec![start.to_string()]; while let Some(current) = queue.pop() { if visited.contains(¤t) { continue; } visited.insert(current.clone()); if let Some(targets) = self.forward_links.get(¤t) { for target in targets { if !visited.contains(target) { queue.push(target.clone()); } } } } visited } /// Get backlinks (documents that link to this one) pub fn backlinks(&self, doc: &str) -> Vec { self.backward_links .get(doc) .cloned() .unwrap_or_default() } /// Get forward links (documents this one links to) pub fn forward_links(&self, doc: &str) -> Vec { self.forward_links .get(doc) .cloned() .unwrap_or_default() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_wiki_links() { let content = r#" ## Borrowing See [[lifetimes.md]] for more. Also check [[../../shared/skills/SKILL-ownership]] "#; let links = WikiLinkParser::parse_links(content).unwrap(); assert_eq!(links.len(), 2); assert!(links.contains(&"lifetimes.md".to_string())); assert!(links.contains(&"../../shared/skills/SKILL-ownership".to_string())); } #[test] fn test_infer_link_type() { assert_eq!(WikiLinkParser::infer_link_type("debugging.md"), LinkType::Memory); assert_eq!( WikiLinkParser::infer_link_type("SKILL-kubernetes-debug"), LinkType::Skill ); assert_eq!( WikiLinkParser::infer_link_type("../../shared/concepts/design.md"), LinkType::Shared ); } #[test] fn test_resolve_path_simple() { let source_dir = Path::new("poimen/tools"); let resolved = WikiLinkParser::resolve_path("debugging.md", source_dir).unwrap(); assert_eq!(resolved.file_name().unwrap(), "debugging.md"); } #[test] fn test_graph_reachable_docs() { let mut graph = WikiLinkGraph::new("poimen"); graph.add_link("index.md", "tools/kubectl.md"); graph.add_link("tools/kubectl.md", "debugging/pod-crashes.md"); graph.add_link("debugging/pod-crashes.md", "../memories/ownership.md"); let reachable = graph.reachable_docs("index.md"); assert!(reachable.contains("index.md")); assert!(reachable.contains("tools/kubectl.md")); assert!(reachable.contains("debugging/pod-crashes.md")); assert!(reachable.contains("../memories/ownership.md")); } #[test] fn test_graph_backlinks() { let mut graph = WikiLinkGraph::new("poimen"); graph.add_link("tools/kubectl.md", "debugging.md"); graph.add_link("tools/docker.md", "debugging.md"); let backlinks = graph.backlinks("debugging.md"); assert_eq!(backlinks.len(), 2); assert!(backlinks.contains(&"tools/kubectl.md".to_string())); assert!(backlinks.contains(&"tools/docker.md".to_string())); } }