1pub mod frontmatter;
6pub mod fs;
7pub mod markdown;
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use serde_json::Value;
12
13use crate::model::{Edge, Metadata};
14use crate::parsers::Link;
15use crate::util::{is_uri, resolve_link};
16
17pub fn link_edge(source: &str, raw: &str) -> Option<Edge> {
25 let trimmed = raw.trim();
26 if trimmed.is_empty() || trimmed.starts_with('#') {
27 return None;
28 }
29
30 let (base, fragment) = match trimmed.find('#') {
31 Some(i) => (&trimmed[..i], Some(&trimmed[i..])),
32 None => (trimmed, None),
33 };
34 if base.is_empty() {
35 return None;
36 }
37
38 let target = if is_uri(base) {
39 base.to_string()
40 } else {
41 resolve_link(source, base)
42 };
43
44 let mut metadata = Metadata::new();
45 if let Some(frag) = fragment {
46 metadata.insert("link".into(), Value::String(format!("{target}{frag}")));
47 }
48 if base != target {
53 metadata.insert("raw".into(), Value::String(base.to_string()));
54 }
55
56 Some(Edge::with_metadata(source, target, metadata))
57}
58
59pub fn link_edges(source: &str, links: &[Link]) -> Vec<Edge> {
67 let mut by_target: BTreeMap<String, (Edge, BTreeSet<usize>)> = BTreeMap::new();
68 for link in links {
69 let Some(edge) = link_edge(source, &link.target) else {
70 continue;
71 };
72 let entry = by_target
73 .entry(edge.target.clone())
74 .or_insert_with(|| (edge, BTreeSet::new()));
75 if let Some(line) = link.line {
76 entry.1.insert(line);
77 }
78 }
79
80 by_target
81 .into_values()
82 .map(|(mut edge, lines)| {
83 if !lines.is_empty() {
84 let arr: Vec<Value> = lines.into_iter().map(|l| Value::from(l as u64)).collect();
85 edge.metadata.insert("lines".into(), Value::Array(arr));
86 }
87 edge
88 })
89 .collect()
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn resolves_relative_target() {
98 let edge = link_edge("docs/guide.md", "setup.md").unwrap();
99 assert_eq!(edge.source, "docs/guide.md");
100 assert_eq!(edge.target, "docs/setup.md");
101 assert_eq!(edge.metadata["raw"], Value::String("setup.md".into()));
103 }
104
105 #[test]
106 fn no_raw_metadata_when_resolution_is_identity() {
107 let edge = link_edge("guide.md", "setup.md").unwrap();
110 assert_eq!(edge.target, "setup.md");
111 assert!(edge.metadata.is_empty());
112 }
113
114 #[test]
115 fn strips_fragment_into_link_metadata() {
116 let edge = link_edge("a.md", "b.md#heading").unwrap();
117 assert_eq!(edge.target, "b.md");
118 assert_eq!(edge.metadata["link"], Value::String("b.md#heading".into()));
119 }
120
121 #[test]
122 fn passes_through_uris() {
123 let edge = link_edge("a.md", "https://example.com/x").unwrap();
124 assert_eq!(edge.target, "https://example.com/x");
125 }
126
127 #[test]
128 fn drops_anchor_only_and_empty() {
129 assert!(link_edge("a.md", "#section").is_none());
130 assert!(link_edge("a.md", " ").is_none());
131 }
132
133 fn link(target: &str, line: Option<usize>) -> Link {
134 Link {
135 target: target.into(),
136 line,
137 }
138 }
139
140 #[test]
141 fn aggregates_lines_per_target() {
142 let links = vec![
145 link("b.md", Some(6)),
146 link("b.md", Some(2)),
147 link("b.md", Some(6)),
148 link("c.md", Some(3)),
149 ];
150 let edges = link_edges("a.md", &links);
151 let b = edges.iter().find(|e| e.target == "b.md").unwrap();
152 assert_eq!(b.metadata["lines"], serde_json::json!([2, 6]));
153 let c = edges.iter().find(|e| e.target == "c.md").unwrap();
154 assert_eq!(c.metadata["lines"], serde_json::json!([3]));
155 }
156
157 #[test]
158 fn omits_lines_when_no_line_known() {
159 let edges = link_edges("a.md", &[link("b.md", None)]);
160 assert_eq!(edges.len(), 1);
161 assert!(edges[0].metadata.get("lines").is_none());
162 }
163}