1use serde::{Deserialize, Serialize};
26use serde_json::{Map, Value};
27use std::collections::{BTreeMap, BTreeSet};
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 fs_type(&self) -> Option<&str> {
71 self.metadata.get(FS_NAMESPACE)?.get("type")?.as_str()
72 }
73
74 pub fn is_resolved(&self) -> bool {
77 self.metadata.contains_key(FS_NAMESPACE)
78 }
79}
80
81#[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 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 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct Graph {
139 #[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 pub fn composed() -> Self {
152 Self {
153 label: None,
154 directed: true,
155 nodes: BTreeMap::new(),
156 edges: Vec::new(),
157 }
158 }
159
160 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 pub fn set_node(&mut self, path: impl Into<String>, node: Node) {
172 self.nodes.insert(path.into(), node);
173 }
174
175 pub fn add_edge(&mut self, edge: Edge) {
177 self.edges.push(edge);
178 }
179
180 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 pub fn into_document(self) -> GraphDocument {
191 GraphDocument { graph: self }
192 }
193}
194
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198pub struct GraphDocument {
199 pub graph: Graph,
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205pub struct GraphSet {
206 pub graphs: Vec<Graph>,
207}
208
209impl GraphSet {
210 pub fn new(graphs: Vec<Graph>) -> Self {
212 Self { graphs }
213 }
214}
215
216#[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
233pub 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
246pub 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
258pub 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 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 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 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}