Skip to main content

drft/
compose.rs

1//! Composition: the projection from the raw set of per-graph fragments into a
2//! single composed graph. This is the only module that knows about more than one
3//! graph, and the only place the reserved `@` and `_` sigils appear.
4//!
5//! Composition merges by **path coincidence**:
6//!
7//! - **Nodes** are keyed by path. Each contributing graph's bare metadata nests
8//!   under its `@<graph>` namespace; the reserved `_graphs` key lists every
9//!   contributing namespace.
10//! - **Edges** are deduped by `(source, target)`. Per-graph edge metadata, when
11//!   present, nests under `@<graph>`; `_graphs` lists contributors.
12//! - **Resolution** is namespace presence: a path that appears only as an edge
13//!   target — with no `@fs` block — is an unresolved/external reference and gets
14//!   no node entry.
15//!
16//! v0.8's graphs are all colocated, so no contribution edges are auto-emitted
17//! here; that mechanism arrives with the first separated graph.
18
19use serde_json::Value;
20
21use crate::model::{Edge, Graph, GraphSet, Metadata, PROVENANCE_KEY, namespace};
22
23/// Merge a raw set of per-graph fragments into one composed graph.
24pub fn compose(set: &GraphSet) -> Graph {
25    let mut composed = Graph::composed();
26
27    for fragment in &set.graphs {
28        let namespace = namespace_of(fragment);
29
30        for (path, node) in &fragment.nodes {
31            let entry = composed.nodes.entry(path.clone()).or_default();
32            entry
33                .metadata
34                .insert(namespace.clone(), Value::Object(node.metadata.clone()));
35            add_provenance(&mut entry.metadata, &namespace);
36        }
37    }
38
39    // Edges merge by (source, target); track insertion order for determinism via
40    // the order graphs and their edges appear, then sort at the end.
41    let mut edge_index: std::collections::HashMap<(String, String), usize> =
42        std::collections::HashMap::new();
43
44    for fragment in &set.graphs {
45        let namespace = namespace_of(fragment);
46
47        for edge in &fragment.edges {
48            let key = (edge.source.clone(), edge.target.clone());
49            let idx = *edge_index.entry(key).or_insert_with(|| {
50                composed.edges.push(Edge::with_metadata(
51                    &edge.source,
52                    &edge.target,
53                    Metadata::new(),
54                ));
55                composed.edges.len() - 1
56            });
57            let meta = &mut composed.edges[idx].metadata;
58            if !edge.metadata.is_empty() {
59                meta.insert(namespace.clone(), Value::Object(edge.metadata.clone()));
60            }
61            add_provenance(meta, &namespace);
62        }
63    }
64
65    composed.sort_edges();
66
67    composed
68}
69
70/// The `@<label>` namespace key for a fragment. Fragments always carry a label;
71/// fall back to `@graph` defensively if one is missing.
72fn namespace_of(fragment: &Graph) -> String {
73    match &fragment.label {
74        Some(label) => namespace(label),
75        None => "@graph".to_string(),
76    }
77}
78
79/// Append `namespace` to the `_graphs` provenance list, keeping it sorted and
80/// unique.
81fn add_provenance(metadata: &mut Metadata, namespace: &str) {
82    let list = metadata
83        .entry(PROVENANCE_KEY)
84        .or_insert_with(|| Value::Array(Vec::new()));
85    if let Value::Array(entries) = list {
86        let value = Value::String(namespace.to_string());
87        if !entries.contains(&value) {
88            entries.push(value);
89            entries.sort_by(|a, b| a.as_str().cmp(&b.as_str()));
90        }
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use crate::model::Node;
98    use serde_json::json;
99
100    fn obj(value: Value) -> Metadata {
101        value.as_object().unwrap().clone()
102    }
103
104    fn fragment_with_node(label: &str, path: &str, meta: Value) -> Graph {
105        let mut g = Graph::labeled(label);
106        g.set_node(path, Node::new(obj(meta)));
107        g
108    }
109
110    #[test]
111    fn single_graph_namespaces_metadata() {
112        let fs = fragment_with_node("fs", "a.md", json!({ "type": "file", "hash": "b3:1" }));
113        let composed = compose(&GraphSet::new(vec![fs]));
114
115        let node = &composed.nodes["a.md"];
116        assert_eq!(
117            node.metadata["@fs"],
118            json!({ "type": "file", "hash": "b3:1" })
119        );
120        assert_eq!(node.metadata[PROVENANCE_KEY], json!(["@fs"]));
121    }
122
123    #[test]
124    fn multiple_graphs_merge_by_path() {
125        let fs = fragment_with_node("fs", "a.md", json!({ "type": "file", "hash": "b3:1" }));
126        let frontmatter = fragment_with_node(
127            "frontmatter",
128            "a.md",
129            json!({ "title": "A", "status": "draft" }),
130        );
131
132        let composed = compose(&GraphSet::new(vec![fs, frontmatter]));
133        let node = &composed.nodes["a.md"];
134        assert_eq!(node.metadata["@fs"]["type"], json!("file"));
135        assert_eq!(node.metadata["@frontmatter"]["title"], json!("A"));
136        assert_eq!(
137            node.metadata[PROVENANCE_KEY],
138            json!(["@frontmatter", "@fs"])
139        );
140    }
141
142    #[test]
143    fn edges_dedup_and_collect_provenance() {
144        let mut markdown = Graph::labeled("markdown");
145        markdown.add_edge(Edge::new("a.md", "b.md"));
146        let mut frontmatter = Graph::labeled("frontmatter");
147        frontmatter.add_edge(Edge::new("a.md", "b.md"));
148
149        let composed = compose(&GraphSet::new(vec![markdown, frontmatter]));
150        assert_eq!(composed.edges.len(), 1);
151        assert_eq!(
152            composed.edges[0].metadata[PROVENANCE_KEY],
153            json!(["@frontmatter", "@markdown"])
154        );
155    }
156
157    #[test]
158    fn edge_metadata_nests_under_namespace() {
159        let mut markdown = Graph::labeled("markdown");
160        markdown.add_edge(Edge::with_metadata(
161            "a.md",
162            "b.md",
163            obj(json!({ "link": "b.md#x" })),
164        ));
165        let composed = compose(&GraphSet::new(vec![markdown]));
166        assert_eq!(
167            composed.edges[0].metadata["@markdown"]["link"],
168            json!("b.md#x")
169        );
170    }
171}