feat(ariadne): add graph artifacts

This commit is contained in:
2026-09-05 20:31:50 -04:00
parent 64773e0948
commit f0cccf3170
4 changed files with 430 additions and 1 deletions

View File

@@ -96,5 +96,10 @@
- In this monorepo, "dotfiles" refers to the dotfiles/NixOS configuration under `src/dotfiles/` and `configs/nixos/`. - In this monorepo, "dotfiles" refers to the dotfiles/NixOS configuration under `src/dotfiles/` and `configs/nixos/`.
- The flake does not currently define `formatter.x86_64-linux`. - The flake does not currently define `formatter.x86_64-linux`.
Do not use `nix fmt` as a validation command until a formatter is added. Do not use `nix fmt` as a validation command until a formatter is added.
- When creating a pull request from a worktree-specific development branch, create a dedicated pull-request branch
separate from the branch that names or anchors the worktree.
Do not open pull requests directly from the worktree branch.
- In Bazel `BUILD` files, depend on vendored Rust crates through the public aliases under
`//third_party/crates`, for example `//third_party/crates:serde`.
- Ariadne extraction must preserve the game-root season prefix for archived source. - Ariadne extraction must preserve the game-root season prefix for archived source.
Different game installs can contain compiled scripts with the same internal path. Different game installs can contain compiled scripts with the same internal path.

View File

@@ -1,9 +1,22 @@
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library") load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test")
rust_library( rust_library(
name = "ariadne_lib", name = "ariadne_lib",
crate_name = "ariadne", crate_name = "ariadne",
srcs = ["src/lib.rs"], srcs = ["src/lib.rs"],
deps = [
"//third_party/crates:serde",
"//third_party/crates:serde_json",
],
)
rust_test(
name = "ariadne_lib_test",
srcs = ["src/lib_test.rs"],
deps = [
":ariadne_lib",
"//third_party/crates:serde_json",
],
) )
rust_binary( rust_binary(

View File

@@ -1,3 +1,285 @@
use std::collections::{BTreeMap, BTreeSet};
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::fs::{self, File};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub type Metadata = BTreeMap<String, Value>;
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct NodeId(String);
impl NodeId {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for NodeId {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<String> for NodeId {
fn from(value: String) -> Self {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct SourceRef {
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub column: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Node {
pub id: NodeId,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<SourceRef>,
pub payload: Value,
}
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
pub struct Edge {
pub from: NodeId,
pub to: NodeId,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Delta {
AddNode { node: Node },
RemoveNode { node: Node },
ReplaceNode { old: Node, new: Node },
AddEdge { edge: Edge },
RemoveEdge { edge: Edge },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Graph {
pub metadata: Metadata,
pub nodes: BTreeMap<NodeId, Node>,
pub edges: BTreeSet<Edge>,
}
impl Graph {
pub fn new(metadata: Metadata) -> Self {
Self {
metadata,
nodes: BTreeMap::new(),
edges: BTreeSet::new(),
}
}
pub fn add_node(&mut self, node: Node) -> Result<Delta, GraphError> {
if self.nodes.contains_key(&node.id) {
return Err(GraphError::DuplicateNode { id: node.id });
}
self.nodes.insert(node.id.clone(), node.clone());
Ok(Delta::AddNode { node })
}
pub fn remove_node(&mut self, id: &NodeId) -> Result<Vec<Delta>, GraphError> {
let node = self
.nodes
.remove(id)
.ok_or_else(|| GraphError::MissingNode { id: id.clone() })?;
let incident_edges: Vec<_> = self
.edges
.iter()
.filter(|edge| edge.from == *id || edge.to == *id)
.cloned()
.collect();
let mut deltas = Vec::with_capacity(incident_edges.len() + 1);
for edge in incident_edges {
self.edges.remove(&edge);
deltas.push(Delta::RemoveEdge { edge });
}
deltas.push(Delta::RemoveNode { node });
Ok(deltas)
}
pub fn replace_node(&mut self, node: Node) -> Result<Delta, GraphError> {
let old = self
.nodes
.get_mut(&node.id)
.ok_or_else(|| GraphError::MissingNode {
id: node.id.clone(),
})?;
let previous = std::mem::replace(old, node.clone());
Ok(Delta::ReplaceNode {
old: previous,
new: node,
})
}
pub fn add_edge(&mut self, edge: Edge) -> Result<Delta, GraphError> {
self.require_node(&edge.from)?;
self.require_node(&edge.to)?;
if !self.edges.insert(edge.clone()) {
return Err(GraphError::DuplicateEdge { edge });
}
Ok(Delta::AddEdge { edge })
}
pub fn remove_edge(&mut self, edge: &Edge) -> Result<Delta, GraphError> {
if !self.edges.remove(edge) {
return Err(GraphError::MissingEdge { edge: edge.clone() });
}
Ok(Delta::RemoveEdge { edge: edge.clone() })
}
fn require_node(&self, id: &NodeId) -> Result<(), GraphError> {
if self.nodes.contains_key(id) {
Ok(())
} else {
Err(GraphError::MissingNode { id: id.clone() })
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GraphError {
DuplicateNode { id: NodeId },
MissingNode { id: NodeId },
DuplicateEdge { edge: Edge },
MissingEdge { edge: Edge },
}
impl Display for GraphError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
GraphError::DuplicateNode { id } => {
write!(formatter, "duplicate node `{}`", id.as_str())
}
GraphError::MissingNode { id } => write!(formatter, "missing node `{}`", id.as_str()),
GraphError::DuplicateEdge { edge } => write!(
formatter,
"duplicate edge `{} -> {}`",
edge.from.as_str(),
edge.to.as_str()
),
GraphError::MissingEdge { edge } => write!(
formatter,
"missing edge `{} -> {}`",
edge.from.as_str(),
edge.to.as_str()
),
}
}
}
impl Error for GraphError {}
pub fn write_graph_artifact(
directory: impl AsRef<Path>,
graph: &Graph,
delta: &[Delta],
) -> std::io::Result<()> {
let directory = directory.as_ref();
fs::create_dir_all(directory)?;
write_json_file(directory.join("metadata.json"), &graph.metadata)?;
write_jsonl_file(directory.join("nodes.jsonl"), graph.nodes.values())?;
write_jsonl_file(directory.join("edges.jsonl"), graph.edges.iter())?;
write_jsonl_file(directory.join("delta.jsonl"), delta.iter())?;
Ok(())
}
pub fn read_graph_artifact(directory: impl AsRef<Path>) -> std::io::Result<Graph> {
let directory = directory.as_ref();
let metadata = read_json_file(directory.join("metadata.json"))?;
let nodes: Vec<Node> = read_jsonl_file(directory.join("nodes.jsonl"))?;
let edges: Vec<Edge> = read_jsonl_file(directory.join("edges.jsonl"))?;
let mut graph = Graph::new(metadata);
for node in nodes {
graph.add_node(node).map_err(invalid_data)?;
}
for edge in edges {
graph.add_edge(edge).map_err(invalid_data)?;
}
Ok(graph)
}
pub fn read_graph_delta(directory: impl AsRef<Path>) -> std::io::Result<Vec<Delta>> {
read_jsonl_file(directory.as_ref().join("delta.jsonl"))
}
fn write_json_file(path: impl AsRef<Path>, value: &impl Serialize) -> std::io::Result<()> {
let file = File::create(path)?;
let writer = BufWriter::new(file);
serde_json::to_writer_pretty(writer, value).map_err(invalid_data)
}
fn read_json_file<T: for<'de> Deserialize<'de>>(path: impl AsRef<Path>) -> std::io::Result<T> {
let file = File::open(path)?;
let reader = BufReader::new(file);
serde_json::from_reader(reader).map_err(invalid_data)
}
fn write_jsonl_file<'a, T: 'a + Serialize>(
path: impl AsRef<Path>,
values: impl IntoIterator<Item = &'a T>,
) -> std::io::Result<()> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
for value in values {
serde_json::to_writer(&mut writer, value).map_err(invalid_data)?;
writer.write_all(b"\n")?;
}
Ok(())
}
fn read_jsonl_file<T: for<'de> Deserialize<'de>>(
path: impl AsRef<Path>,
) -> std::io::Result<Vec<T>> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut values = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
values.push(serde_json::from_str(&line).map_err(invalid_data)?);
}
Ok(values)
}
fn invalid_data(error: impl Error + Send + Sync + 'static) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidData, error)
}
pub fn run() { pub fn run() {
println!("Ariadne"); println!("Ariadne");
} }

129
src/ariadne/src/lib_test.rs Normal file
View File

@@ -0,0 +1,129 @@
use std::fs;
use ariadne::{
Delta, Edge, Graph, GraphError, Metadata, Node, NodeId, read_graph_artifact, read_graph_delta,
write_graph_artifact,
};
use serde_json::json;
fn node(id: &str, kind: &str) -> Node {
Node {
id: NodeId::from(id),
kind: kind.to_owned(),
source: None,
payload: json!({}),
}
}
#[test]
fn graph_records_node_and_edge_mutations() {
let mut graph = Graph::new(Metadata::new());
let add_start = graph.add_node(node("start", "label")).unwrap();
let add_end = graph.add_node(node("end", "label")).unwrap();
let edge = Edge {
from: NodeId::from("start"),
to: NodeId::from("end"),
};
let add_edge = graph.add_edge(edge.clone()).unwrap();
let remove_edge = graph.remove_edge(&edge).unwrap();
let add_edge_again = graph.add_edge(edge.clone()).unwrap();
let replace_start = graph.replace_node(node("start", "entry_label")).unwrap();
assert_eq!(graph.nodes.len(), 2);
assert_eq!(graph.edges.len(), 1);
assert!(matches!(add_start, Delta::AddNode { .. }));
assert!(matches!(add_end, Delta::AddNode { .. }));
assert_eq!(add_edge, Delta::AddEdge { edge: edge.clone() });
assert_eq!(remove_edge, Delta::RemoveEdge { edge: edge.clone() });
assert_eq!(add_edge_again, Delta::AddEdge { edge });
assert_eq!(
replace_start,
Delta::ReplaceNode {
old: node("start", "label"),
new: node("start", "entry_label")
}
);
}
#[test]
fn graph_rejects_edges_with_missing_endpoints() {
let mut graph = Graph::new(Metadata::new());
graph.add_node(node("start", "label")).unwrap();
let error = graph
.add_edge(Edge {
from: NodeId::from("start"),
to: NodeId::from("missing"),
})
.unwrap_err();
assert_eq!(
error,
GraphError::MissingNode {
id: NodeId::from("missing")
}
);
}
#[test]
fn removing_node_records_incident_edge_removals_before_node_removal() {
let mut graph = Graph::new(Metadata::new());
graph.add_node(node("start", "label")).unwrap();
graph.add_node(node("end", "label")).unwrap();
let edge = Edge {
from: NodeId::from("start"),
to: NodeId::from("end"),
};
graph.add_edge(edge.clone()).unwrap();
let deltas = graph.remove_node(&NodeId::from("start")).unwrap();
assert_eq!(
deltas,
vec![
Delta::RemoveEdge { edge },
Delta::RemoveNode {
node: node("start", "label")
}
]
);
assert!(graph.edges.is_empty());
assert!(!graph.nodes.contains_key(&NodeId::from("start")));
}
#[test]
fn graph_artifact_round_trips_complete_graph_and_delta() {
let directory = std::env::temp_dir().join(format!(
"ariadne-graph-artifact-test-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&directory);
let mut metadata = Metadata::new();
metadata.insert("phase".to_owned(), json!("source"));
let mut graph = Graph::new(metadata);
let mut delta = Vec::new();
delta.push(graph.add_node(node("start", "label")).unwrap());
delta.push(graph.add_node(node("end", "label")).unwrap());
delta.push(
graph
.add_edge(Edge {
from: NodeId::from("start"),
to: NodeId::from("end"),
})
.unwrap(),
);
write_graph_artifact(&directory, &graph, &delta).unwrap();
assert_eq!(read_graph_artifact(&directory).unwrap(), graph);
assert_eq!(read_graph_delta(&directory).unwrap(), delta);
assert!(directory.join("metadata.json").is_file());
assert!(directory.join("nodes.jsonl").is_file());
assert!(directory.join("edges.jsonl").is_file());
assert!(directory.join("delta.jsonl").is_file());
fs::remove_dir_all(directory).unwrap();
}