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 pub fn raw_links(&self) -> Vec<&str> {
137 let mut raws = BTreeSet::new();
138 for (key, value) in &self.metadata {
139 if !key.starts_with('@') {
140 continue;
141 }
142 if let Some(raw) = value.get("raw").and_then(Value::as_str) {
143 raws.insert(raw);
144 }
145 }
146 raws.into_iter().collect()
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155pub struct Graph {
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub label: Option<String>,
159 pub directed: bool,
160 #[serde(default)]
161 pub nodes: BTreeMap<String, Node>,
162 #[serde(default)]
163 pub edges: Vec<Edge>,
164}
165
166impl Graph {
167 pub fn composed() -> Self {
169 Self {
170 label: None,
171 directed: true,
172 nodes: BTreeMap::new(),
173 edges: Vec::new(),
174 }
175 }
176
177 pub fn labeled(label: impl Into<String>) -> Self {
179 Self {
180 label: Some(label.into()),
181 directed: true,
182 nodes: BTreeMap::new(),
183 edges: Vec::new(),
184 }
185 }
186
187 pub fn set_node(&mut self, path: impl Into<String>, node: Node) {
189 self.nodes.insert(path.into(), node);
190 }
191
192 pub fn add_edge(&mut self, edge: Edge) {
194 self.edges.push(edge);
195 }
196
197 pub fn sort_edges(&mut self) {
199 self.edges.sort_by(|a, b| {
200 a.source
201 .cmp(&b.source)
202 .then_with(|| a.target.cmp(&b.target))
203 });
204 }
205
206 pub fn into_document(self) -> GraphDocument {
208 GraphDocument { graph: self }
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215pub struct GraphDocument {
216 pub graph: Graph,
217}
218
219#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222pub struct GraphSet {
223 pub graphs: Vec<Graph>,
224}
225
226impl GraphSet {
227 pub fn new(graphs: Vec<Graph>) -> Self {
229 Self { graphs }
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
235pub enum ValidationError {
236 #[error("graph label must not be empty")]
237 EmptyLabel,
238 #[error("graph label '{0}' must not contain '@' or start with '_'")]
239 SigilInLabel(String),
240 #[error("raw metadata key '{0}' must be bare (no leading '@' or '_')")]
241 SigilInRawKey(String),
242 #[error(
243 "composed metadata key '{0}' is invalid: expected an '@<graph>' namespace or '_graphs'"
244 )]
245 InvalidComposedKey(String),
246 #[error("composed metadata namespace '@{0}' must name a bare graph (no '@' or '_')")]
247 InvalidNamespace(String),
248}
249
250pub fn validate_label(label: &str) -> Result<(), ValidationError> {
254 if label.is_empty() {
255 return Err(ValidationError::EmptyLabel);
256 }
257 if label.contains('@') || label.starts_with('_') {
258 return Err(ValidationError::SigilInLabel(label.to_string()));
259 }
260 Ok(())
261}
262
263pub fn validate_raw_metadata(metadata: &Metadata) -> Result<(), ValidationError> {
267 for key in metadata.keys() {
268 if key.starts_with('@') || key.starts_with('_') {
269 return Err(ValidationError::SigilInRawKey(key.clone()));
270 }
271 }
272 Ok(())
273}
274
275pub fn validate_composed_metadata(metadata: &Metadata) -> Result<(), ValidationError> {
278 for key in metadata.keys() {
279 if key == PROVENANCE_KEY {
280 continue;
281 }
282 match key.strip_prefix('@') {
283 Some(name) => validate_label(name)
284 .map_err(|_| ValidationError::InvalidNamespace(name.to_string()))?,
285 None => return Err(ValidationError::InvalidComposedKey(key.clone())),
286 }
287 }
288 Ok(())
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use serde_json::json;
295
296 fn meta(value: Value) -> Metadata {
297 value.as_object().unwrap().clone()
298 }
299
300 #[test]
301 fn edge_lines_unions_namespaces_sorted() {
302 let mut m = Metadata::new();
303 m.insert("@markdown".into(), json!({ "lines": [5, 2] }));
304 m.insert("@frontmatter".into(), json!({ "lines": [2, 9] }));
305 m.insert("_graphs".into(), json!(["@markdown", "@frontmatter"]));
306 let edge = Edge::with_metadata("a.md", "b.md", m);
307 assert_eq!(edge.lines(), vec![2, 5, 9], "unioned, sorted, deduped");
308 assert!(Edge::new("a.md", "b.md").lines().is_empty());
309 }
310
311 #[test]
312 fn composed_document_round_trips() {
313 let mut graph = Graph::composed();
314 graph.set_node(
315 "src/graph.rs",
316 Node::new(meta(json!({
317 "@fs": { "type": "file", "hash": "b3:444" },
318 "_graphs": ["@fs"]
319 }))),
320 );
321 graph.set_node(
322 "docs/architecture.md",
323 Node::new(meta(json!({
324 "@fs": { "type": "file", "hash": "b3:222" },
325 "@frontmatter": { "title": "Architecture", "status": "draft" },
326 "_graphs": ["@fs", "@frontmatter"]
327 }))),
328 );
329 graph.add_edge(Edge::with_metadata(
330 "docs/architecture.md",
331 "src/graph.rs",
332 meta(json!({ "_graphs": ["@markdown", "@frontmatter"] })),
333 ));
334
335 let doc = graph.into_document();
336 let json = serde_json::to_value(&doc).unwrap();
337
338 assert!(json.get("graph").is_some());
340 assert!(json["graph"].get("label").is_none());
341 assert_eq!(json["graph"]["directed"], json!(true));
342
343 let back: GraphDocument = serde_json::from_value(json).unwrap();
344 assert_eq!(doc, back);
345 }
346
347 #[test]
348 fn raw_set_round_trips() {
349 let mut fs = Graph::labeled("fs");
350 fs.set_node(
351 "src/graph.rs",
352 Node::new(meta(json!({ "type": "file", "hash": "b3:444" }))),
353 );
354
355 let mut markdown = Graph::labeled("markdown");
356 markdown.add_edge(Edge::new("docs/architecture.md", "src/graph.rs"));
357
358 let set = GraphSet::new(vec![fs, markdown]);
359 let json = serde_json::to_value(&set).unwrap();
360
361 let graphs = json["graphs"].as_array().unwrap();
363 assert_eq!(graphs.len(), 2);
364 assert_eq!(graphs[0]["label"], json!("fs"));
365 assert_eq!(graphs[1]["label"], json!("markdown"));
366
367 let back: GraphSet = serde_json::from_value(json).unwrap();
368 assert_eq!(set, back);
369 }
370
371 #[test]
372 fn empty_metadata_is_omitted() {
373 let mut graph = Graph::composed();
374 graph.set_node("a.md", Node::default());
375 graph.add_edge(Edge::new("a.md", "b.md"));
376 let json = serde_json::to_value(graph.into_document()).unwrap();
377 assert!(json["graph"]["nodes"]["a.md"].get("metadata").is_none());
378 assert!(json["graph"]["edges"][0].get("metadata").is_none());
379 }
380
381 #[test]
382 fn node_keys_are_sorted() {
383 let mut graph = Graph::composed();
384 graph.set_node("z.md", Node::default());
385 graph.set_node("a.md", Node::default());
386 graph.set_node("m.md", Node::default());
387 let json = serde_json::to_string(&graph.into_document()).unwrap();
388 let a = json.find("a.md").unwrap();
389 let m = json.find("m.md").unwrap();
390 let z = json.find("z.md").unwrap();
391 assert!(a < m && m < z, "node keys should serialize in sorted order");
392 }
393
394 #[test]
395 fn validate_label_accepts_bare() {
396 assert!(validate_label("fs").is_ok());
397 assert!(validate_label("markdown").is_ok());
398 assert!(validate_label("frontmatter").is_ok());
399 }
400
401 #[test]
402 fn validate_label_rejects_sigils_and_empty() {
403 assert_eq!(validate_label(""), Err(ValidationError::EmptyLabel));
404 assert!(matches!(
405 validate_label("@fs"),
406 Err(ValidationError::SigilInLabel(_))
407 ));
408 assert!(matches!(
409 validate_label("_internal"),
410 Err(ValidationError::SigilInLabel(_))
411 ));
412 assert!(validate_label("design_docs").is_ok());
414 }
415
416 #[test]
417 fn validate_raw_metadata_rejects_sigil_keys() {
418 assert!(validate_raw_metadata(&meta(json!({ "type": "file" }))).is_ok());
419 assert!(matches!(
420 validate_raw_metadata(&meta(json!({ "@fs": {} }))),
421 Err(ValidationError::SigilInRawKey(_))
422 ));
423 assert!(matches!(
424 validate_raw_metadata(&meta(json!({ "_graphs": [] }))),
425 Err(ValidationError::SigilInRawKey(_))
426 ));
427 }
428
429 #[test]
430 fn validate_composed_metadata_accepts_namespaces_and_provenance() {
431 assert!(
432 validate_composed_metadata(&meta(json!({
433 "@fs": { "type": "file" },
434 "_graphs": ["@fs"]
435 })))
436 .is_ok()
437 );
438 }
439
440 #[test]
441 fn validate_composed_metadata_rejects_bare_and_bad_namespace() {
442 assert!(matches!(
443 validate_composed_metadata(&meta(json!({ "type": "file" }))),
444 Err(ValidationError::InvalidComposedKey(_))
445 ));
446 assert!(matches!(
447 validate_composed_metadata(&meta(json!({ "@_internal": {} }))),
448 Err(ValidationError::InvalidNamespace(_))
449 ));
450 }
451}