use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::{BTreeMap, BTreeSet};
pub const PROVENANCE_KEY: &str = "_graphs";
pub const FS_NAMESPACE: &str = "@fs";
pub fn namespace(label: &str) -> String {
format!("@{label}")
}
pub type Metadata = Map<String, Value>;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Node {
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub metadata: Metadata,
}
impl Node {
pub fn new(metadata: Metadata) -> Self {
Self { metadata }
}
pub fn fs_hash(&self) -> Option<&str> {
self.metadata.get(FS_NAMESPACE)?.get("hash")?.as_str()
}
pub fn fs_type(&self) -> Option<&str> {
self.metadata.get(FS_NAMESPACE)?.get("type")?.as_str()
}
pub fn is_resolved(&self) -> bool {
self.metadata.contains_key(FS_NAMESPACE)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Edge {
pub source: String,
pub target: String,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub metadata: Metadata,
}
impl Edge {
pub fn new(source: impl Into<String>, target: impl Into<String>) -> Self {
Self {
source: source.into(),
target: target.into(),
metadata: Metadata::new(),
}
}
pub fn with_metadata(
source: impl Into<String>,
target: impl Into<String>,
metadata: Metadata,
) -> Self {
Self {
source: source.into(),
target: target.into(),
metadata,
}
}
pub fn lines(&self) -> Vec<usize> {
let mut lines = BTreeSet::new();
for (key, value) in &self.metadata {
if !key.starts_with('@') {
continue;
}
if let Some(arr) = value.get("lines").and_then(Value::as_array) {
for n in arr.iter().filter_map(Value::as_u64) {
lines.insert(n as usize);
}
}
}
lines.into_iter().collect()
}
pub fn raw_links(&self) -> Vec<&str> {
let mut raws = BTreeSet::new();
for (key, value) in &self.metadata {
if !key.starts_with('@') {
continue;
}
if let Some(raw) = value.get("raw").and_then(Value::as_str) {
raws.insert(raw);
}
}
raws.into_iter().collect()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Graph {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub directed: bool,
#[serde(default)]
pub nodes: BTreeMap<String, Node>,
#[serde(default)]
pub edges: Vec<Edge>,
}
impl Graph {
pub fn composed() -> Self {
Self {
label: None,
directed: true,
nodes: BTreeMap::new(),
edges: Vec::new(),
}
}
pub fn labeled(label: impl Into<String>) -> Self {
Self {
label: Some(label.into()),
directed: true,
nodes: BTreeMap::new(),
edges: Vec::new(),
}
}
pub fn set_node(&mut self, path: impl Into<String>, node: Node) {
self.nodes.insert(path.into(), node);
}
pub fn add_edge(&mut self, edge: Edge) {
self.edges.push(edge);
}
pub fn sort_edges(&mut self) {
self.edges.sort_by(|a, b| {
a.source
.cmp(&b.source)
.then_with(|| a.target.cmp(&b.target))
});
}
pub fn into_document(self) -> GraphDocument {
GraphDocument { graph: self }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GraphDocument {
pub graph: Graph,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GraphSet {
pub graphs: Vec<Graph>,
}
impl GraphSet {
pub fn new(graphs: Vec<Graph>) -> Self {
Self { graphs }
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ValidationError {
#[error("graph label must not be empty")]
EmptyLabel,
#[error("graph label '{0}' must not contain '@' or start with '_'")]
SigilInLabel(String),
#[error("raw metadata key '{0}' must be bare (no leading '@' or '_')")]
SigilInRawKey(String),
#[error(
"composed metadata key '{0}' is invalid: expected an '@<graph>' namespace or '_graphs'"
)]
InvalidComposedKey(String),
#[error("composed metadata namespace '@{0}' must name a bare graph (no '@' or '_')")]
InvalidNamespace(String),
}
pub fn validate_label(label: &str) -> Result<(), ValidationError> {
if label.is_empty() {
return Err(ValidationError::EmptyLabel);
}
if label.contains('@') || label.starts_with('_') {
return Err(ValidationError::SigilInLabel(label.to_string()));
}
Ok(())
}
pub fn validate_raw_metadata(metadata: &Metadata) -> Result<(), ValidationError> {
for key in metadata.keys() {
if key.starts_with('@') || key.starts_with('_') {
return Err(ValidationError::SigilInRawKey(key.clone()));
}
}
Ok(())
}
pub fn validate_composed_metadata(metadata: &Metadata) -> Result<(), ValidationError> {
for key in metadata.keys() {
if key == PROVENANCE_KEY {
continue;
}
match key.strip_prefix('@') {
Some(name) => validate_label(name)
.map_err(|_| ValidationError::InvalidNamespace(name.to_string()))?,
None => return Err(ValidationError::InvalidComposedKey(key.clone())),
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn meta(value: Value) -> Metadata {
value.as_object().unwrap().clone()
}
#[test]
fn edge_lines_unions_namespaces_sorted() {
let mut m = Metadata::new();
m.insert("@markdown".into(), json!({ "lines": [5, 2] }));
m.insert("@frontmatter".into(), json!({ "lines": [2, 9] }));
m.insert("_graphs".into(), json!(["@markdown", "@frontmatter"]));
let edge = Edge::with_metadata("a.md", "b.md", m);
assert_eq!(edge.lines(), vec![2, 5, 9], "unioned, sorted, deduped");
assert!(Edge::new("a.md", "b.md").lines().is_empty());
}
#[test]
fn composed_document_round_trips() {
let mut graph = Graph::composed();
graph.set_node(
"src/graph.rs",
Node::new(meta(json!({
"@fs": { "type": "file", "hash": "b3:444" },
"_graphs": ["@fs"]
}))),
);
graph.set_node(
"docs/architecture.md",
Node::new(meta(json!({
"@fs": { "type": "file", "hash": "b3:222" },
"@frontmatter": { "title": "Architecture", "status": "draft" },
"_graphs": ["@fs", "@frontmatter"]
}))),
);
graph.add_edge(Edge::with_metadata(
"docs/architecture.md",
"src/graph.rs",
meta(json!({ "_graphs": ["@markdown", "@frontmatter"] })),
));
let doc = graph.into_document();
let json = serde_json::to_value(&doc).unwrap();
assert!(json.get("graph").is_some());
assert!(json["graph"].get("label").is_none());
assert_eq!(json["graph"]["directed"], json!(true));
let back: GraphDocument = serde_json::from_value(json).unwrap();
assert_eq!(doc, back);
}
#[test]
fn raw_set_round_trips() {
let mut fs = Graph::labeled("fs");
fs.set_node(
"src/graph.rs",
Node::new(meta(json!({ "type": "file", "hash": "b3:444" }))),
);
let mut markdown = Graph::labeled("markdown");
markdown.add_edge(Edge::new("docs/architecture.md", "src/graph.rs"));
let set = GraphSet::new(vec![fs, markdown]);
let json = serde_json::to_value(&set).unwrap();
let graphs = json["graphs"].as_array().unwrap();
assert_eq!(graphs.len(), 2);
assert_eq!(graphs[0]["label"], json!("fs"));
assert_eq!(graphs[1]["label"], json!("markdown"));
let back: GraphSet = serde_json::from_value(json).unwrap();
assert_eq!(set, back);
}
#[test]
fn empty_metadata_is_omitted() {
let mut graph = Graph::composed();
graph.set_node("a.md", Node::default());
graph.add_edge(Edge::new("a.md", "b.md"));
let json = serde_json::to_value(graph.into_document()).unwrap();
assert!(json["graph"]["nodes"]["a.md"].get("metadata").is_none());
assert!(json["graph"]["edges"][0].get("metadata").is_none());
}
#[test]
fn node_keys_are_sorted() {
let mut graph = Graph::composed();
graph.set_node("z.md", Node::default());
graph.set_node("a.md", Node::default());
graph.set_node("m.md", Node::default());
let json = serde_json::to_string(&graph.into_document()).unwrap();
let a = json.find("a.md").unwrap();
let m = json.find("m.md").unwrap();
let z = json.find("z.md").unwrap();
assert!(a < m && m < z, "node keys should serialize in sorted order");
}
#[test]
fn validate_label_accepts_bare() {
assert!(validate_label("fs").is_ok());
assert!(validate_label("markdown").is_ok());
assert!(validate_label("frontmatter").is_ok());
}
#[test]
fn validate_label_rejects_sigils_and_empty() {
assert_eq!(validate_label(""), Err(ValidationError::EmptyLabel));
assert!(matches!(
validate_label("@fs"),
Err(ValidationError::SigilInLabel(_))
));
assert!(matches!(
validate_label("_internal"),
Err(ValidationError::SigilInLabel(_))
));
assert!(validate_label("design_docs").is_ok());
}
#[test]
fn validate_raw_metadata_rejects_sigil_keys() {
assert!(validate_raw_metadata(&meta(json!({ "type": "file" }))).is_ok());
assert!(matches!(
validate_raw_metadata(&meta(json!({ "@fs": {} }))),
Err(ValidationError::SigilInRawKey(_))
));
assert!(matches!(
validate_raw_metadata(&meta(json!({ "_graphs": [] }))),
Err(ValidationError::SigilInRawKey(_))
));
}
#[test]
fn validate_composed_metadata_accepts_namespaces_and_provenance() {
assert!(
validate_composed_metadata(&meta(json!({
"@fs": { "type": "file" },
"_graphs": ["@fs"]
})))
.is_ok()
);
}
#[test]
fn validate_composed_metadata_rejects_bare_and_bad_namespace() {
assert!(matches!(
validate_composed_metadata(&meta(json!({ "type": "file" }))),
Err(ValidationError::InvalidComposedKey(_))
));
assert!(matches!(
validate_composed_metadata(&meta(json!({ "@_internal": {} }))),
Err(ValidationError::InvalidNamespace(_))
));
}
}