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
49    Some(Edge::with_metadata(source, target, metadata))
50}
51
52/// Resolve a parser's discovered links into edges, one per `(source, target)`.
53///
54/// Multiple links to the same target collapse to a single edge: `compose` dedups
55/// by `(source, target)` and overwrites per-namespace metadata, so aggregation
56/// must happen here. Source lines are unioned into a sorted, deduped `lines`
57/// array (omitted entirely when no link carried a line); the first occurrence's
58/// `link` (fragment) metadata wins.
59pub 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        // The same target linked on two lines collapses to one edge whose `lines`
126        // is sorted and deduped; a distinct target is its own edge.
127        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}