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