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
49 Some(Edge::with_metadata(source, target, metadata))
50}
51
52pub fn link_edges(source: &str, links: &[Link]) -> Vec<Edge> {
60 let mut by_target: BTreeMap<String, (Edge, BTreeSet<usize>)> = BTreeMap::new();
61 for link in links {
62 let Some(edge) = link_edge(source, &link.target) else {
63 continue;
64 };
65 let entry = by_target
66 .entry(edge.target.clone())
67 .or_insert_with(|| (edge, BTreeSet::new()));
68 if let Some(line) = link.line {
69 entry.1.insert(line);
70 }
71 }
72
73 by_target
74 .into_values()
75 .map(|(mut edge, lines)| {
76 if !lines.is_empty() {
77 let arr: Vec<Value> = lines.into_iter().map(|l| Value::from(l as u64)).collect();
78 edge.metadata.insert("lines".into(), Value::Array(arr));
79 }
80 edge
81 })
82 .collect()
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 #[test]
90 fn resolves_relative_target() {
91 let edge = link_edge("docs/guide.md", "setup.md").unwrap();
92 assert_eq!(edge.source, "docs/guide.md");
93 assert_eq!(edge.target, "docs/setup.md");
94 assert!(edge.metadata.is_empty());
95 }
96
97 #[test]
98 fn strips_fragment_into_link_metadata() {
99 let edge = link_edge("a.md", "b.md#heading").unwrap();
100 assert_eq!(edge.target, "b.md");
101 assert_eq!(edge.metadata["link"], Value::String("b.md#heading".into()));
102 }
103
104 #[test]
105 fn passes_through_uris() {
106 let edge = link_edge("a.md", "https://example.com/x").unwrap();
107 assert_eq!(edge.target, "https://example.com/x");
108 }
109
110 #[test]
111 fn drops_anchor_only_and_empty() {
112 assert!(link_edge("a.md", "#section").is_none());
113 assert!(link_edge("a.md", " ").is_none());
114 }
115
116 fn link(target: &str, line: Option<usize>) -> Link {
117 Link {
118 target: target.into(),
119 line,
120 }
121 }
122
123 #[test]
124 fn aggregates_lines_per_target() {
125 let links = vec![
128 link("b.md", Some(6)),
129 link("b.md", Some(2)),
130 link("b.md", Some(6)),
131 link("c.md", Some(3)),
132 ];
133 let edges = link_edges("a.md", &links);
134 let b = edges.iter().find(|e| e.target == "b.md").unwrap();
135 assert_eq!(b.metadata["lines"], serde_json::json!([2, 6]));
136 let c = edges.iter().find(|e| e.target == "c.md").unwrap();
137 assert_eq!(c.metadata["lines"], serde_json::json!([3]));
138 }
139
140 #[test]
141 fn omits_lines_when_no_line_known() {
142 let edges = link_edges("a.md", &[link("b.md", None)]);
143 assert_eq!(edges.len(), 1);
144 assert!(edges[0].metadata.get("lines").is_none());
145 }
146}