diff --git a/AGENTS.md b/AGENTS.md index bd8749d..f83866c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,5 +96,10 @@ - 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`. 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. +- Load `crate_deps` from `//third_party/crates:crates.bzl`, not `//third_party/crates:defs.bzl`. + The compatibility wrapper currently exports a symbol that generated `crates.bzl` does not define. - Ariadne extraction must preserve the game-root season prefix for archived source. Different game installs can contain compiled scripts with the same internal path. diff --git a/src/ariadne/BUILD b/src/ariadne/BUILD index c3f343f..d6e4d18 100644 --- a/src/ariadne/BUILD +++ b/src/ariadne/BUILD @@ -1,9 +1,17 @@ -load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library") +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test") +load("//third_party/crates:crates.bzl", "crate_deps") rust_library( name = "ariadne_lib", crate_name = "ariadne", srcs = ["src/lib.rs"], + deps = crate_deps(["serde", "serde_json"]), +) + +rust_test( + name = "ariadne_lib_test", + srcs = ["src/lib_test.rs"], + deps = [":ariadne_lib"] + crate_deps(["serde_json"]), ) rust_binary( diff --git a/src/ariadne/Cargo.toml b/src/ariadne/Cargo.toml index 2d5ae86..4c5df11 100644 --- a/src/ariadne/Cargo.toml +++ b/src/ariadne/Cargo.toml @@ -6,3 +6,7 @@ edition = "2024" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" + +[[test]] +name = "ariadne_lib_test" +path = "src/lib_test.rs" diff --git a/src/ariadne/src/lib.rs b/src/ariadne/src/lib.rs index 8f9e795..5e6ec8d 100644 --- a/src/ariadne/src/lib.rs +++ b/src/ariadne/src/lib.rs @@ -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; + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct NodeId(String); + +impl NodeId { + pub fn new(value: impl Into) -> 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 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub column: Option, +} + +#[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, + 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, + pub edges: BTreeSet, +} + +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 { + 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, 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 { + 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 { + 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 { + 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, + 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) -> std::io::Result { + let directory = directory.as_ref(); + let metadata = read_json_file(directory.join("metadata.json"))?; + let nodes: Vec = read_jsonl_file(directory.join("nodes.jsonl"))?; + let edges: Vec = 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) -> std::io::Result> { + read_jsonl_file(directory.as_ref().join("delta.jsonl")) +} + +fn write_json_file(path: impl AsRef, 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 Deserialize<'de>>(path: impl AsRef) -> std::io::Result { + 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, + values: impl IntoIterator, +) -> 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 Deserialize<'de>>( + path: impl AsRef, +) -> std::io::Result> { + 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() { println!("Ariadne"); } diff --git a/src/ariadne/src/lib_test.rs b/src/ariadne/src/lib_test.rs new file mode 100644 index 0000000..834a4c0 --- /dev/null +++ b/src/ariadne/src/lib_test.rs @@ -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(); +}