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
133/// A single JGF graph. Used for both a raw per-graph fragment (with `label`
134/// set to the graph name) and the composed graph (with `label` absent).
135///
136/// Nodes are keyed by path in a `BTreeMap` for deterministic, sorted output.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct Graph {
139    /// The graph name, present in a raw fragment, absent in the composed graph.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub label: Option<String>,
142    pub directed: bool,
143    #[serde(default)]
144    pub nodes: BTreeMap<String, Node>,
145    #[serde(default)]
146    pub edges: Vec<Edge>,
147}
148
149impl Graph {
150    /// An empty composed (unlabeled) directed graph.
151    pub fn composed() -> Self {
152        Self {
153            label: None,
154            directed: true,
155            nodes: BTreeMap::new(),
156            edges: Vec::new(),
157        }
158    }
159
160    /// An empty labeled directed graph (a raw per-graph fragment).
161    pub fn labeled(label: impl Into<String>) -> Self {
162        Self {
163            label: Some(label.into()),
164            directed: true,
165            nodes: BTreeMap::new(),
166            edges: Vec::new(),
167        }
168    }
169
170    /// Insert or replace the node at `path`.
171    pub fn set_node(&mut self, path: impl Into<String>, node: Node) {
172        self.nodes.insert(path.into(), node);
173    }
174
175    /// Append an edge.
176    pub fn add_edge(&mut self, edge: Edge) {
177        self.edges.push(edge);
178    }
179
180    /// Sort edges by `(source, target)` for deterministic output.
181    pub fn sort_edges(&mut self) {
182        self.edges.sort_by(|a, b| {
183            a.source
184                .cmp(&b.source)
185                .then_with(|| a.target.cmp(&b.target))
186        });
187    }
188
189    /// Wrap this graph as a composed JGF document (`{"graph": {...}}`).
190    pub fn into_document(self) -> GraphDocument {
191        GraphDocument { graph: self }
192    }
193}
194
195/// JGF single-graph document: `{"graph": {...}}`. The composed view drft emits
196/// by default.
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198pub struct GraphDocument {
199    pub graph: Graph,
200}
201
202/// JGF multi-graph document: `{"graphs": [...]}`. The raw view drft emits under
203/// `--raw` — the unmerged set of per-graph fragments.
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205pub struct GraphSet {
206    pub graphs: Vec<Graph>,
207}
208
209impl GraphSet {
210    /// A set from the given graphs.
211    pub fn new(graphs: Vec<Graph>) -> Self {
212        Self { graphs }
213    }
214}
215
216/// An invariant violation in graph labels or metadata keys.
217#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
218pub enum ValidationError {
219    #[error("graph label must not be empty")]
220    EmptyLabel,
221    #[error("graph label '{0}' must not contain '@' or start with '_'")]
222    SigilInLabel(String),
223    #[error("raw metadata key '{0}' must be bare (no leading '@' or '_')")]
224    SigilInRawKey(String),
225    #[error(
226        "composed metadata key '{0}' is invalid: expected an '@<graph>' namespace or '_graphs'"
227    )]
228    InvalidComposedKey(String),
229    #[error("composed metadata namespace '@{0}' must name a bare graph (no '@' or '_')")]
230    InvalidNamespace(String),
231}
232
233/// Validate that a graph label is non-empty and free of the reserved sigils: no
234/// `@` anywhere (it builds the `@<label>` namespace) and no leading `_`
235/// (reserved for keys like `_graphs`). Interior `_` is allowed.
236pub fn validate_label(label: &str) -> Result<(), ValidationError> {
237    if label.is_empty() {
238        return Err(ValidationError::EmptyLabel);
239    }
240    if label.contains('@') || label.starts_with('_') {
241        return Err(ValidationError::SigilInLabel(label.to_string()));
242    }
243    Ok(())
244}
245
246/// Validate that a raw fragment's metadata keys are all bare — no key may begin
247/// with the reserved `@` or `_` sigils. Builders emit bare keys; the sigils are
248/// introduced only at compose.
249pub fn validate_raw_metadata(metadata: &Metadata) -> Result<(), ValidationError> {
250    for key in metadata.keys() {
251        if key.starts_with('@') || key.starts_with('_') {
252            return Err(ValidationError::SigilInRawKey(key.clone()));
253        }
254    }
255    Ok(())
256}
257
258/// Validate that a composed metadata object's top-level keys are each either an
259/// `@<graph>` namespace (naming a bare graph) or the reserved `_graphs` key.
260pub fn validate_composed_metadata(metadata: &Metadata) -> Result<(), ValidationError> {
261    for key in metadata.keys() {
262        if key == PROVENANCE_KEY {
263            continue;
264        }
265        match key.strip_prefix('@') {
266            Some(name) => validate_label(name)
267                .map_err(|_| ValidationError::InvalidNamespace(name.to_string()))?,
268            None => return Err(ValidationError::InvalidComposedKey(key.clone())),
269        }
270    }
271    Ok(())
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use serde_json::json;
278
279    fn meta(value: Value) -> Metadata {
280        value.as_object().unwrap().clone()
281    }
282
283    #[test]
284    fn edge_lines_unions_namespaces_sorted() {
285        let mut m = Metadata::new();
286        m.insert("@markdown".into(), json!({ "lines": [5, 2] }));
287        m.insert("@frontmatter".into(), json!({ "lines": [2, 9] }));
288        m.insert("_graphs".into(), json!(["@markdown", "@frontmatter"]));
289        let edge = Edge::with_metadata("a.md", "b.md", m);
290        assert_eq!(edge.lines(), vec![2, 5, 9], "unioned, sorted, deduped");
291        assert!(Edge::new("a.md", "b.md").lines().is_empty());
292    }
293
294    #[test]
295    fn composed_document_round_trips() {
296        let mut graph = Graph::composed();
297        graph.set_node(
298            "src/graph.rs",
299            Node::new(meta(json!({
300                "@fs": { "type": "file", "hash": "b3:444" },
301                "_graphs": ["@fs"]
302            }))),
303        );
304        graph.set_node(
305            "docs/architecture.md",
306            Node::new(meta(json!({
307                "@fs": { "type": "file", "hash": "b3:222" },
308                "@frontmatter": { "title": "Architecture", "status": "draft" },
309                "_graphs": ["@fs", "@frontmatter"]
310            }))),
311        );
312        graph.add_edge(Edge::with_metadata(
313            "docs/architecture.md",
314            "src/graph.rs",
315            meta(json!({ "_graphs": ["@markdown", "@frontmatter"] })),
316        ));
317
318        let doc = graph.into_document();
319        let json = serde_json::to_value(&doc).unwrap();
320
321        // Composed envelope: top-level "graph", no "label" inside.
322        assert!(json.get("graph").is_some());
323        assert!(json["graph"].get("label").is_none());
324        assert_eq!(json["graph"]["directed"], json!(true));
325
326        let back: GraphDocument = serde_json::from_value(json).unwrap();
327        assert_eq!(doc, back);
328    }
329
330    #[test]
331    fn raw_set_round_trips() {
332        let mut fs = Graph::labeled("fs");
333        fs.set_node(
334            "src/graph.rs",
335            Node::new(meta(json!({ "type": "file", "hash": "b3:444" }))),
336        );
337
338        let mut markdown = Graph::labeled("markdown");
339        markdown.add_edge(Edge::new("docs/architecture.md", "src/graph.rs"));
340
341        let set = GraphSet::new(vec![fs, markdown]);
342        let json = serde_json::to_value(&set).unwrap();
343
344        // Raw envelope: top-level "graphs" array, each fragment labeled.
345        let graphs = json["graphs"].as_array().unwrap();
346        assert_eq!(graphs.len(), 2);
347        assert_eq!(graphs[0]["label"], json!("fs"));
348        assert_eq!(graphs[1]["label"], json!("markdown"));
349
350        let back: GraphSet = serde_json::from_value(json).unwrap();
351        assert_eq!(set, back);
352    }
353
354    #[test]
355    fn empty_metadata_is_omitted() {
356        let mut graph = Graph::composed();
357        graph.set_node("a.md", Node::default());
358        graph.add_edge(Edge::new("a.md", "b.md"));
359        let json = serde_json::to_value(graph.into_document()).unwrap();
360        assert!(json["graph"]["nodes"]["a.md"].get("metadata").is_none());
361        assert!(json["graph"]["edges"][0].get("metadata").is_none());
362    }
363
364    #[test]
365    fn node_keys_are_sorted() {
366        let mut graph = Graph::composed();
367        graph.set_node("z.md", Node::default());
368        graph.set_node("a.md", Node::default());
369        graph.set_node("m.md", Node::default());
370        let json = serde_json::to_string(&graph.into_document()).unwrap();
371        let a = json.find("a.md").unwrap();
372        let m = json.find("m.md").unwrap();
373        let z = json.find("z.md").unwrap();
374        assert!(a < m && m < z, "node keys should serialize in sorted order");
375    }
376
377    #[test]
378    fn validate_label_accepts_bare() {
379        assert!(validate_label("fs").is_ok());
380        assert!(validate_label("markdown").is_ok());
381        assert!(validate_label("frontmatter").is_ok());
382    }
383
384    #[test]
385    fn validate_label_rejects_sigils_and_empty() {
386        assert_eq!(validate_label(""), Err(ValidationError::EmptyLabel));
387        assert!(matches!(
388            validate_label("@fs"),
389            Err(ValidationError::SigilInLabel(_))
390        ));
391        assert!(matches!(
392            validate_label("_internal"),
393            Err(ValidationError::SigilInLabel(_))
394        ));
395        // Interior underscore is allowed.
396        assert!(validate_label("design_docs").is_ok());
397    }
398
399    #[test]
400    fn validate_raw_metadata_rejects_sigil_keys() {
401        assert!(validate_raw_metadata(&meta(json!({ "type": "file" }))).is_ok());
402        assert!(matches!(
403            validate_raw_metadata(&meta(json!({ "@fs": {} }))),
404            Err(ValidationError::SigilInRawKey(_))
405        ));
406        assert!(matches!(
407            validate_raw_metadata(&meta(json!({ "_graphs": [] }))),
408            Err(ValidationError::SigilInRawKey(_))
409        ));
410    }
411
412    #[test]
413    fn validate_composed_metadata_accepts_namespaces_and_provenance() {
414        assert!(
415            validate_composed_metadata(&meta(json!({
416                "@fs": { "type": "file" },
417                "_graphs": ["@fs"]
418            })))
419            .is_ok()
420        );
421    }
422
423    #[test]
424    fn validate_composed_metadata_rejects_bare_and_bad_namespace() {
425        assert!(matches!(
426            validate_composed_metadata(&meta(json!({ "type": "file" }))),
427            Err(ValidationError::InvalidComposedKey(_))
428        ));
429        assert!(matches!(
430            validate_composed_metadata(&meta(json!({ "@_internal": {} }))),
431            Err(ValidationError::InvalidNamespace(_))
432        ));
433    }
434}