Skip to main content

rto_graph/
artifact.rs

1//! A portable, versioned snapshot of an assembled graph.
2//!
3//! A [`GraphArtifact`] is the whole graph — every node and edge, plus the
4//! `HEAD` tree id it was assembled from — serialised as deterministic JSON. It
5//! is the unit CI publishes so that a clone can load a ready-made graph instead
6//! of re-extracting it (offline fallback: rebuild). Because [`Store::export_factset`]
7//! orders its output, the same graph always produces byte-identical JSON.
8
9use serde::{Deserialize, Serialize};
10
11use crate::{FactSet, Store, StoreError};
12
13/// Versioned schema tag for the artifact envelope. Bump on any breaking change.
14pub const ARTIFACT_SCHEMA: &str = "roteiro.graph/v1";
15
16/// A self-describing snapshot of an assembled graph.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct GraphArtifact {
19    /// Schema tag ([`ARTIFACT_SCHEMA`]).
20    pub schema: String,
21    /// Hex id of the `HEAD` tree this graph was assembled from, if recorded.
22    pub tree: Option<String>,
23    /// The full node/edge set.
24    pub facts: FactSet,
25}
26
27impl GraphArtifact {
28    /// Capture the current contents of `store` as an artifact.
29    ///
30    /// # Errors
31    /// Returns [`StoreError`] if the store cannot be read.
32    pub fn from_store(store: &Store) -> Result<Self, StoreError> {
33        Ok(Self {
34            schema: ARTIFACT_SCHEMA.to_owned(),
35            tree: store.sync_state()?,
36            facts: store.export_factset()?,
37        })
38    }
39
40    /// Serialise to pretty, deterministic JSON.
41    ///
42    /// # Errors
43    /// Returns [`StoreError::Json`] if serialisation fails.
44    pub fn to_json(&self) -> Result<String, StoreError> {
45        Ok(serde_json::to_string_pretty(self)?)
46    }
47
48    /// Parse an artifact from JSON.
49    ///
50    /// # Errors
51    /// Returns [`StoreError::Json`] on malformed JSON, or [`StoreError::Corrupt`]
52    /// if the schema tag is unrecognised.
53    pub fn from_json(json: &str) -> Result<Self, StoreError> {
54        let artifact: Self = serde_json::from_str(json)?;
55        if artifact.schema != ARTIFACT_SCHEMA {
56            return Err(StoreError::Corrupt(format!(
57                "unsupported graph artifact schema: {} (expected {ARTIFACT_SCHEMA})",
58                artifact.schema
59            )));
60        }
61        Ok(artifact)
62    }
63
64    /// Load this artifact into `store`, replacing its entire contents. If the
65    /// artifact carries a tree id it is recorded, so a `sync` at the matching
66    /// commit sees the graph as already applied; a tree-less artifact records no
67    /// synced state.
68    ///
69    /// # Errors
70    /// Returns [`StoreError`] if the rebuild fails.
71    pub fn load_into(&self, store: &mut Store) -> Result<(), StoreError> {
72        store.rebuild(&self.facts, self.tree.as_deref())
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::{ARTIFACT_SCHEMA, GraphArtifact};
79    use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Store};
80
81    /// The three nodes and two edges of the sample graph, as builder closures so
82    /// tests can apply them in any order.
83    fn sample_nodes() -> Vec<Node> {
84        vec![
85            Node::new("adr:0001", NodeKind::Adr, "Build Roteiro"),
86            Node::new("sym:rust:a.rs#main", NodeKind::Fn, "main"),
87            Node::new("sym:rust:a.rs#helper", NodeKind::Fn, "helper"),
88        ]
89    }
90
91    fn sample_edges() -> Vec<Edge> {
92        vec![
93            Edge::authored("adr:0001", "sym:rust:a.rs#main", EdgeKind::References),
94            Edge::derived(
95                "sym:rust:a.rs#main",
96                "sym:rust:a.rs#helper",
97                EdgeKind::Calls,
98            ),
99        ]
100    }
101
102    /// Build a store from the sample graph, applying nodes/edges in the given
103    /// order (to exercise insertion-order independence) and recording `tree`.
104    fn seeded_ordered(reversed: bool, tree: Option<&str>) -> Store {
105        let mut store = Store::open_in_memory().expect("store");
106        let mut nodes = sample_nodes();
107        let mut edges = sample_edges();
108        if reversed {
109            nodes.reverse();
110            edges.reverse();
111        }
112        let mut facts = FactSet::new();
113        facts.nodes = nodes;
114        facts.edges = edges;
115        store.rebuild(&facts, tree).expect("rebuild");
116        store
117    }
118
119    fn seeded() -> Store {
120        seeded_ordered(false, Some("treeabc"))
121    }
122
123    #[test]
124    fn round_trips_through_json_and_a_fresh_store() {
125        let store = seeded();
126        let artifact = GraphArtifact::from_store(&store).expect("capture");
127        assert_eq!(artifact.schema, ARTIFACT_SCHEMA);
128        assert_eq!(artifact.tree.as_deref(), Some("treeabc"));
129        assert_eq!(artifact.facts.nodes.len(), 3);
130        assert_eq!(artifact.facts.edges.len(), 2);
131
132        // JSON round-trip is lossless.
133        let json = artifact.to_json().expect("json");
134        let parsed = GraphArtifact::from_json(&json).expect("parse");
135        assert_eq!(parsed, artifact);
136
137        // Loading into a fresh store reproduces the graph without extraction.
138        let mut fresh = Store::open_in_memory().expect("fresh");
139        parsed.load_into(&mut fresh).expect("load");
140        assert_eq!(fresh.node_count().expect("nc"), 3);
141        assert_eq!(fresh.edge_count().expect("ec"), 2);
142        assert_eq!(
143            fresh.sync_state().expect("state").as_deref(),
144            Some("treeabc")
145        );
146        // Mixed provenance survives the round-trip.
147        let inbound = fresh.edges_to("sym:rust:a.rs#main").expect("edges");
148        assert!(
149            inbound
150                .iter()
151                .any(|e| e.provenance == crate::Provenance::Authored)
152        );
153    }
154
155    #[test]
156    fn export_is_deterministic_regardless_of_insertion_order() {
157        // The same graph, inserted forwards vs. reversed, must export to
158        // byte-identical JSON — proving the ordering comes from the export, not
159        // from insertion order.
160        let a = GraphArtifact::from_store(&seeded_ordered(false, Some("treeabc")))
161            .expect("a")
162            .to_json()
163            .expect("ja");
164        let b = GraphArtifact::from_store(&seeded_ordered(true, Some("treeabc")))
165            .expect("b")
166            .to_json()
167            .expect("jb");
168        assert_eq!(a, b);
169    }
170
171    #[test]
172    fn artifact_without_tree_records_no_sync_state() {
173        // An artifact carrying no tree id must load as `sync_state == None`, not
174        // an empty string, so a later `sync` does not spuriously short-circuit.
175        let store = seeded_ordered(false, None);
176        let artifact = GraphArtifact::from_store(&store).expect("capture");
177        assert_eq!(artifact.tree, None);
178
179        let mut fresh = Store::open_in_memory().expect("fresh");
180        artifact.load_into(&mut fresh).expect("load");
181        assert_eq!(fresh.node_count().expect("nc"), 3);
182        assert_eq!(fresh.sync_state().expect("state"), None);
183    }
184
185    #[test]
186    fn rejects_unknown_schema() {
187        let json = r#"{"schema":"roteiro.graph/v999","tree":null,"facts":{"nodes":[],"edges":[]}}"#;
188        assert!(matches!(
189            GraphArtifact::from_json(json),
190            Err(crate::StoreError::Corrupt(_))
191        ));
192    }
193}