Skip to main content

drft/
model.rs

1//! Core graph data model and JGF (JSON Graph Format) serialization.
2//!
3//! drft's substrate is a *set of independent graphs* (the raw view); a
4//! composition step merges them by path into one graph (the composed view).
5//! Both views share the same node/edge shape and both serialize to valid JGF:
6//!
7//! - **Composed** — a single graph: `{"graph": {...}}` ([`GraphDocument`]).
8//! - **Raw** — the unmerged set: `{"graphs": [...]}` ([`GraphSet`]), JGF's
9//!   multi-graph form.
10//!
11//! A node or edge carries a JSON object of [`Metadata`]. The keys differ by
12//! view:
13//!
14//! - In a **raw** per-graph fragment, keys are *bare* — whatever the builder
15//!   emits (e.g. `{"type": "file", "hash": "b3:…"}`).
16//! - In the **composed** graph, keys are *namespaced*: an `@<graph>` object per
17//!   contributing graph, plus the reserved `_graphs` provenance list.
18//!
19//! `@` and `_` are reserved, compose-only sigils. A graph label must not contain
20//! `@` (it builds the `@<label>` namespace) or start with `_` (reserved for keys
21//! like `_graphs`); interior `_` is fine. [`validate_label`],
22//! [`validate_raw_metadata`], and [`validate_composed_metadata`] enforce these
23//! invariants.
24
25use serde::{Deserialize, Serialize};
26use serde_json::{Map, Value};
27use std::collections::{BTreeMap, BTreeSet};
28
29/// The reserved provenance key stamped on composed nodes and edges.
30pub const PROVENANCE_KEY: &str = "_graphs";
31
32/// The `fs` namespace key on a composed node — the base graph that carries
33/// content type and hash. The single place the `@fs` literal lives.
34pub const FS_NAMESPACE: &str = "@fs";
35
36/// The `@<label>` namespace key under which a graph's contribution nests in a
37/// composed node or edge. The single place the `@` prefix rule lives;
38/// [`FS_NAMESPACE`] is `namespace("fs")`.
39pub fn namespace(label: &str) -> String {
40    format!("@{label}")
41}
42
43/// A JSON object of metadata attached to a node or edge.
44///
45/// `serde_json::Map` (with the default feature set) is backed by a `BTreeMap`,
46/// so key order is sorted and deterministic — important for golden tests and
47/// reproducible output.
48pub type Metadata = Map<String, Value>;
49
50/// A node in a graph. Its identity is its key in [`Graph::nodes`] (a path);
51/// the node body carries only metadata.
52#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
53pub struct Node {
54    #[serde(default, skip_serializing_if = "Map::is_empty")]
55    pub metadata: Metadata,
56}
57
58impl Node {
59    /// A node with the given metadata object.
60    pub fn new(metadata: Metadata) -> Self {
61        Self { metadata }
62    }
63
64    /// This composed node's current `fs` content hash, if it has one.
65    pub fn fs_hash(&self) -> Option<&str> {
66        self.metadata.get(FS_NAMESPACE)?.get("hash")?.as_str()
67    }
68
69    /// This composed node's `fs` type (`file`, `symlink`, `directory`), if any.
70    pub fn fs_type(&self) -> Option<&str> {
71        self.metadata.get(FS_NAMESPACE)?.get("type")?.as_str()
72    }
73
74    /// Whether this composed node is resolved — present with an `@fs` block.
75    /// Resolution is namespace presence.
76    pub fn is_resolved(&self) -> bool {
77        self.metadata.contains_key(FS_NAMESPACE)
78    }
79}
80
81/// A directed edge from `source` to `target` (both node-identity paths).
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct Edge {
84    pub source: String,
85    pub target: String,
86    #[serde(default, skip_serializing_if = "Map::is_empty")]
87    pub metadata: Metadata,
88}
89
90impl Edge {
91    /// An edge from `source` to `target` with no metadata.
92    pub fn new(source: impl Into<String>, target: impl Into<String>) -> Self {
93        Self {
94            source: source.into(),
95            target: target.into(),
96            metadata: Metadata::new(),
97        }
98    }
99
100    /// An edge from `source` to `target` carrying the given metadata.
101    pub fn with_metadata(
102        source: impl Into<String>,
103        target: impl Into<String>,
104        metadata: Metadata,
105    ) -> Self {
106        Self {
107            source: source.into(),
108            target: target.into(),
109            metadata,
110        }
111    }
112
113    /// The source line(s) where this edge's link appears, unioned across parser
114    /// namespaces (`@markdown`, `@frontmatter`, …) and sorted/deduped. Empty when
115    /// no parser recorded a line. The link lives in `source`, so these are
116    /// positions within `source`.
117    pub fn lines(&self) -> Vec<usize> {
118        let mut lines = BTreeSet::new();
119        for (key, value) in &self.metadata {
120            if !key.starts_with('@') {
121                continue;
122            }
123            if let Some(arr) = value.get("lines").and_then(Value::as_array) {
124                for n in arr.iter().filter_map(Value::as_u64) {
125                    lines.insert(n as usize);
126                }
127            }
128        }
129        lines.into_iter().collect()
130    }
131
132    /// The literal link text(s) that produced this edge, across contributing
133    /// graphs. Present only where resolution moved the path, so an edge whose
134    /// target is exactly what the author typed reports nothing. Two graphs can
135    /// disagree — one doc may write `./x.md` where another writes `x.md`.
136    pub fn raw_links(&self) -> Vec<&str> {
137        let mut raws = BTreeSet::new();
138        for (key, value) in &self.metadata {
139            if !key.starts_with('@') {
140                continue;
141            }
142            if let Some(raw) = value.get("raw").and_then(Value::as_str) {
143                raws.insert(raw);
144            }
145        }
146        raws.into_iter().collect()
147    }
148}
149
150/// A single JGF graph. Used for both a raw per-graph fragment (with `label`
151/// set to the graph name) and the composed graph (with `label` absent).
152///
153/// Nodes are keyed by path in a `BTreeMap` for deterministic, sorted output.
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155pub struct Graph {
156    /// The graph name, present in a raw fragment, absent in the composed graph.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub label: Option<String>,
159    pub directed: bool,
160    #[serde(default)]
161    pub nodes: BTreeMap<String, Node>,
162    #[serde(default)]
163    pub edges: Vec<Edge>,
164}
165
166impl Graph {
167    /// An empty composed (unlabeled) directed graph.
168    pub fn composed() -> Self {
169        Self {
170            label: None,
171            directed: true,
172            nodes: BTreeMap::new(),
173            edges: Vec::new(),
174        }
175    }
176
177    /// An empty labeled directed graph (a raw per-graph fragment).
178    pub fn labeled(label: impl Into<String>) -> Self {
179        Self {
180            label: Some(label.into()),
181            directed: true,
182            nodes: BTreeMap::new(),
183            edges: Vec::new(),
184        }
185    }
186
187    /// Insert or replace the node at `path`.
188    pub fn set_node(&mut self, path: impl Into<String>, node: Node) {
189        self.nodes.insert(path.into(), node);
190    }
191
192    /// Append an edge.
193    pub fn add_edge(&mut self, edge: Edge) {
194        self.edges.push(edge);
195    }
196
197    /// Sort edges by `(source, target)` for deterministic output.
198    pub fn sort_edges(&mut self) {
199        self.edges.sort_by(|a, b| {
200            a.source
201                .cmp(&b.source)
202                .then_with(|| a.target.cmp(&b.target))
203        });
204    }
205
206    /// Wrap this graph as a composed JGF document (`{"graph": {...}}`).
207    pub fn into_document(self) -> GraphDocument {
208        GraphDocument { graph: self }
209    }
210}
211
212/// JGF single-graph document: `{"graph": {...}}`. The composed view drft emits
213/// by default.
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215pub struct GraphDocument {
216    pub graph: Graph,
217}
218
219/// JGF multi-graph document: `{"graphs": [...]}`. The raw view drft emits under
220/// `--raw` — the unmerged set of per-graph fragments.
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222pub struct GraphSet {
223    pub graphs: Vec<Graph>,
224}
225
226impl GraphSet {
227    /// A set from the given graphs.
228    pub fn new(graphs: Vec<Graph>) -> Self {
229        Self { graphs }
230    }
231}
232
233/// An invariant violation in graph labels or metadata keys.
234#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
235pub enum ValidationError {
236    #[error("graph label must not be empty")]
237    EmptyLabel,
238    #[error("graph label '{0}' must not contain '@' or start with '_'")]
239    SigilInLabel(String),
240    #[error("raw metadata key '{0}' must be bare (no leading '@' or '_')")]
241    SigilInRawKey(String),
242    #[error(
243        "composed metadata key '{0}' is invalid: expected an '@<graph>' namespace or '_graphs'"
244    )]
245    InvalidComposedKey(String),
246    #[error("composed metadata namespace '@{0}' must name a bare graph (no '@' or '_')")]
247    InvalidNamespace(String),
248}
249
250/// Validate that a graph label is non-empty and free of the reserved sigils: no
251/// `@` anywhere (it builds the `@<label>` namespace) and no leading `_`
252/// (reserved for keys like `_graphs`). Interior `_` is allowed.
253pub fn validate_label(label: &str) -> Result<(), ValidationError> {
254    if label.is_empty() {
255        return Err(ValidationError::EmptyLabel);
256    }
257    if label.contains('@') || label.starts_with('_') {
258        return Err(ValidationError::SigilInLabel(label.to_string()));
259    }
260    Ok(())
261}
262
263/// Validate that a raw fragment's metadata keys are all bare — no key may begin
264/// with the reserved `@` or `_` sigils. Builders emit bare keys; the sigils are
265/// introduced only at compose.
266pub fn validate_raw_metadata(metadata: &Metadata) -> Result<(), ValidationError> {
267    for key in metadata.keys() {
268        if key.starts_with('@') || key.starts_with('_') {
269            return Err(ValidationError::SigilInRawKey(key.clone()));
270        }
271    }
272    Ok(())
273}
274
275/// Validate that a composed metadata object's top-level keys are each either an
276/// `@<graph>` namespace (naming a bare graph) or the reserved `_graphs` key.
277pub fn validate_composed_metadata(metadata: &Metadata) -> Result<(), ValidationError> {
278    for key in metadata.keys() {
279        if key == PROVENANCE_KEY {
280            continue;
281        }
282        match key.strip_prefix('@') {
283            Some(name) => validate_label(name)
284                .map_err(|_| ValidationError::InvalidNamespace(name.to_string()))?,
285            None => return Err(ValidationError::InvalidComposedKey(key.clone())),
286        }
287    }
288    Ok(())
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use serde_json::json;
295
296    fn meta(value: Value) -> Metadata {
297        value.as_object().unwrap().clone()
298    }
299
300    #[test]
301    fn edge_lines_unions_namespaces_sorted() {
302        let mut m = Metadata::new();
303        m.insert("@markdown".into(), json!({ "lines": [5, 2] }));
304        m.insert("@frontmatter".into(), json!({ "lines": [2, 9] }));
305        m.insert("_graphs".into(), json!(["@markdown", "@frontmatter"]));
306        let edge = Edge::with_metadata("a.md", "b.md", m);
307        assert_eq!(edge.lines(), vec![2, 5, 9], "unioned, sorted, deduped");
308        assert!(Edge::new("a.md", "b.md").lines().is_empty());
309    }
310
311    #[test]
312    fn composed_document_round_trips() {
313        let mut graph = Graph::composed();
314        graph.set_node(
315            "src/graph.rs",
316            Node::new(meta(json!({
317                "@fs": { "type": "file", "hash": "b3:444" },
318                "_graphs": ["@fs"]
319            }))),
320        );
321        graph.set_node(
322            "docs/architecture.md",
323            Node::new(meta(json!({
324                "@fs": { "type": "file", "hash": "b3:222" },
325                "@frontmatter": { "title": "Architecture", "status": "draft" },
326                "_graphs": ["@fs", "@frontmatter"]
327            }))),
328        );
329        graph.add_edge(Edge::with_metadata(
330            "docs/architecture.md",
331            "src/graph.rs",
332            meta(json!({ "_graphs": ["@markdown", "@frontmatter"] })),
333        ));
334
335        let doc = graph.into_document();
336        let json = serde_json::to_value(&doc).unwrap();
337
338        // Composed envelope: top-level "graph", no "label" inside.
339        assert!(json.get("graph").is_some());
340        assert!(json["graph"].get("label").is_none());
341        assert_eq!(json["graph"]["directed"], json!(true));
342
343        let back: GraphDocument = serde_json::from_value(json).unwrap();
344        assert_eq!(doc, back);
345    }
346
347    #[test]
348    fn raw_set_round_trips() {
349        let mut fs = Graph::labeled("fs");
350        fs.set_node(
351            "src/graph.rs",
352            Node::new(meta(json!({ "type": "file", "hash": "b3:444" }))),
353        );
354
355        let mut markdown = Graph::labeled("markdown");
356        markdown.add_edge(Edge::new("docs/architecture.md", "src/graph.rs"));
357
358        let set = GraphSet::new(vec![fs, markdown]);
359        let json = serde_json::to_value(&set).unwrap();
360
361        // Raw envelope: top-level "graphs" array, each fragment labeled.
362        let graphs = json["graphs"].as_array().unwrap();
363        assert_eq!(graphs.len(), 2);
364        assert_eq!(graphs[0]["label"], json!("fs"));
365        assert_eq!(graphs[1]["label"], json!("markdown"));
366
367        let back: GraphSet = serde_json::from_value(json).unwrap();
368        assert_eq!(set, back);
369    }
370
371    #[test]
372    fn empty_metadata_is_omitted() {
373        let mut graph = Graph::composed();
374        graph.set_node("a.md", Node::default());
375        graph.add_edge(Edge::new("a.md", "b.md"));
376        let json = serde_json::to_value(graph.into_document()).unwrap();
377        assert!(json["graph"]["nodes"]["a.md"].get("metadata").is_none());
378        assert!(json["graph"]["edges"][0].get("metadata").is_none());
379    }
380
381    #[test]
382    fn node_keys_are_sorted() {
383        let mut graph = Graph::composed();
384        graph.set_node("z.md", Node::default());
385        graph.set_node("a.md", Node::default());
386        graph.set_node("m.md", Node::default());
387        let json = serde_json::to_string(&graph.into_document()).unwrap();
388        let a = json.find("a.md").unwrap();
389        let m = json.find("m.md").unwrap();
390        let z = json.find("z.md").unwrap();
391        assert!(a < m && m < z, "node keys should serialize in sorted order");
392    }
393
394    #[test]
395    fn validate_label_accepts_bare() {
396        assert!(validate_label("fs").is_ok());
397        assert!(validate_label("markdown").is_ok());
398        assert!(validate_label("frontmatter").is_ok());
399    }
400
401    #[test]
402    fn validate_label_rejects_sigils_and_empty() {
403        assert_eq!(validate_label(""), Err(ValidationError::EmptyLabel));
404        assert!(matches!(
405            validate_label("@fs"),
406            Err(ValidationError::SigilInLabel(_))
407        ));
408        assert!(matches!(
409            validate_label("_internal"),
410            Err(ValidationError::SigilInLabel(_))
411        ));
412        // Interior underscore is allowed.
413        assert!(validate_label("design_docs").is_ok());
414    }
415
416    #[test]
417    fn validate_raw_metadata_rejects_sigil_keys() {
418        assert!(validate_raw_metadata(&meta(json!({ "type": "file" }))).is_ok());
419        assert!(matches!(
420            validate_raw_metadata(&meta(json!({ "@fs": {} }))),
421            Err(ValidationError::SigilInRawKey(_))
422        ));
423        assert!(matches!(
424            validate_raw_metadata(&meta(json!({ "_graphs": [] }))),
425            Err(ValidationError::SigilInRawKey(_))
426        ));
427    }
428
429    #[test]
430    fn validate_composed_metadata_accepts_namespaces_and_provenance() {
431        assert!(
432            validate_composed_metadata(&meta(json!({
433                "@fs": { "type": "file" },
434                "_graphs": ["@fs"]
435            })))
436            .is_ok()
437        );
438    }
439
440    #[test]
441    fn validate_composed_metadata_rejects_bare_and_bad_namespace() {
442        assert!(matches!(
443            validate_composed_metadata(&meta(json!({ "type": "file" }))),
444            Err(ValidationError::InvalidComposedKey(_))
445        ));
446        assert!(matches!(
447            validate_composed_metadata(&meta(json!({ "@_internal": {} }))),
448            Err(ValidationError::InvalidNamespace(_))
449        ));
450    }
451}