Skip to main content

drft/builders/
mod.rs

1//! Builders turn a source's `(path, bytes)` records into graph nodes and edges.
2//! v0.8 ships the `fs` node builder plus two text builders (parsers):
3//! `markdown` (edges) and `frontmatter` (edges + metadata).
4
5pub 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
17/// Turn a raw link string discovered by a text builder into an edge from
18/// `source` to its resolved target.
19///
20/// Returns `None` for links with no file target (empty or anchor-only). A
21/// fragment (`#heading`) is stripped from the target — which is the node
22/// identity — and preserved as the edge's `link` metadata. Non-URI targets are
23/// resolved relative to `source`; URIs pass through unchanged.
24pub 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    // The literal text the author wrote, kept only when resolution moved it. The
49    // resolved target alone cannot distinguish `foo.md` from `./foo.md` — they
50    // resolve identically — and that distinction is what tells a wrong base from
51    // a deliberate doc-relative link. Graph-only, like `lines`; never locked.
52    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
59/// Resolve a parser's discovered links into edges, one per `(source, target)`.
60///
61/// Multiple links to the same target collapse to a single edge: `compose` dedups
62/// by `(source, target)` and overwrites per-namespace metadata, so aggregation
63/// must happen here. Source lines are unioned into a sorted, deduped `lines`
64/// array (omitted entirely when no link carried a line); the first occurrence's
65/// `link` (fragment) metadata wins.
66pub 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        // Resolution moved the path, so the literal text is kept for diagnostics.
102        assert_eq!(edge.metadata["raw"], Value::String("setup.md".into()));
103    }
104
105    #[test]
106    fn no_raw_metadata_when_resolution_is_identity() {
107        // A link already written from the graph root resolves to itself — there is
108        // no second spelling worth recording.
109        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        // The same target linked on two lines collapses to one edge whose `lines`
143        // is sorted and deduped; a distinct target is its own edge.
144        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}