1use serde::{Deserialize, Serialize};
26use serde_json::{Map, Value};
27use std::collections::BTreeMap;
28
29pub const PROVENANCE_KEY: &str = "_graphs";
31
32pub const FS_NAMESPACE: &str = "@fs";
35
36pub fn namespace(label: &str) -> String {
40 format!("@{label}")
41}
42
43pub type Metadata = Map<String, Value>;
49
50#[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 pub fn new(metadata: Metadata) -> Self {
61 Self { metadata }
62 }
63
64 pub fn fs_hash(&self) -> Option<&str> {
66 self.metadata.get(FS_NAMESPACE)?.get("hash")?.as_str()
67 }
68
69 pub fn is_resolved(&self) -> bool {
72 self.metadata.contains_key(FS_NAMESPACE)
73 }
74}
75
76#[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 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct Graph {
115 #[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 pub fn composed() -> Self {
128 Self {
129 label: None,
130 directed: true,
131 nodes: BTreeMap::new(),
132 edges: Vec::new(),
133 }
134 }
135
136 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 pub fn set_node(&mut self, path: impl Into<String>, node: Node) {
148 self.nodes.insert(path.into(), node);
149 }
150
151 pub fn add_edge(&mut self, edge: Edge) {
153 self.edges.push(edge);
154 }
155
156 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 pub fn into_document(self) -> GraphDocument {
167 GraphDocument { graph: self }
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
174pub struct GraphDocument {
175 pub graph: Graph,
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181pub struct GraphSet {
182 pub graphs: Vec<Graph>,
183}
184
185impl GraphSet {
186 pub fn new(graphs: Vec<Graph>) -> Self {
188 Self { graphs }
189 }
190}
191
192#[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
209pub 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
222pub 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
234pub 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 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 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 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}